From 8d0938e0dc0bb5ddf0c32bacac38f5b88da6d9bc Mon Sep 17 00:00:00 2001 From: Oleh H Date: Fri, 25 Sep 2026 18:39:16 +0000 Subject: [PATCH 01/11] Validate with shared Zod schemas, and move forms to react-hook-form Rules, rule edits, URL matchers and actions are each described by one Zod schema in src/shared, bound to its type with z.toZod. The main process reads IPC input and files with them (parseInput), replacing the hand-written sanitizers and checks, and the forms check with the same schemas through zodResolver: one definition of what is valid, with the same messages. - Rule pages keep their edits in a react-hook-form control made outside React (createFormControl), kept by page id as Monaco models are; the tab holds only a dirty flag, kept in step by the form's subscribe. Header rows use useFieldArray, replacing the row-key bookkeeping. Apply, Enter and Ctrl/Cmd+S go through handleSubmit, and an Apply landing rebases what was typed meanwhile (reset with keepValues, so rows keep their keys and focus). - Each invalid field says why under it (FieldError, aria-invalid, aria-describedby), and submitting focuses the first one. - The action form and the frame-name form use useForm; the action form checks with the store's own schema. - The renderer runs Zod jitless: its probe for new Function breaks the CSP. CLAUDE.md gains the rules for forms and validation. --- CHANGELOG.md | 2 +- CLAUDE.md | 9 + docs/DESIGN_SYSTEM.md | 9 +- docs/SPEC.md | 15 +- package-lock.json | 188 ++++++++++++++++-- package.json | 3 + src/main/ipc/registerRuleIpc.ts | 10 +- src/main/store/ActionStore/ActionStore.ts | 8 +- src/main/store/ActionStore/actionFields.ts | 41 ---- .../store/ActionStore/actionPatchSchema.ts | 11 + src/main/store/ActionStore/newActionSchema.ts | 4 + .../store/ActionStore/sanitizeStoredAction.ts | 16 +- src/main/store/OverrideStore/assertMatcher.ts | 5 +- src/main/store/RuleStore/RuleStore.ts | 16 +- src/main/store/RuleStore/patchRule.ts | 7 +- src/main/store/constants.ts | 3 +- src/main/store/invalidRule.ts | 4 - src/main/store/parseInput.ts | 15 ++ src/main/store/ruleFieldSanitizers.ts | 10 - src/main/store/rulePatchSchema.ts | 11 + src/main/store/sanitizeFrameNames.ts | 4 +- src/main/store/sanitizeHeaderEdits.ts | 14 -- src/main/store/sanitizeMatcher.ts | 9 - src/main/store/sanitizeResourceTypes.ts | 11 - src/main/store/sanitizeRuleFields.ts | 8 - src/main/store/sanitizeRuleInput.ts | 22 -- src/main/store/sanitizeRulePatch.ts | 20 -- src/main/store/sanitizeStoredRule.ts | 15 +- src/main/store/types.ts | 9 +- .../gallery/sections/Controls/InputsBlock.tsx | 24 ++- src/renderer/src/app/index.tsx | 4 + .../src/app/model/bridge/events/syncRules.ts | 6 +- src/renderer/src/entities/editor-tab/index.ts | 1 - .../entities/editor-tab/model/store/index.ts | 2 +- .../editor-tab/model/store/pageDirtyChecks.ts | 4 +- .../entities/editor-tab/model/store/types.ts | 24 +-- .../editor-tab/model/store/useTabStore.ts | 10 +- .../action/edit/ui/ActionForm/ActionForm.tsx | 71 ++++--- .../edit-match-rule/model/match/applyMatch.ts | 5 +- .../features/name-frame/ui/FrameNameForm.tsx | 23 +-- src/renderer/src/features/rule/edit/index.ts | 2 +- .../features/rule/edit/model/applyRulePage.ts | 63 +++--- .../src/features/rule/edit/model/constants.ts | 11 +- .../rule/edit/model/createRulePage.ts | 42 ++-- .../rule/edit/model/dropClosedRuleForms.ts | 12 ++ .../src/features/rule/edit/model/index.ts | 5 +- .../rule/edit/model/initialRowKeys.ts | 7 - .../features/rule/edit/model/isNewRowKey.ts | 6 - .../features/rule/edit/model/nextRowKey.ts | 7 - .../features/rule/edit/model/openNewRule.ts | 10 +- .../rule/edit/model/openRuleEditor.ts | 7 +- .../features/rule/edit/model/openRuleForm.ts | 19 ++ .../rule/edit/model/rebaseRuleDraft.ts | 14 -- .../rule/edit/model/rebaseRuleForm.ts | 19 ++ .../features/rule/edit/model/rowKeyCounter.ts | 2 - .../src/features/rule/edit/model/ruleForms.ts | 8 + .../features/rule/edit/model/setRuleDraft.ts | 7 - .../src/features/rule/edit/model/types.ts | 15 +- .../rule/edit/ui/RuleForm/ActionFields.tsx | 7 +- .../rule/edit/ui/RuleForm/HeaderEditRow.tsx | 47 +++-- .../edit/ui/RuleForm/HeaderEditsField.tsx | 47 ++--- .../rule/edit/ui/RuleForm/RuleForm.tsx | 74 +------ .../rule/edit/ui/RuleForm/RuleFormFields.tsx | 66 ++++++ .../rule/edit/ui/RuleForm/RuleNotes.tsx | 7 +- .../rule/edit/ui/RuleForm/actionFields.ts | 2 +- .../features/rule/edit/ui/RuleForm/types.ts | 36 ++-- .../rule/edit/ui/RuleForm/withPreset.ts | 9 +- .../src/shared/lib/form/firstErrorMessage.ts | 18 ++ src/renderer/src/shared/lib/form/index.ts | 1 + src/renderer/src/shared/lib/index.ts | 1 + .../src/shared/ui/field-error/FieldError.tsx | 19 ++ .../src/shared/ui/field-error/index.ts | 1 + .../UrlMatcherFields/UrlMatcherFields.tsx | 12 +- .../ui/url-matcher/UrlMatcherFields/types.ts | 5 + .../editor-panel/ui/RulePage/NewRulePage.tsx | 6 +- .../editor-panel/ui/RulePage/RulePage.tsx | 8 +- src/shared/actions/actionInputSchema.ts | 31 +++ src/shared/actions/index.ts | 1 + src/shared/constants.ts | 3 + src/shared/matcher/compileRegex.ts | 4 +- src/shared/matcher/index.ts | 2 +- src/shared/matcher/urlMatcherSchema.ts | 23 +++ src/shared/matcher/validateMatcher.ts | 14 -- src/shared/rules/checkRuleFields.ts | 8 - src/shared/rules/headerEditSchema.ts | 19 ++ src/shared/rules/headerEditsSchema.ts | 9 + src/shared/rules/headerNameSchema.ts | 10 + src/shared/rules/headerValueChecks.ts | 12 -- src/shared/rules/headerValueSchemas.ts | 12 ++ src/shared/rules/index.ts | 16 +- src/shared/rules/isHeaderOperation.ts | 5 - src/shared/rules/isMatchType.ts | 5 - src/shared/rules/isRuleAction.ts | 5 - src/shared/rules/resourceTypesSchema.ts | 10 + src/shared/rules/ruleInputChecks.ts | 18 -- src/shared/rules/ruleInputSchema.ts | 26 +++ src/shared/rules/ruleMatcherSchema.ts | 8 + src/shared/rules/types.ts | 8 +- src/shared/rules/validateHeaderEdit.ts | 15 -- src/shared/rules/validateRuleInput.ts | 14 -- src/shared/rules/validateRuleMatcher.ts | 11 - src/shared/types/api.ts | 2 +- src/shared/validation/firstIssue.ts | 7 + src/shared/validation/index.ts | 1 + test/renderer/bridge-events.test.ts | 31 ++- test/renderer/pageTabs.test.ts | 37 ++-- test/renderer/rules.test.ts | 116 +++++++---- test/renderer/session.test.ts | 6 +- test/unit/matcher.test.ts | 11 +- test/unit/ruleSanitizers.test.ts | 72 +++---- test/unit/ruleValidation.test.ts | 7 +- 111 files changed, 1057 insertions(+), 817 deletions(-) delete mode 100644 src/main/store/ActionStore/actionFields.ts create mode 100644 src/main/store/ActionStore/actionPatchSchema.ts create mode 100644 src/main/store/ActionStore/newActionSchema.ts delete mode 100644 src/main/store/invalidRule.ts create mode 100644 src/main/store/parseInput.ts delete mode 100644 src/main/store/ruleFieldSanitizers.ts create mode 100644 src/main/store/rulePatchSchema.ts delete mode 100644 src/main/store/sanitizeHeaderEdits.ts delete mode 100644 src/main/store/sanitizeMatcher.ts delete mode 100644 src/main/store/sanitizeResourceTypes.ts delete mode 100644 src/main/store/sanitizeRuleFields.ts delete mode 100644 src/main/store/sanitizeRuleInput.ts delete mode 100644 src/main/store/sanitizeRulePatch.ts create mode 100644 src/renderer/src/features/rule/edit/model/dropClosedRuleForms.ts delete mode 100644 src/renderer/src/features/rule/edit/model/initialRowKeys.ts delete mode 100644 src/renderer/src/features/rule/edit/model/isNewRowKey.ts delete mode 100644 src/renderer/src/features/rule/edit/model/nextRowKey.ts create mode 100644 src/renderer/src/features/rule/edit/model/openRuleForm.ts delete mode 100644 src/renderer/src/features/rule/edit/model/rebaseRuleDraft.ts create mode 100644 src/renderer/src/features/rule/edit/model/rebaseRuleForm.ts delete mode 100644 src/renderer/src/features/rule/edit/model/rowKeyCounter.ts create mode 100644 src/renderer/src/features/rule/edit/model/ruleForms.ts delete mode 100644 src/renderer/src/features/rule/edit/model/setRuleDraft.ts create mode 100644 src/renderer/src/features/rule/edit/ui/RuleForm/RuleFormFields.tsx create mode 100644 src/renderer/src/shared/lib/form/firstErrorMessage.ts create mode 100644 src/renderer/src/shared/lib/form/index.ts create mode 100644 src/renderer/src/shared/ui/field-error/FieldError.tsx create mode 100644 src/renderer/src/shared/ui/field-error/index.ts create mode 100644 src/shared/actions/actionInputSchema.ts create mode 100644 src/shared/actions/index.ts create mode 100644 src/shared/matcher/urlMatcherSchema.ts delete mode 100644 src/shared/matcher/validateMatcher.ts delete mode 100644 src/shared/rules/checkRuleFields.ts create mode 100644 src/shared/rules/headerEditSchema.ts create mode 100644 src/shared/rules/headerEditsSchema.ts create mode 100644 src/shared/rules/headerNameSchema.ts delete mode 100644 src/shared/rules/headerValueChecks.ts create mode 100644 src/shared/rules/headerValueSchemas.ts delete mode 100644 src/shared/rules/isHeaderOperation.ts delete mode 100644 src/shared/rules/isMatchType.ts delete mode 100644 src/shared/rules/isRuleAction.ts create mode 100644 src/shared/rules/resourceTypesSchema.ts delete mode 100644 src/shared/rules/ruleInputChecks.ts create mode 100644 src/shared/rules/ruleInputSchema.ts create mode 100644 src/shared/rules/ruleMatcherSchema.ts delete mode 100644 src/shared/rules/validateHeaderEdit.ts delete mode 100644 src/shared/rules/validateRuleInput.ts delete mode 100644 src/shared/rules/validateRuleMatcher.ts create mode 100644 src/shared/validation/firstIssue.ts create mode 100644 src/shared/validation/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57489a4d..9cc99138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added - **Original sources from source maps.** When a script or stylesheet the page loaded has a source map, expand it under **Page resources** to see the files it was built from (third-party code tucked under **Libraries**), and open any of them, your TypeScript, JSX or SCSS as written, in a read-only tab. **Go to bundle code** takes you from a line of an original to the code it became in the bundle, pretty-printed or not, even in your override, and **Go to original source** takes you back; Ctrl/Cmd+Shift+M goes either way. The command palette and the Explorer filter find original files too. -- **Rules: block requests and change response headers.** Right-click a file in the Explorer to block it (an analytics script, a slow third-party iframe) or to remove a page's Content-Security-Policy, in one click with an Undo. Or add a rule from the new **Rules** section: block every request matching a URL pattern, set or remove response headers (with presets for CSP, framing and caching), or let the page call an API on another origin, preflights and cookies included. A blocked request never reaches the server, and the file stays in the tree, struck through. Each rule shows how often it applied and its recent requests, turns on and off with a switch, and belongs to its workspace. Edits on a rule's page stay on its tab until you apply them (**Apply**, Enter or Ctrl/⌘+S). +- **Rules: block requests and change response headers.** Right-click a file in the Explorer to block it (an analytics script, a slow third-party iframe) or to remove a page's Content-Security-Policy, in one click with an Undo. Or add a rule from the new **Rules** section: block every request matching a URL pattern, set or remove response headers (with presets for CSP, framing and caching), or let the page call an API on another origin, preflights and cookies included. A blocked request never reaches the server, and the file stays in the tree, struck through. Each rule shows how often it applied and its recent requests, turns on and off with a switch, and belongs to its workspace. Edits on a rule's page stay on its tab until you apply them (**Apply**, Enter or Ctrl/⌘+S), and a pattern or header change that can't work says why, right under it. - **The website in a window of its own.** Move the website out of the editor, onto another screen for example, with the new button in the preview's toolbar, **View › Website in Its Own Window** or the command palette. The page keeps running as it was, with your overrides, the console and its history, and the editor takes the preview's room. Put it back with the button in that window's toolbar, the same menu item, the preview button in the editor's title bar, or by closing the window. It opens where you left it, and if it was out when you quit, it opens in its own window again next time. - **Actions: code you run in a frame, one click away.** Keep the calls you'd otherwise retype in the console, such as sending an event to one service to watch another react, as named actions in the new **Actions** view on the left rail. Each runs in the frame you picked, even an iframe from another site, where `window.top.frames['cart'].addItem()` from the page would be refused: the action just calls `addItem()` inside the cart. Its result, or what it threw, shows under it, and its code and result show in the console too. Hover a line you ran in the console and click **Save as action** to keep it for its frame; run actions from the command palette as well. Each workspace has its own actions. Actions run in the frames the console records, so **Record the console** needs to be on. - **The Actions panel in a window of its own.** Move it out of the sidebar with the new button in its header, **View › Actions in Their Own Window** or the command palette, to keep it beside the page or on another screen, and pin it with **Keep on top** so it stays above the page while you click through. Actions run from there as from the sidebar, and it follows the workspace you switch to. Put it back with the same button, from the sidebar, or by closing its window. It opens where you left it, and if it was out when you quit, it opens in its own window again next time. diff --git a/CLAUDE.md b/CLAUDE.md index cd96c731..d6628a01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,3 +86,12 @@ Other component rules: - Compute cheap derived values during render; `useMemo`/`useCallback` only for expensive work or a reference something depends on. - Subscribe to narrow store slices and select stable references (a selector returning a new object re-renders forever; use `useShallow`). - Keys are stable ids; an index is fine only for static lists that never reorder. + +## Forms and validation + +- **One Zod schema per shape, in `src/shared`** (`rules/`, `actions/`, `matcher/`), bound to its type in `src/shared/types` with `z.toZod()` so the two can't drift. The main process reads IPC input and files with it (`parseInput`: unknown keys dropped, `Invalid : ` for a wrong shape) and the form checks with the same one. Never write a second check of the same rule. +- **Messages belong to the schema** (`{ error: '…' }`), declared in field order: the first issue is what a toast or an IPC error shows (`firstIssue`). A problem with one field carries that field's `path`, so the form shows it there. +- **Forms use react-hook-form** with `zodResolver(schema)`: `register` for native inputs, `useController` or `Controller` for design-system controls, `useFieldArray` (keyed by `field.id`) for lists, `useWatch` (not `watch`) for values shown elsewhere. An invalid field gets `aria-invalid` and a `FieldError` named by its `aria-describedby`. Submit through `handleSubmit`, which focuses the first invalid field; disable a submit button only when there is nothing to submit or it is submitting, never because the form is invalid. +- **Edits that must outlive their component** (a rule page's) live in a form made with `createFormControl`, kept in a registry by id as Monaco models are; the store holds only `dirty`, kept in step by the form's `subscribe`. Never copy form values into a store. +- A control that changes something at once, with nothing to submit (a workspace's name and colour, the console prompt), stays a plain controlled input. +- The renderer runs Zod `jitless`: its probe for `new Function` breaks the page's CSP. diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 669ff821..77f22834 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -108,7 +108,8 @@ Each component lives in its own folder with an `index.ts` public API. Props belo |---|---|---| | `Button` | `variant: 'primary' \| 'secondary' \| 'ghost' \| 'danger'`, `size: 'sm' \| 'md'`, `leading?/trailing?: ReactNode`, `loading?` | press spring; primary uses `--accent-grad` | | `IconButton` | `icon`, `label` (tooltip + aria-label), `active?`, `size?` | square 28 px, tooltip built in | -| `Input` | `leading?`, `trailing?`, `size?`, `mono?` + native props | focus ring = accent at 40% | +| `Input` | `leading?`, `trailing?`, `size?`, `mono?`, `invalid?` + native props (`ref` is the ``) | focus ring = accent at 40%; `invalid` (or `aria-invalid`) turns frame and ring `--danger` | +| `FieldError` | `id`, `message?` + `

` props | what is wrong with a field, under it (`text-danger`, 12 px, `role="alert"`); renders nothing without a message; the field names it in `aria-describedby` | | `Kbd` | `keys: string[]` | renders ⌘/Ctrl per platform | | `Badge` | `tone: 'neutral' \| 'live' \| 'warning' \| 'info' \| 'accent'`, `dot?` | pill, 11 px | | `Counter` | `value: number` | digit roll (number ticker) | @@ -118,7 +119,7 @@ Each component lives in its own folder with an `index.ts` public API. Props belo | `CommandPalette` | `open`, `onOpenChange`, `groups: {heading, items: CommandItem[]}[]`, `placeholder` | fuzzy filter, spring panel, moving highlight | | `ToastStack` + `toast()` | `toast({title, description?, tone?, action?})` | stacked, swipe/auto-dismiss, bottom-left of the editor; its width is capped by `--preview-w` (published by the preview pane) so it never reaches the native page view | | `ConfirmDialog` + `confirm()` | `confirm({title, body, confirmLabel, tone}) => Promise` | replaces `window.confirm` | -| `UrlMatcherFields` | `value: UrlMatcher`, `onChange`, `onEnter?`, `testIdPrefix?`, `autoFocus?` | the match type menu, the pattern and "ignore ?query", as siblings the caller lays out in a wrapping row; shared by an override's match row and rule pages | +| `UrlMatcherFields` | `value: UrlMatcher`, `onChange`, `onEnter?`, `error?`, `patternRef?`, `testIdPrefix?`, `autoFocus?` | the match type menu, the pattern and "ignore ?query", as siblings the caller lays out in a wrapping row (the pattern's `error` on a line of its own); shared by an override's match row and rule pages | | `Popover` | `open`, `onOpenChange`, `anchor: HTMLElement \| null`, `side?: 'right' \| 'bottom'`, `label` | a few controls beside an element (editing a workspace's tile); not modal: Esc (focus back on the anchor), a press or focus outside, window blur or resize close it; registers as an overlay like menus | | `HoverHighlight` | wraps a list; one pill follows the hovered row | port of beUI SharedLayoutBg | | `Collapsible` / `Section` | `title`, `count?`, `actions?`, `defaultOpen?` | height auto animation, caps header | @@ -170,8 +171,8 @@ Code shared with the main process (`src/shared`: IPC types, URL matching) is imp - **Events are batched:** the bridge queues resource, navigation, iframe and worker events and applies them in order once per animation frame (every 250 ms while the window is hidden), so a page reporting thousands of files rebuilds the tree once per frame, not once per file. Console rows already come batched from the main process (every 50 ms at most), so each batch is one store update. - **One IPC bridge**: `startBridge()` in `app/model/bridge/` subscribes to `window.consoleEditor.onEvent` once and routes events into entity stores (and main-menu commands into features) through typed handler tables, one handler per event type and per menu command. - **Selectors everywhere**: components subscribe to the smallest slice (`useStore(s => s.byId[id])`), lists use `useShallow`; derived data (resource tree, filtered lists) is computed in `lib/` and memoized. -- **Page tabs keep their own edits.** A tab that isn't a file (What's New, a rule page, a new-rule page) is a `PageTab`, a union by `page` kind. Each kind declares whether it belongs to the app or to the workspace (`PAGE_SCOPES`: workspace pages close on a switch), whether it holds edits (`PAGE_DIRTY_CHECKS`), and which view renders it (`PAGE_VIEWS` in `widgets/editor-panel`), so a new kind fails typecheck until it has all three. A rule page's unapplied edits are a draft on its tab (`{ base, value, rowKeys }`), not form state, so they survive switching tabs; the form shows them only while `base` is still the saved rule, and the rule's own Apply landing rebases what was typed meanwhile. -- **Non-serializable objects stay out of stores**: Monaco models and editor instances live in registries (`entities/editor-tab/model/models/`, `shared/monaco/editors/`) keyed by tab id; the store only holds metadata (dirty, saved version, diff mode). Decoded source maps live in the source-map worker; `entities/source-map` keeps each bundle's state and file list. +- **Page tabs keep their own edits.** A tab that isn't a file (What's New, a rule page, a new-rule page) is a `PageTab`, a union by `page` kind. Each kind declares whether it belongs to the app or to the workspace (`PAGE_SCOPES`: workspace pages close on a switch), whether it holds edits (`PAGE_DIRTY_CHECKS`), and which view renders it (`PAGE_VIEWS` in `widgets/editor-panel`), so a new kind fails typecheck until it has all three. A rule page's unapplied edits live in its form, a react-hook-form control made outside React (`createFormControl`) and kept by page id in `features/rule/edit/model/ruleForms`, so they survive switching tabs and Save reaches them; the tab only holds `dirty`, which the form's `subscribe` keeps in step. The form's defaults are the saved rule: the rule's own Apply landing rebases what was typed meanwhile onto the new version (`reset(saved, { keepValues: true })`, so header rows keep their keys and focus). +- **Non-serializable objects stay out of stores**: Monaco models and editor instances live in registries (`entities/editor-tab/model/models/`, `shared/monaco/editors/`) keyed by tab id, rule pages' forms in theirs; the store only holds metadata (dirty, saved version, diff mode). Decoded source maps live in the source-map worker; `entities/source-map` keeps each bundle's state and file list. ## 7. Accessibility diff --git a/docs/SPEC.md b/docs/SPEC.md index a33773cc..f2de754d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -84,7 +84,8 @@ flowchart LR src/ shared/ types/ (IPC + data model, one file per domain), constants.ts (gallery hash, env var names), ipcChannels.ts (the IPC channel of each API method, for main and preload), - matcher/ (URL matching), rules/ (rule validation, shared by the form and the store), + matcher/ (URL matching), rules/ and actions/ (their Zod schemas: what the forms check and main + parses IPC and files with), validation/ (a schema's first problem, as a message), minified/, version/ (semver comparison), changelog/ (CHANGELOG.md sections) main/ @@ -120,7 +121,7 @@ src/ cdp/ (transport interface), websocketTransport/ (browser-level CDP, used by tests), constants.ts (CDP command and event names, HTTP status classes) store/ OverrideStore/, SessionStore/, RuleStore/, ActionStore/ (each a store with its file and record helpers), SettingsStore.ts, - WindowStore/ (an own window's place: the website's, the Actions panel's), WriteQueue.ts, writeAtomic.ts and shared sanitizers + WindowStore/ (an own window's place: the website's, the Actions panel's), WriteQueue.ts, writeAtomic.ts, parseInput.ts (untrusted input read with a schema) and shared sanitizers update/ UpdateService/ (checks, downloads, installs: §10.1), electronInstaller/ (electron-updater), updateEndpoints.ts (GitHub, or a local update server in tests) sitePermissions/ permission policy for the site view @@ -141,9 +142,9 @@ src/ edit-match-rule, navigate-page, filter-resources, update-settings, close-tab, update-app (notifications, the What's New page, the status-bar entry), edit-workspace, run-in-frame, filter-console, name-frame, clear-console, expand-console-value, detach-page, - rule/ (quick-actions: block or remove a CSP in one click; edit: rule pages and their form; + rule/ (quick-actions: block or remove a CSP in one click; edit: rule pages and their forms; toggle; delete), action/ (a group: run, edit, detach) - entities/ page, settings, override, editor-tab (+ Monaco model registry, page tabs and their drafts, + entities/ page, settings, override, editor-tab (+ Monaco model registry, page tabs and whether they hold edits, read-only source tabs), resource, source-map (each bundle's map state, the originals tree), app-update (updater state, the bundled CHANGELOG.md), workspace (+ its rail tile), frame (the page's frames, their labels and colours, finding an action's frame), console-log, @@ -498,7 +499,7 @@ An action is code kept to run in a frame of the page with one click: an event se | Large files | Scripts, stylesheets and HTML over 1 M characters open in a lite mode: syntax colouring only (Monarch grammars, no language service or validation, folding, minimap or bracket colourization), shown as "Large file" | | Focus | Opening or switching tabs (original sources too) focuses the editor; closing a tab from the keyboard, typing in a field or arrowing through the Explorer never has focus pulled into the code | | Rules | The Explorer's **Rules** section lists the workspace's rules, oldest first: what each does, its pattern, a switch, and how often it applied this session. **+** starts a rule of each kind (Block requests, Change response headers, Allow cross-origin requests); right-click to edit, turn on or off, copy the pattern or delete (asked first). A file's context menu blocks it (by its exact URL, any query) or its iframe, removes a document's Content-Security-Policy, or starts a header rule for it, all but the last in one click with an Undo in the toast; a blocked file stays in the tree, struck through, and offers to unblock it (turning its rule off) or open its rule. The page's own document can't be blocked. The palette lists rules (open, turn on or off) and the new-rule actions | -| Rule pages | A rule opens as a page tab, one per rule: the URL matcher (as an override's), request types, and the action's fields (header changes in order, with name suggestions and presets: remove CSP, allow framing, `Cache-Control: no-store`), notes on what the rule as written can't do (a regex pauses every request; the page itself is never blocked; the HTTP cache keeps the server's headers; CSP in a `` tag isn't a header; CORS needs a CORS rule), and the last 20 URLs it applied to. Edits are a draft on the tab until **Apply** (Enter, Ctrl/Cmd+S): invalid input isn't sent, only the changed fields are, and what is typed while it runs stays as a draft. A draft survives switching tabs but not a restart, so closing its tab, switching workspaces or quitting asks first. A new rule's page has **Create** instead and becomes the rule's page. Rules apply from the next request, so the page reloads after a change if that setting is on | +| Rule pages | A rule opens as a page tab, one per rule: the URL matcher (as an override's), request types, and the action's fields (header changes in order, with name suggestions and presets: remove CSP, allow framing, `Cache-Control: no-store`), notes on what the rule as written can't do (a regex pauses every request; the page itself is never blocked; the HTTP cache keeps the server's headers; CSP in a `` tag isn't a header; CORS needs a CORS rule), and the last 20 URLs it applied to. Edits stay in the page's form until **Apply** (Enter, Ctrl/Cmd+S): each field says what is wrong with it as it is typed (the rule's schema, the one main checks too), invalid input isn't sent (the first invalid field takes focus), only the changed fields are, and what is typed while it runs stays as edits to the version it saved. The form lives outside the page's component, keyed by its tab, so its edits survive switching tabs but not a restart: closing its tab, switching workspaces or quitting asks first. A new rule's page has **Create** instead and becomes the rule's page. Rules apply from the next request, so the page reloads after a change if that setting is on | | Workspaces | The rail lists them below Explorer, Actions and Search: a tile each (site favicon or the name's first letter, on the workspace's colour), the active one marked, + to add one. Clicking another tile switches to it (§5.1); clicking the active one opens a popover to rename it and pick its icon and colour, applied as you change them; right-click for the same, or to delete it. The tooltip shows the name and the page title. The palette lists them too | | Close with unsaved edits | Closing a tab asks first. Closing the app keeps every unsaved edit as a draft and reopens it next time, with the tabs and the last page (see §5, Session restore); unapplied rule edits aren't kept, so it asks first when there are some | | Menu | App menu replaces Electron's default, so Ctrl/Cmd+R reloads **the site**, not the editor. Undo/redo/select-all are routed to Monaco (or to the website, or the website window's address bar, when one of those has focus). Page DevTools: Ctrl/Cmd+Shift+J; editor DevTools: Ctrl/Cmd+Alt+I. Ctrl/Cmd+B toggles the sidebar. **View › Website in Its Own Window** moves the website either way (§7.1). **View › Go to Original Source or Bundle Code** (Ctrl/Cmd+Shift+M) jumps whichever way applies, and says so on other tabs | @@ -525,7 +526,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g - Packaged builds flip Electron's fuses: `ELECTRON_RUN_AS_NODE`, `NODE_OPTIONS` and the `--inspect` switches are ignored, the app loads only from its `app.asar`, whose integrity is checked on macOS and Windows, and the site view's cookies are encrypted with the OS keystore (not yet on macOS: an ad-hoc signed app would be asked for the Keychain password after every update). `file://` keeps its extra privileges because the editor UI and its module workers load from it. - Packaged builds drop `--remote-debugging-port`/`--remote-debugging-pipe` when the data folder is the default one (compared after resolving links, so pointing `CONSOLE_EDITOR_USER_DATA` at it doesn't count as another), as Chrome does for its default profile: otherwise any local program could start the app with a port and read the site view's logins. - One instance per data folder (`app.requestSingleInstanceLock`), so two processes never write the same overrides and session files. A second launch hands its URL to the running window and exits; if the first is still starting, that URL replaces the one it was about to open. On macOS, where Finder and `open` reopen the running app instead of starting a second process, the app also takes URLs from `open-url` the same way (following Electron's documentation; not yet checked on a Mac). Runs from source use a separate `… (dev)` data folder, so they never share one with an installed copy. -- IPC inputs are type-checked; matchers and rules are validated before storage (the same checks the form runs: header names are tokens, values hold no line breaks, protected headers are refused, at most 32 header changes and 200 rules per workspace); settings are filtered to known boolean keys. Source-map calls take only an http(s) bundle URL: main derives the map URL itself. +- IPC inputs are type-checked; rules, rule edits, matchers and actions are parsed with the Zod schemas the forms use (`src/shared/rules`, `src/shared/actions`) before storage: unknown keys are dropped, a field of the wrong shape is refused by name (`Invalid rule: match`), and a value by what is wrong with it (header names are tokens, values hold no line breaks, protected headers are refused, at most 32 header changes and 200 rules per workspace). Files are read with the same schemas. Settings are filtered to known boolean keys. Source-map calls take only an http(s) bundle URL: main derives the map URL itself. - Original sources are only ever shown as editor text, and their names (which the page chooses) as plain labels with control characters removed. - Rules change only what the app's own browser sees. A CORS rule makes an API readable by the page shown, credentials included, which is what it is for; it can't change what the server allows, and cookies still follow the browser's rules. - The site sees a standard Chrome user agent (Electron tokens removed). @@ -537,7 +538,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g | Layer | What | Command | |---|---|---| | Unit | Matchers, header/SRI/source-map transforms, stores (persistence, atomic concurrent writes), engine logic and navigation rules with a fake CDP transport, iframe session coordination (timeouts, sessions that go away, cascading detach), worker sessions (setup order and resuming, `Fetch` kept on, settings per worker type, which session lists and credits a worker's files, missed-override reasons, outdated service workers and `prepareReload` (unregistering by scope, letting go, retrying), a service worker's state kept for its next session, shared-worker discovery and holding), minified heuristic, version comparison, CHANGELOG parsing (and that CHANGELOG.md covers `package.json`'s version), the update service with fakes (checks, quiet failures, progress, checksums, install failures, schedule), the Linux desktop entry (quoting paths, which copies install one, a package's entry winning, removing only its own, following a renamed AppImage, writing only what changed) and the `.rpm`'s install script kept in step with electron-builder's, workspaces in the stores (migration, per-workspace tabs, drafts, overrides and actions, deletion, frame names), the action store (each workspace's actions across instances, what it refuses and why, a failed write changing nothing, changes run in order), favicon loading (recognising images, size caps), the console service with a fake CDP transport (frames across sessions, contexts, rows and their previews, format directives, running code, properties, the cap, clearing, the setting), the session observer (handed every session before it runs, never holding interception up) and where the website's and the Actions panel's windows open (its saved place while it is on a screen, another screen than the editor's, fitted to a small one, at the size each opens at) and how that is saved (Keep on top too), rules (validation, the store's persistence, entries it can't read, broken and unreadable files, the engine's stages, header edits, CORS and preflights, request types, patterns), source maps (finding the reference, loading limits, header capture) | `npm test` | -| Renderer | Resource tree building and filtering (worker entries: kept across navigations for service and shared workers, dropped with their worker, filtered by worker URL), command-palette fuzzy matching, missed-override notices by reason (a nested worker's once per override version), update notifications, What's New and page tabs, session sync and workspace switching (pending drafts written, tabs closed without losing them, the other workspace's reopened), console frames (keys, labels, colours), rows, filters, Keep rows, prompt history and frame names, rules (quick rules and Undo, toggling with rollback, rule pages and their drafts, applying only what changed, results that arrive after a switch, hits and recent requests), original sources (parsing, positions through pretty-printing, tabs, jumps both ways, the tree, the palette), actions (finding an action's frame by key or by name, labels of frames not on the page, runs and why one couldn't run, the form's openings, saving and deleting, copies, the palette's group, the bridge loading and following them), the Actions window's bridge (what it loads, the events it follows and those it ignores) and the editor's copy of where the panel is and of settings another window changed | `npm test` | +| Renderer | Resource tree building and filtering (worker entries: kept across navigations for service and shared workers, dropped with their worker, filtered by worker URL), command-palette fuzzy matching, missed-override notices by reason (a nested worker's once per override version), update notifications, What's New and page tabs, session sync and workspace switching (pending drafts written, tabs closed without losing them, the other workspace's reopened), console frames (keys, labels, colours), rows, filters, Keep rows, prompt history and frame names, rules (quick rules and Undo, toggling with rollback, rule pages and their forms, applying only what changed, results that arrive after a switch, hits and recent requests), original sources (parsing, positions through pretty-printing, tabs, jumps both ways, the tree, the palette), actions (finding an action's frame by key or by name, labels of frames not on the page, runs and why one couldn't run, the form's openings, saving and deleting, copies, the palette's group, the bridge loading and following them), the Actions window's bridge (what it loads, the events it follows and those it ignores) and the editor's copy of where the panel is and of settings another window changed | `npm test` | | Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`) | `npm run lint:fsd`, `npm run lint:structure` | | Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's | `npm test` (skips if no Chromium; `npx playwright install chromium`) | | End-to-end | Built Electron app driven by Playwright: open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | diff --git a/package-lock.json b/package-lock.json index f44ffa18..5e730aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@hookform/resolvers": "^5.9.1", "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", "@jridgewell/trace-mapping": "^0.3.31", @@ -25,8 +26,10 @@ "motion": "^13.4.2", "react": "^19.3.0", "react-dom": "^19.3.0", + "react-hook-form": "^7.88.0", "react-use-measure": "^2.1.7", "tailwind-merge": "^3.7.0", + "zod": "^4.6.5", "zustand": "^5.0.15" }, "devDependencies": { @@ -1229,6 +1232,116 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hookform/resolvers": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.1.tgz", + "integrity": "sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0 || ^18.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=6.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@hugeicons/core-free-icons": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.3.5.tgz", @@ -1769,6 +1882,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2668,7 +2787,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4722,7 +4841,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4746,7 +4865,7 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5585,7 +5704,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-stringify-safe": { @@ -6847,6 +6966,22 @@ "react": "^19.3.0" } }, + "node_modules/react-hook-form": { + "version": "7.88.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz", + "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-use-measure": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", @@ -6919,7 +7054,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7317,6 +7452,29 @@ "steiger": "dist/cli.mjs" } }, + "node_modules/steiger/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/steiger/node_modules/zod-validation-error": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz", + "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.24.4" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -8378,28 +8536,14 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz", - "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.24.4" - } - }, "node_modules/zustand": { "version": "5.0.15", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", diff --git a/package.json b/package.json index 72213768..6853f160 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@hookform/resolvers": "^5.9.1", "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", "@jridgewell/trace-mapping": "^0.3.31", @@ -81,8 +82,10 @@ "motion": "^13.4.2", "react": "^19.3.0", "react-dom": "^19.3.0", + "react-hook-form": "^7.88.0", "react-use-measure": "^2.1.7", "tailwind-merge": "^3.7.0", + "zod": "^4.6.5", "zustand": "^5.0.15" }, "overrides": { diff --git a/src/main/ipc/registerRuleIpc.ts b/src/main/ipc/registerRuleIpc.ts index aac88b2d..87fb84e4 100644 --- a/src/main/ipc/registerRuleIpc.ts +++ b/src/main/ipc/registerRuleIpc.ts @@ -1,23 +1,23 @@ import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { PageController } from '../PageController'; +import { parseInput } from '../store/parseInput'; import type { RuleStore } from '../store/RuleStore'; -import { sanitizeRuleInput } from '../store/sanitizeRuleInput'; -import { sanitizeRulePatch } from '../store/sanitizeRulePatch'; +import { rulePatchSchema } from '../store/rulePatchSchema'; import { toRule } from '../store/toRule'; import { assertString } from './assertString'; import type { IpcHandle } from './types'; -/** The rules' channels, for the editor UI. 'rules-changed' is sent before each reply resolves. */ +/** The rules' channels, for the editor UI. The store checks what they are given; 'rules-changed' is sent before each reply resolves. */ export function registerRuleIpc(handle: IpcHandle, rules: RuleStore, page: PageController): void { handle(IPC_CHANNEL.listRules, () => rules.forRenderer()); handle(IPC_CHANNEL.createRule, async (input: unknown) => { - const created = await rules.create(sanitizeRuleInput(input)); + const created = await rules.create(input); await page.rulesChanged(); return toRule(created); }); handle(IPC_CHANNEL.updateRule, async (id: unknown, patch: unknown) => { assertString(id, 'id'); - const clean = sanitizeRulePatch(patch); + const clean = parseInput(rulePatchSchema, patch, 'rule'); const updated = await rules.update(id, clean); // Only the matcher and on/off change Fetch patterns; request types and header edits are read at pause time. await page.rulesChanged(clean.match !== undefined || clean.enabled !== undefined); diff --git a/src/main/store/ActionStore/ActionStore.ts b/src/main/store/ActionStore/ActionStore.ts index 749d493a..3f79d679 100644 --- a/src/main/store/ActionStore/ActionStore.ts +++ b/src/main/store/ActionStore/ActionStore.ts @@ -1,9 +1,11 @@ import { join } from 'node:path'; import type { ConsoleAction } from '../../../shared/types'; +import { parseInput } from '../parseInput'; import { WriteQueue } from '../WriteQueue'; -import { actionFields } from './actionFields'; +import { actionPatchSchema } from './actionPatchSchema'; import { ACTIONS_FILE, MAX_ACTIONS } from './constants'; import { newAction } from './newAction'; +import { newActionSchema } from './newActionSchema'; import { readActions } from './readActions'; import { toAction } from './toAction'; import type { StoredAction } from './types'; @@ -45,7 +47,7 @@ export class ActionStore { } async create(input: unknown): Promise { - const fields = actionFields(input, true); + const fields = parseInput(newActionSchema, input, 'action'); // The workspace active when it was asked for, even if another becomes active before it is written. const { workspaceId } = this; return this.mutate((actions) => { @@ -57,7 +59,7 @@ export class ActionStore { } async update(id: unknown, patch: unknown): Promise { - const fields = actionFields(patch, false); + const fields = parseInput(actionPatchSchema, patch, 'action'); return this.mutate((actions) => { // Found in the latest state, so queued updates don't undo each other. const index = actions.findIndex((a) => a.id === id); diff --git a/src/main/store/ActionStore/actionFields.ts b/src/main/store/ActionStore/actionFields.ts deleted file mode 100644 index 33206819..00000000 --- a/src/main/store/ActionStore/actionFields.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { MAX_ACTION_CODE, MAX_ACTION_NAME } from '../../../shared/constants'; -import type { ActionInput, ActionPatch } from '../../../shared/types'; -import { MAX_FRAME_ADDRESS } from '../constants'; - -/** Checks each field and what it becomes, by field; a missing field is left out. */ -const FIELDS: { [K in keyof ActionInput]: (value: unknown) => ActionInput[K] } = { - name: (value) => { - const name = typeof value === 'string' ? value.trim().slice(0, MAX_ACTION_NAME) : ''; - if (!name) throw new Error('An action needs a name'); - return name; - }, - target: (value) => { - if (typeof value !== 'string' || !value || value.length > MAX_FRAME_ADDRESS) throw new Error('An action needs a frame to run in'); - return value; - }, - targetName: (value = '') => { - if (typeof value !== 'string' || value.length > MAX_FRAME_ADDRESS) throw new Error('targetName must be a string'); - return value; - }, - code: (value) => { - if (typeof value !== 'string' || !value.trim()) throw new Error('An action needs code to run'); - if (value.length > MAX_ACTION_CODE) throw new Error('That code is too long for an action'); - return value; - }, -}; - -/** - * The fields of `input` that are there, checked (names trimmed); throws, with a - * readable message, when one isn't valid. `whole`: all of them must be there. - */ -export function actionFields(input: unknown, whole: true): ActionInput; -export function actionFields(input: unknown, whole: false): ActionPatch; -export function actionFields(input: unknown, whole: boolean): ActionPatch { - if (!input || typeof input !== 'object') throw new Error('An action is needed'); - const given = input as Record; - const fields: Record = {}; - for (const [key, check] of Object.entries(FIELDS)) { - if (given[key] !== undefined || whole) fields[key] = check(given[key]); - } - return fields as ActionPatch; -} diff --git a/src/main/store/ActionStore/actionPatchSchema.ts b/src/main/store/ActionStore/actionPatchSchema.ts new file mode 100644 index 00000000..01d701c0 --- /dev/null +++ b/src/main/store/ActionStore/actionPatchSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { actionInputSchema } from '../../../shared/actions'; +import type { ActionPatch } from '../../../shared/types'; + +const { name, target, targetName, code } = actionInputSchema.shape; + +/** An action edit: only the fields given, each checked as a new action's. A field given as undefined is refused, not dropped. */ +export const actionPatchSchema = z.object( + { name: name.exactOptional(), target: target.exactOptional(), targetName: targetName.exactOptional(), code: code.exactOptional() }, + { error: 'An action is needed' }, +) satisfies z.ZodType; diff --git a/src/main/store/ActionStore/newActionSchema.ts b/src/main/store/ActionStore/newActionSchema.ts new file mode 100644 index 00000000..c00970fb --- /dev/null +++ b/src/main/store/ActionStore/newActionSchema.ts @@ -0,0 +1,4 @@ +import { actionInputSchema } from '../../../shared/actions'; + +/** A new action, or one read from the file: without a frame name, it has none. */ +export const newActionSchema = actionInputSchema.extend({ targetName: actionInputSchema.shape.targetName.default('') }); diff --git a/src/main/store/ActionStore/sanitizeStoredAction.ts b/src/main/store/ActionStore/sanitizeStoredAction.ts index faa378d8..14403236 100644 --- a/src/main/store/ActionStore/sanitizeStoredAction.ts +++ b/src/main/store/ActionStore/sanitizeStoredAction.ts @@ -1,6 +1,6 @@ import { WORKSPACE_ID } from '../constants'; -import { actionFields } from './actionFields'; import { ACTION_ID } from './constants'; +import { newActionSchema } from './newActionSchema'; import type { StoredAction } from './types'; /** A well-formed action from the file, or null, so a corrupt file can't inject junk. */ @@ -8,12 +8,10 @@ export function sanitizeStoredAction(input: unknown): StoredAction | null { const a = input as Partial | null; if (!a || typeof a.id !== 'string' || !ACTION_ID.test(a.id)) return null; if (typeof a.workspaceId !== 'string' || !WORKSPACE_ID.test(a.workspaceId)) return null; - try { - const now = Date.now(); - const createdAt = typeof a.createdAt === 'number' ? a.createdAt : now; - const updatedAt = typeof a.updatedAt === 'number' ? a.updatedAt : createdAt; - return { id: a.id, workspaceId: a.workspaceId, ...actionFields(a, true), createdAt, updatedAt }; - } catch { - return null; - } + const fields = newActionSchema.safeParse(a); + if (!fields.success) return null; + const now = Date.now(); + const createdAt = typeof a.createdAt === 'number' ? a.createdAt : now; + const updatedAt = typeof a.updatedAt === 'number' ? a.updatedAt : createdAt; + return { id: a.id, workspaceId: a.workspaceId, ...fields.data, createdAt, updatedAt }; } diff --git a/src/main/store/OverrideStore/assertMatcher.ts b/src/main/store/OverrideStore/assertMatcher.ts index 9dc2725b..960dc04a 100644 --- a/src/main/store/OverrideStore/assertMatcher.ts +++ b/src/main/store/OverrideStore/assertMatcher.ts @@ -1,8 +1,9 @@ -import { validateMatcher } from '../../../shared/matcher'; +import { urlMatcherSchema } from '../../../shared/matcher'; import type { UrlMatcher } from '../../../shared/types'; +import { firstIssue } from '../../../shared/validation'; /** Throws, with a readable message, when `match` can't be used. */ export function assertMatcher(match: UrlMatcher): void { - const error = validateMatcher(match); + const error = firstIssue(urlMatcherSchema, match); if (error) throw new Error(error); } diff --git a/src/main/store/RuleStore/RuleStore.ts b/src/main/store/RuleStore/RuleStore.ts index a407aa98..0433022c 100644 --- a/src/main/store/RuleStore/RuleStore.ts +++ b/src/main/store/RuleStore/RuleStore.ts @@ -1,10 +1,10 @@ import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { compareRuleAge, validateRuleInput } from '../../../shared/rules'; -import type { CreateRuleInput, Rule, RulePatch } from '../../../shared/types'; +import { compareRuleAge, ruleInputSchema } from '../../../shared/rules'; +import type { Rule, RulePatch } from '../../../shared/types'; import { isRecord } from '../isRecord'; -import { sanitizeRuleInput } from '../sanitizeRuleInput'; -import { sanitizeRulePatch } from '../sanitizeRulePatch'; +import { parseInput } from '../parseInput'; +import { rulePatchSchema } from '../rulePatchSchema'; import { toRule } from '../toRule'; import type { RuleState, StoredRule } from '../types'; import { WriteQueue } from '../WriteQueue'; @@ -80,17 +80,15 @@ export class RuleStore { } /** Adds an enabled rule to the workspace active when this is called, even if another is by the time it is written. */ - async create(input: CreateRuleInput): Promise { - const clean = sanitizeRuleInput(input); - const error = validateRuleInput(clean); - if (error) throw new Error(error); + async create(input: unknown): Promise { + const clean = parseInput(ruleInputSchema, input, 'rule'); const { workspaceId } = this; return this.mutate((state) => addRule(state, workspaceId, clean)); } /** Edits a rule of any workspace, on top of the latest committed state (queued edits don't undo each other). */ async update(id: string, patch: RulePatch): Promise { - const clean = sanitizeRulePatch(patch); + const clean = parseInput(rulePatchSchema, patch, 'rule'); return this.mutate((state) => patchRule(state, id, clean)); } diff --git a/src/main/store/RuleStore/patchRule.ts b/src/main/store/RuleStore/patchRule.ts index 8e6e1873..508a5b6a 100644 --- a/src/main/store/RuleStore/patchRule.ts +++ b/src/main/store/RuleStore/patchRule.ts @@ -1,15 +1,16 @@ -import { validateRuleInput } from '../../../shared/rules'; +import { ruleInputSchema } from '../../../shared/rules'; import type { RulePatch } from '../../../shared/types'; +import { firstIssue } from '../../../shared/validation'; import type { RuleState, StoredRule } from '../types'; -/** Applies a (sanitized) patch to a rule in `state`: header edits only on a header rule, and the result must be valid. */ +/** Applies a (parsed) patch to a rule in `state`: header edits only on a header rule, and the result must be valid. */ export function patchRule(state: RuleState, id: string, patch: RulePatch): StoredRule { const current = state.rules.get(id); if (!current) throw new Error(`Unknown rule ${id}`); if (patch.headers !== undefined && current.action !== 'headers') throw new Error('Only header rules have headers'); // The patch holds only fields `current`'s action has (checked above), so the merge stays that member. const next = { ...current, ...patch, updatedAt: Date.now() } as StoredRule; - const error = validateRuleInput(next); + const error = firstIssue(ruleInputSchema, next); if (error) throw new Error(error); state.rules.set(id, next); return next; diff --git a/src/main/store/constants.ts b/src/main/store/constants.ts index cd619c76..d5f58eec 100644 --- a/src/main/store/constants.ts +++ b/src/main/store/constants.ts @@ -17,6 +17,5 @@ export const DEFAULT_WORKSPACE_ICON: WorkspaceIcon = 'favicon'; export const RULE_ID_BYTES = 4; export const RULE_ID = new RegExp(`^[0-9a-f]{${RULE_ID_BYTES * 2}}$`); -/** Frame names a workspace keeps, and the longest frame address one is filed under. */ +/** Frame names a workspace keeps. */ export const MAX_FRAME_NAMES = 200; -export const MAX_FRAME_ADDRESS = 2048; diff --git a/src/main/store/invalidRule.ts b/src/main/store/invalidRule.ts deleted file mode 100644 index 62d0f266..00000000 --- a/src/main/store/invalidRule.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Refuses rule input whose `field` has the wrong shape (from IPC or disk). */ -export function invalidRule(field: string): never { - throw new Error(`Invalid rule: ${field}`); -} diff --git a/src/main/store/parseInput.ts b/src/main/store/parseInput.ts new file mode 100644 index 00000000..d10b9c6f --- /dev/null +++ b/src/main/store/parseInput.ts @@ -0,0 +1,15 @@ +import type { z } from 'zod'; + +/** What an input that isn't an object is called in its message. */ +const WHOLE_INPUT = 'not an object'; + +/** + * Reads untrusted input (IPC, a file) as `schema`: its known fields only, as fresh copies. Throws + * the first problem's message; a field of the wrong shape, which has no message of its own, is + * `Invalid : `. + */ +export function parseInput(schema: S, input: unknown, what: string): z.output { + const result = schema.safeParse(input, { error: (issue) => `Invalid ${what}: ${String(issue.path?.[0] ?? WHOLE_INPUT)}` }); + if (!result.success) throw new Error(result.error.issues[0]!.message); + return result.data; +} diff --git a/src/main/store/ruleFieldSanitizers.ts b/src/main/store/ruleFieldSanitizers.ts deleted file mode 100644 index 41076f01..00000000 --- a/src/main/store/ruleFieldSanitizers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { invalidRule } from './invalidRule'; -import { sanitizeHeaderEdits } from './sanitizeHeaderEdits'; -import type { RuleFieldSanitizers } from './types'; - -/** How each action's own fields are read from untrusted input. A new action fails typecheck until it has one. */ -export const RULE_FIELD_SANITIZERS: RuleFieldSanitizers = { - block: () => ({}), - cors: () => ({}), - headers: (input) => ({ headers: sanitizeHeaderEdits(input.headers) ?? invalidRule('headers') }), -}; diff --git a/src/main/store/rulePatchSchema.ts b/src/main/store/rulePatchSchema.ts new file mode 100644 index 00000000..27acfa69 --- /dev/null +++ b/src/main/store/rulePatchSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { headerEditsSchema, resourceTypesSchema, ruleMatcherSchema } from '../../shared/rules'; +import type { RulePatch } from '../../shared/types'; + +/** A rule edit: only the fields given, each checked as a rule's. A field given as undefined is refused, not dropped. */ +export const rulePatchSchema = z.object({ + match: ruleMatcherSchema.exactOptional(), + resourceTypes: resourceTypesSchema.exactOptional(), + enabled: z.boolean().exactOptional(), + headers: headerEditsSchema.exactOptional(), +}) satisfies z.ZodType; diff --git a/src/main/store/sanitizeFrameNames.ts b/src/main/store/sanitizeFrameNames.ts index 811460e0..53ca2f35 100644 --- a/src/main/store/sanitizeFrameNames.ts +++ b/src/main/store/sanitizeFrameNames.ts @@ -1,5 +1,5 @@ -import { MAX_FRAME_NAME } from '../../shared/constants'; -import { MAX_FRAME_ADDRESS, MAX_FRAME_NAMES } from './constants'; +import { MAX_FRAME_ADDRESS, MAX_FRAME_NAME } from '../../shared/constants'; +import { MAX_FRAME_NAMES } from './constants'; /** Keeps well-formed frame names (address -> name, trimmed, not empty), so a corrupt file or a bad message can't inject junk. */ export function sanitizeFrameNames(input: unknown): Record { diff --git a/src/main/store/sanitizeHeaderEdits.ts b/src/main/store/sanitizeHeaderEdits.ts deleted file mode 100644 index f7d82c10..00000000 --- a/src/main/store/sanitizeHeaderEdits.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { isHeaderOperation, MAX_HEADER_EDITS } from '../../shared/rules'; -import type { HeaderEdit } from '../../shared/types'; -import { isRecord } from './isRecord'; - -/** Well-formed header edits (at most MAX_HEADER_EDITS) as fresh copies, or null. */ -export function sanitizeHeaderEdits(input: unknown): HeaderEdit[] | null { - if (!Array.isArray(input) || input.length > MAX_HEADER_EDITS) return null; - const edits: HeaderEdit[] = []; - for (const edit of input) { - if (!isRecord(edit) || !isHeaderOperation(edit.operation) || typeof edit.name !== 'string' || typeof edit.value !== 'string') return null; - edits.push({ operation: edit.operation, name: edit.name, value: edit.value }); - } - return edits; -} diff --git a/src/main/store/sanitizeMatcher.ts b/src/main/store/sanitizeMatcher.ts deleted file mode 100644 index f1734055..00000000 --- a/src/main/store/sanitizeMatcher.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { isMatchType } from '../../shared/rules'; -import type { UrlMatcher } from '../../shared/types'; -import { isRecord } from './isRecord'; - -/** A well-formed matcher as a fresh copy of its three fields, or null. */ -export function sanitizeMatcher(input: unknown): UrlMatcher | null { - if (!isRecord(input) || !isMatchType(input.type) || typeof input.pattern !== 'string' || typeof input.ignoreQuery !== 'boolean') return null; - return { type: input.type, pattern: input.pattern, ignoreQuery: input.ignoreQuery }; -} diff --git a/src/main/store/sanitizeResourceTypes.ts b/src/main/store/sanitizeResourceTypes.ts deleted file mode 100644 index e1fec608..00000000 --- a/src/main/store/sanitizeResourceTypes.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isRuleResourceType } from '../../shared/rules'; -import { RULE_RESOURCE_TYPES, type RuleResourceType } from '../../shared/types'; - -/** - * Known request types, once each, in RULE_RESOURCE_TYPES order; null if any is - * unknown: dropping it would widen the rule, as an empty list means every type. - */ -export function sanitizeResourceTypes(input: unknown): RuleResourceType[] | null { - if (!Array.isArray(input) || !input.every(isRuleResourceType)) return null; - return RULE_RESOURCE_TYPES.filter((type) => input.includes(type)); -} diff --git a/src/main/store/sanitizeRuleFields.ts b/src/main/store/sanitizeRuleFields.ts deleted file mode 100644 index 3917c919..00000000 --- a/src/main/store/sanitizeRuleFields.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RuleAction } from '../../shared/types'; -import { RULE_FIELD_SANITIZERS } from './ruleFieldSanitizers'; -import type { RuleFieldsOf } from './types'; - -/** Reads an action's own fields from untrusted input. Generic so each action reaches its own reader without a cast. */ -export function sanitizeRuleFields(action: A, input: Record): RuleFieldsOf { - return RULE_FIELD_SANITIZERS[action](input); -} diff --git a/src/main/store/sanitizeRuleInput.ts b/src/main/store/sanitizeRuleInput.ts deleted file mode 100644 index 77da381d..00000000 --- a/src/main/store/sanitizeRuleInput.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { isRuleAction } from '../../shared/rules'; -import type { CreateRuleInput } from '../../shared/types'; -import { invalidRule } from './invalidRule'; -import { isRecord } from './isRecord'; -import { sanitizeMatcher } from './sanitizeMatcher'; -import { sanitizeResourceTypes } from './sanitizeResourceTypes'; -import { sanitizeRuleFields } from './sanitizeRuleFields'; - -/** - * A rule to create, from untrusted input (IPC, or an entry of rules.json): the - * known fields of its action only, as fresh copies. Throws `Invalid rule: ` - * for a field of the wrong shape. What the values mean is validateRuleInput's to check. - */ -export function sanitizeRuleInput(input: unknown): CreateRuleInput { - if (!isRecord(input)) return invalidRule('not an object'); - const { action } = input; - if (!isRuleAction(action)) return invalidRule('action'); - const match = sanitizeMatcher(input.match) ?? invalidRule('match'); - const resourceTypes = sanitizeResourceTypes(input.resourceTypes) ?? invalidRule('resourceTypes'); - // The action's own fields come from the action's own reader, so the spread holds exactly that member's fields. - return { action, match, resourceTypes, ...sanitizeRuleFields(action, input) } as CreateRuleInput; -} diff --git a/src/main/store/sanitizeRulePatch.ts b/src/main/store/sanitizeRulePatch.ts deleted file mode 100644 index d4f5f28f..00000000 --- a/src/main/store/sanitizeRulePatch.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RulePatch } from '../../shared/types'; -import { invalidRule } from './invalidRule'; -import { isRecord } from './isRecord'; -import { sanitizeHeaderEdits } from './sanitizeHeaderEdits'; -import { sanitizeMatcher } from './sanitizeMatcher'; -import { sanitizeResourceTypes } from './sanitizeResourceTypes'; - -/** - * A rule edit from untrusted input (IPC): only the fields present, as fresh - * copies. Throws `Invalid rule: ` for a field of the wrong shape. - */ -export function sanitizeRulePatch(input: unknown): RulePatch { - if (!isRecord(input)) return invalidRule('not an object'); - const patch: RulePatch = {}; - if (input.match !== undefined) patch.match = sanitizeMatcher(input.match) ?? invalidRule('match'); - if (input.resourceTypes !== undefined) patch.resourceTypes = sanitizeResourceTypes(input.resourceTypes) ?? invalidRule('resourceTypes'); - if (input.enabled !== undefined) patch.enabled = typeof input.enabled === 'boolean' ? input.enabled : invalidRule('enabled'); - if (input.headers !== undefined) patch.headers = sanitizeHeaderEdits(input.headers) ?? invalidRule('headers'); - return patch; -} diff --git a/src/main/store/sanitizeStoredRule.ts b/src/main/store/sanitizeStoredRule.ts index 3622f893..6a8f26e3 100644 --- a/src/main/store/sanitizeStoredRule.ts +++ b/src/main/store/sanitizeStoredRule.ts @@ -1,8 +1,6 @@ -import { validateRuleInput } from '../../shared/rules'; -import type { CreateRuleInput } from '../../shared/types'; +import { ruleInputSchema } from '../../shared/rules'; import { RULE_ID } from './constants'; import { isRecord } from './isRecord'; -import { sanitizeRuleInput } from './sanitizeRuleInput'; import type { StoredRule } from './types'; /** @@ -15,12 +13,7 @@ export function sanitizeStoredRule(input: unknown): StoredRule | null { const { id, workspaceId, enabled, createdAt, updatedAt } = input; if (typeof enabled !== 'boolean' || typeof createdAt !== 'number' || typeof updatedAt !== 'number') return null; if (!Number.isFinite(createdAt) || !Number.isFinite(updatedAt)) return null; - let rule: CreateRuleInput; - try { - rule = sanitizeRuleInput(input); - } catch { - return null; - } - if (validateRuleInput(rule)) return null; - return { workspaceId, id, ...rule, enabled, createdAt, updatedAt }; + const rule = ruleInputSchema.safeParse(input); + if (!rule.success) return null; + return { workspaceId, id, ...rule.data, enabled, createdAt, updatedAt }; } diff --git a/src/main/store/types.ts b/src/main/store/types.ts index f6227d79..241f2b38 100644 --- a/src/main/store/types.ts +++ b/src/main/store/types.ts @@ -1,5 +1,4 @@ -import type { RuleInputOf } from '../../shared/rules'; -import type { Override, OverrideMeta, Rule, RuleAction, SessionTab, WorkspaceColor, WorkspaceIcon } from '../../shared/types'; +import type { Override, OverrideMeta, Rule, SessionTab, WorkspaceColor, WorkspaceIcon } from '../../shared/types'; /** An override as kept here, with the workspace it belongs to. */ export type StoredOverride = Override & { workspaceId: string }; @@ -13,12 +12,6 @@ export interface RuleState { foreign: unknown[]; } -/** A rule input's own fields, beyond the action, matcher and types every rule has. */ -export type RuleFieldsOf = Omit, 'action' | 'match' | 'resourceTypes'>; - -/** Per action, reads its own fields from untrusted input as fresh copies, throwing `Invalid rule: `. */ -export type RuleFieldSanitizers = { [A in RuleAction]: (input: Record) => RuleFieldsOf }; - /** An override's entry in the index file: everything but its content. */ export type IndexEntry = OverrideMeta & { workspaceId: string }; diff --git a/src/renderer/src/app/gallery/sections/Controls/InputsBlock.tsx b/src/renderer/src/app/gallery/sections/Controls/InputsBlock.tsx index d2a65df9..7ff159c8 100644 --- a/src/renderer/src/app/gallery/sections/Controls/InputsBlock.tsx +++ b/src/renderer/src/app/gallery/sections/Controls/InputsBlock.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useId, useState } from 'react'; import { icons } from '@/shared/config'; import { Badge } from '@/shared/ui/badge'; +import { FieldError } from '@/shared/ui/field-error'; import { Icon } from '@/shared/ui/icon'; import { IconButton } from '@/shared/ui/icon-button'; import { Input } from '@/shared/ui/input'; @@ -12,8 +13,9 @@ const { CloseIcon, GlobeIcon, SearchIcon, WarningIcon } = icons; export function InputsBlock() { const [query, setQuery] = useState(''); + const errorId = useId(); return ( - +

} /> - } - aria-label="Match rule" - /> +
+ } + aria-label="Match rule" + aria-describedby={errorId} + /> + +
} /> regex} /> diff --git a/src/renderer/src/app/index.tsx b/src/renderer/src/app/index.tsx index 3ec423d9..892391de 100644 --- a/src/renderer/src/app/index.tsx +++ b/src/renderer/src/app/index.tsx @@ -1,11 +1,15 @@ import './styles/index.css'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { z } from 'zod'; import { App } from './App'; /** The element index.html gives the app to render into. */ const ROOT_ID = 'root'; +// Zod probes for `new Function` to speed up parsing; the page's CSP forbids it and reports the probe as a violation. +z.config({ jitless: true }); + createRoot(document.getElementById(ROOT_ID)!).render( diff --git a/src/renderer/src/app/model/bridge/events/syncRules.ts b/src/renderer/src/app/model/bridge/events/syncRules.ts index 89599092..312db700 100644 --- a/src/renderer/src/app/model/bridge/events/syncRules.ts +++ b/src/renderer/src/app/model/bridge/events/syncRules.ts @@ -1,6 +1,6 @@ import { useTabStore, type PageTabOf } from '@/entities/editor-tab'; import { toRuleInput, useRuleStore } from '@/entities/rule'; -import { rebaseRuleDraft } from '@/features/rule/edit'; +import { rebaseRuleForm } from '@/features/rule/edit'; import type { AppEventOf } from '../types'; /** @@ -14,8 +14,6 @@ export function syncRules(event: AppEventOf<'rules-changed'>): void { useTabStore.getState().removePages(rulePages.filter((p) => !rules.has(p.ruleId)).map((p) => p.id)); for (const page of rulePages) { const rule = rules.get(page.ruleId); - if (!rule || !page.draft) continue; - const draft = rebaseRuleDraft(page.draft, toRuleInput(rule)); - if (draft !== page.draft) useTabStore.getState().setPageDraft(page.id, draft); + if (rule) rebaseRuleForm(page.id, toRuleInput(rule)); } } diff --git a/src/renderer/src/entities/editor-tab/index.ts b/src/renderer/src/entities/editor-tab/index.ts index 8d1a0e5b..3ef38112 100644 --- a/src/renderer/src/entities/editor-tab/index.ts +++ b/src/renderer/src/entities/editor-tab/index.ts @@ -14,7 +14,6 @@ export { type PageKind, type PageTabOf, type PageScope, - type RulePageDraft, type DiffMode, } from './model/store'; export { diff --git a/src/renderer/src/entities/editor-tab/model/store/index.ts b/src/renderer/src/entities/editor-tab/model/store/index.ts index ac5b9ecb..e82db09a 100644 --- a/src/renderer/src/entities/editor-tab/model/store/index.ts +++ b/src/renderer/src/entities/editor-tab/model/store/index.ts @@ -6,5 +6,5 @@ export { selectActiveSource } from './selectActiveSource'; export { selectActiveTab } from './selectActiveTab'; export { selectHasDirtyTabs } from './selectHasDirtyTabs'; export { selectTabById } from './selectTabById'; -export type { DiffMode, PageKind, PageScope, PageTab, PageTabOf, RulePageDraft, SourceTab, TabMeta } from './types'; +export type { DiffMode, PageKind, PageScope, PageTab, PageTabOf, SourceTab, TabMeta } from './types'; export { useTabStore } from './useTabStore'; diff --git a/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts b/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts index fd6ec0c0..02b11091 100644 --- a/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts +++ b/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts @@ -3,6 +3,6 @@ import type { PageKind, PageTabOf } from './types'; /** Whether a page holds edits that closing it would lose, by kind: a new kind fails typecheck until it has one. */ export const PAGE_DIRTY_CHECKS: { [K in PageKind]: (page: PageTabOf) => boolean } = { 'whats-new': () => false, - rule: (page) => !!page.draft, - 'new-rule': (page) => !!page.draft, + rule: (page) => !!page.dirty, + 'new-rule': (page) => !!page.dirty, }; diff --git a/src/renderer/src/entities/editor-tab/model/store/types.ts b/src/renderer/src/entities/editor-tab/model/store/types.ts index 2ce78e56..200bf08f 100644 --- a/src/renderer/src/entities/editor-tab/model/store/types.ts +++ b/src/renderer/src/entities/editor-tab/model/store/types.ts @@ -17,19 +17,17 @@ export interface TabMeta { saving: boolean; } -/** Unapplied edits in a rule page, with the saved input they were made to (dropped once that changes). */ -export interface RulePageDraft { - base: CreateRuleInput; - value: CreateRuleInput; - /** Parallel to value.headers (empty for other actions): stable React keys for the header rows. */ - rowKeys: string[]; -} - interface PageTabBase { id: string; title: string; } +/** A page whose form holds edits: the form lives with its feature, by page id; the tab only knows whether it has any. */ +interface EditablePage { + /** It holds edits not applied yet. */ + dirty?: boolean; +} + /** A read-only original from a bundle's source map. Not kept between runs; closes with the file tabs. */ export interface SourceTab { id: string; @@ -50,9 +48,9 @@ export interface SourceTab { export type PageTab = | (PageTabBase & { page: 'whats-new' }) /** A saved rule's editor. */ - | (PageTabBase & { page: 'rule'; ruleId: string; draft?: RulePageDraft }) + | (PageTabBase & EditablePage & { page: 'rule'; ruleId: string }) /** A rule being written, not created yet. */ - | (PageTabBase & { page: 'new-rule'; seed: CreateRuleInput; draft?: RulePageDraft }); + | (PageTabBase & EditablePage & { page: 'new-rule'; seed: CreateRuleInput }); export type PageKind = PageTab['page']; @@ -77,7 +75,7 @@ export interface TabStore { openSource(tab: SourceTab, activate?: boolean): void; /** * Shows a page, opening its tab (after the file and source tabs) if it isn't open yet. An open page - * with the same id takes the new fields in place, keeping its position and anything not given (its draft). + * with the same id takes the new fields in place, keeping its position and anything not given (whether it holds edits). */ openPage(page: PageTab): void; activate(id: string): void; @@ -88,7 +86,7 @@ export interface TabStore { /** Closes these pages; when the active one goes, its neighbour takes over as with `remove`. */ removePages(ids: readonly string[]): void; patch(id: string, patch: Partial): void; - /** Keeps (or, with undefined, drops) a rule page's unapplied edits. */ - setPageDraft(id: string, draft: RulePageDraft | undefined): void; + /** Marks a page as holding edits not applied yet, or not. */ + setPageDirty(id: string, dirty: boolean): void; setDiff(mode: DiffMode): void; } diff --git a/src/renderer/src/entities/editor-tab/model/store/useTabStore.ts b/src/renderer/src/entities/editor-tab/model/store/useTabStore.ts index 13f31423..8feb26b6 100644 --- a/src/renderer/src/entities/editor-tab/model/store/useTabStore.ts +++ b/src/renderer/src/entities/editor-tab/model/store/useTabStore.ts @@ -17,7 +17,7 @@ export const useTabStore = create()((set) => ({ })), openPage: (page) => set((s) => ({ - // Same id, same kind: the merge keeps the open page's place and what the new one leaves out (a draft). + // Same id, same kind: the merge keeps the open page's place and what the new one leaves out (whether it holds edits). pages: s.pages.some((p) => p.id === page.id) ? s.pages.map((p) => (p.id === page.id ? ({ ...p, ...page } as PageTab) : p)) : [...s.pages, page], activeId: page.id, diff: 'off', @@ -54,12 +54,12 @@ export const useTabStore = create()((set) => ({ return { pages, activeId, diff: 'off' }; }), patch: (id, patch) => set((s) => ({ tabs: s.tabs.map((t) => (t.id === id ? { ...t, ...patch } : t)) })), - setPageDraft: (id, draft) => + setPageDirty: (id, dirty) => set((s) => { const page = s.pages.find((p) => p.id === id); - // What's New has no drafts; an unchanged draft keeps the store (and its subscribers) still. - if (!page || page.page === 'whats-new' || page.draft === draft) return s; - return { pages: s.pages.map((p) => (p === page ? { ...page, draft } : p)) }; + // What's New holds no edits; an unchanged flag keeps the store (and its subscribers) still. + if (!page || page.page === 'whats-new' || !!page.dirty === dirty) return s; + return { pages: s.pages.map((p) => (p === page ? { ...page, dirty } : p)) }; }), setDiff: (diff) => set({ diff }), })); diff --git a/src/renderer/src/features/action/edit/ui/ActionForm/ActionForm.tsx b/src/renderer/src/features/action/edit/ui/ActionForm/ActionForm.tsx index 946e2c78..9a1900b5 100644 --- a/src/renderer/src/features/action/edit/ui/ActionForm/ActionForm.tsx +++ b/src/renderer/src/features/action/edit/ui/ActionForm/ActionForm.tsx @@ -1,9 +1,13 @@ -import { useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useId } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { actionInputSchema } from '@common/actions'; import { MAX_ACTION_NAME } from '@common/constants'; -import type { ActionInput, ConsoleFrame } from '@common/types'; +import type { ConsoleFrame } from '@common/types'; import { KEY } from '@/shared/config'; import { clamp } from '@/shared/lib'; import { Button } from '@/shared/ui/button'; +import { FieldError } from '@/shared/ui/field-error'; import { Input } from '@/shared/ui/input'; import { frameKey } from '@/entities/frame'; import { saveAction } from '../../model/saveAction'; @@ -23,70 +27,77 @@ export interface ActionFormProps { const { close } = useActionEditor.getState(); /** - * A new action, or one being changed: its name, the frame it runs in and its - * code. Enter in the name or Ctrl/Cmd+Enter in the code saves it; Esc cancels. + * A new action, or one being changed: its name, the frame it runs in and its code, checked as the + * store checks them (each field says what is wrong once left). Enter in the name or Ctrl/Cmd+Enter + * in the code saves it; Esc cancels. */ export function ActionForm({ editing, frames, names }: ActionFormProps) { - const [draft, setDraft] = useState(editing.start); - const [saving, setSaving] = useState(false); - const change = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); - const ready = !!draft.name.trim() && !!draft.code.trim(); - - const submit = async () => { - if (!ready || saving) return; - setSaving(true); - // Saved, the form closes; otherwise it stays, to try again. - if (!(await saveAction(editing.id, draft))) setSaving(false); - }; + const errorIds = { name: useId(), target: useId(), code: useId() }; + const { + register, + handleSubmit, + control, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues: editing.start, resolver: zodResolver(actionInputSchema), mode: 'onTouched' }); + const [target, targetName, code] = useWatch({ control, name: ['target', 'targetName', 'code'] }); + // Saved, the form closes; otherwise it stays, to try again. + const save = handleSubmit((input) => saveAction(editing.id, input)); return (
{ - event.preventDefault(); - void submit(); - }} + onSubmit={save} + noValidate > change({ name: event.target.value })} + invalid={!!errors.name} + aria-describedby={errors.name ? errorIds.name : undefined} onKeyDown={closeOnEscape} /> +
Runs in change({ target: frameKey(frame), targetName: frame.parentId ? frame.name : '' })} + target={target} + targetName={targetName} + onPick={(frame) => { + setValue('target', frameKey(frame), { shouldDirty: true, shouldValidate: true }); + setValue('targetName', frame.parentId ? frame.name : '', { shouldDirty: true }); + }} />
+