diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index d20b115f..7e16367b 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -46,10 +46,11 @@ Important routes: - `/api/devtools/targets`: returns a fresh, bounded registry of native Valdi, explicit web-preview, and JavaScript-proxy targets. - `/api/devtools/target`: resolves either one opaque native target ID or the exact configured inspected Chromium page identity. - `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP. +- `/api/devtools/component-property`: applies one exact web-only scalar ViewModel property edit from a strict body-only identity and token tuple. - `/api/devtools/performance/snapshot` and `/api/devtools/performance/trace/*`: sample the exact web preview and record one bounded global Chromium trace without changing the daemon/Hermes `/api/performance/*` routes. -Web preview targets advertise the `component-properties` capability. Their -Elements snapshots may include a read-only `Valdi props` projection captured +Web preview targets advertise the independent `component-properties` +capability. Their Elements snapshots may include a `Valdi props` projection captured from own enumerable data descriptors only; accessors, inherited fields, and symbol keys are omitted. Component properties share a 64 KiB UTF-8 budget and are discarded before the hierarchy if the complete snapshot reaches its @@ -58,8 +59,51 @@ reflecting a Proxy can execute its `ownKeys` and descriptor traps and may materialize their complete key result before the cap is applied. A throwing or revoked Proxy therefore causes that component's properties to be omitted while preserving the hierarchy. Prototype traversal is not used, and property values -are never read through normal property access. Native targets do not advertise -or emit this data. +are never read through normal property access. + +An accepted web snapshot may additionally promote the separate +`component-property-edit` capability when both the renderer's dedicated +full-ViewModel mutation API and secure browser randomness are available. Static +target and registry responses do not claim this capability before the bridge +proves it. Editable strings, booleans, and finite numbers receive a 128-bit +lowercase hexadecimal token and a positive snapshot revision. Tokens bind the +exact component, virtual node, raw ViewModel identity, property name, data +descriptor, scalar type, and prior value. Tokens are single-use and expire +after 120 seconds. Each published snapshot replaces the current registry while +retaining only the immediately previous published revision, capped at 1,000 +tokens per revision and 2,000 total, so a panel may safely discard one +already-requested poll without invalidating the snapshot it still displays. +Publishing another revision drops the older retained map; destruction, +secure-randomness failure, and expiry clear the associated object graphs. +Missing crypto, reflection failures, stale identity, invalid values, and +mutation failures fall back to the read-only property presentation with a +generic error. `children`, `prototype`, +`constructor`, and `__proto__` are never editable. ViewModels with a custom +prototype, more than 1,000 own keys, any accessor descriptor, or an own +function-valued data property remain read only. Plain and null-prototype +ViewModels may edit frozen scalar descriptors without mutating the source. + +The renderer installs a read-only overlay Proxy over a stable shadow target +rather than using or mutating the exact prior ViewModel as the Proxy target. The +shadow snapshots the original prototype, complete descriptor set, own-key order, and +extensibility. Non-edited reads retain the exact source ViewModel as their +receiver, while repeated debugger edits flatten onto the same source without +depending on its later structural state. User Proxy `get` and `has` behavior +remains observable during fallback reads. If a Proxy `get` trap returns a +callable despite its data descriptor, the overlay returns a stable per-key +binding to the exact source ViewModel so method calls preserve receiver-private +state without invoking the trap during authorization. Inherited +`Object.prototype` members instead resolve against the shadow with the overlay +receiver, preserving their identity while routing legacy property mutators +through the overlay's rejection traps. Custom `ownKeys` behavior is captured +transactionally and served from the stable shadow after construction rather +than consulting the live source again. +Debugger writes, definitions, deletions, prototype changes, and +`preventExtensions` calls are rejected, while the dedicated full-ViewModel +rerender path receives the overlay. Native targets never advertise +`component-property-edit`, and the route cannot be reached through `targetId`, +the daemon protocol, the generic action bus, console evaluation, storage, or +telemetry. Renderer tracing uses the runtime debugger protocol and the existing native trace recorder. Captures are process-wide: the selected context is the capture @@ -73,9 +117,20 @@ Hermes CPU profiling uses the existing inspector transport. The native and synthetic previews forward capability, query, tap, focus, text, key, and scroll requests through the selected target's bounded input contract. Web-renderer inspection uses the first-party bridge exposed as -`window.__VALDI_WEB_DEBUGGER__` with `getSnapshot()`, `highlightNode()`, and -`clearHighlight()`. The DevTools panel proxies inspection through the exact -configured loopback Chromium target. +`window.__VALDI_WEB_DEBUGGER__` with `getSnapshot()`, `highlightNode()`, +`clearHighlight()`, and the synchronous exact `editComponentProperty()` path. +The DevTools panel proxies inspection through the exact configured loopback +Chromium target. Scalar controls remain read only unless the current snapshot +advertises both property capabilities and supplies valid edit metadata. While +an editor is focused or an edit owns its replacement snapshot refresh, +automatic refresh is paused; target, snapshot, selection, and operation +generations prevent stale completions from changing newer presentation. +Editable controls are hydrated with DOM APIs. Authorization tokens, component +identity, revisions, and the actionable binding stay out of serialized markup +in a private binding; the display property name is assigned only as safe DOM +text and an accessible name. String controls use a strict JSON string literal so +carriage returns, line feeds, NULs, quotes, unpaired surrogates, and unusual +nonblank property names round-trip exactly. Web-preview performance requests require the exact `sessionId`, `inspectedUrl`, and per-tab `targetNonce`; incomplete, stale, or cross-tab identities fail diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index 86c41eb9..06267691 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -476,6 +476,54 @@ button { border-bottom: 1px solid var(--border); } +.component-property-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + margin: 2px 0; +} + +.component-property-label { + display: flex; + min-width: 0; + gap: 4px; + align-items: center; +} + +.component-property-input { + min-width: 0; + flex: 1; + border: 1px solid var(--border); + border-radius: 2px; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.component-property-input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.component-property-string-input { + resize: vertical; + white-space: pre; +} + +.component-property-apply { + border: 1px solid var(--border); + border-radius: 2px; + background: var(--surface-hover); + color: var(--text); + font: inherit; +} + +.component-property-error { + margin-bottom: 6px; + color: var(--error); + overflow-wrap: anywhere; +} + .rule-origin { float: right; color: var(--muted); diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index e6558e68..cef25371 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -10,7 +10,19 @@ const MAX_CONSOLE_HISTORY_ENTRIES = 100; const MAX_PERFORMANCE_SAMPLES = 120; const MAX_PERFORMANCE_TIMELINE_ROWS = 120; const MAX_PERFORMANCE_SUMMARY_ROWS = 12; -const MANUAL_WEB_CAPABILITIES = new Set(['components', 'console', 'highlight', 'performance', 'snapshot', 'storage']); +const COMPONENT_PROPERTY_TOKEN_PATTERN = /^[0-9a-f]{32}$/; +const COMPONENT_PROPERTY_EDIT_ERROR = 'The component property edit is stale or invalid.'; +const FORBIDDEN_COMPONENT_PROPERTY_NAMES = new Set(['__proto__', 'children', 'constructor', 'prototype']); +const componentPropertyEditorBindings = new WeakMap(); +const MANUAL_WEB_CAPABILITIES = new Set([ + 'component-properties', + 'components', + 'console', + 'highlight', + 'performance', + 'snapshot', + 'storage', +]); function parseLaunchIdentity(searchParams) { const targetIds = searchParams.getAll('targetId'); @@ -73,6 +85,7 @@ const state = { autoRefresh: true, refreshTimer: null, refreshPending: false, + snapshotRequestCompletion: null, snapshotGeneration: 0, snapshotRequestGeneration: 0, hoveredNodeId: null, @@ -104,6 +117,12 @@ const state = { traceScope: 'valdi', traceSearch: '', }, + componentPropertyEdit: { + error: null, + focused: false, + operationGeneration: 0, + pending: false, + }, error: null, }; @@ -542,9 +561,17 @@ function resetConsoleForTargetChange() { elements.consoleInput.value = ''; } +function resetComponentPropertyEditForTargetChange() { + state.componentPropertyEdit.operationGeneration++; + state.componentPropertyEdit.pending = false; + state.componentPropertyEdit.focused = false; + state.componentPropertyEdit.error = null; +} + function clearTargetPresentation(message) { state.snapshotRequestGeneration++; state.refreshPending = false; + state.snapshotRequestCompletion = null; state.snapshot = null; state.snapshotGeneration++; state.selectedNodeId = null; @@ -552,6 +579,7 @@ function clearTargetPresentation(message) { state.expandedNodeIds.clear(); resetHighlightForTargetChange(); resetConsoleForTargetChange(); + resetComponentPropertyEditForTargetChange(); preparePerformanceForTargetChange(); elements.treeEmpty.textContent = message; render(); @@ -813,9 +841,11 @@ async function connectToInspectedPage() { stopConsoleStream(); enqueueExactHighlightClear(state.target); resetConsoleForTargetChange(); + resetComponentPropertyEditForTargetChange(); preparePerformanceForTargetChange(); state.snapshotRequestGeneration++; state.refreshPending = false; + state.snapshotRequestCompletion = null; state.snapshot = null; state.snapshotGeneration++; state.selectedNodeId = null; @@ -865,8 +895,33 @@ async function connectToInspectedApplication() { } async function refreshSnapshot() { - if (!state.target || !targetSupports('components') || !targetSupports('snapshot') || state.refreshPending) return; + await refreshSnapshotInternal(null); +} + +async function refreshSnapshotInternal(componentPropertyEditOperationGeneration) { + const componentPropertyEditOwnsRefresh = () => + componentPropertyEditOperationGeneration !== null && + state.componentPropertyEdit.operationGeneration === componentPropertyEditOperationGeneration && + state.componentPropertyEdit.pending; + const refreshIsAllowed = () => + state.target && + targetSupports('components') && + targetSupports('snapshot') && + ((!state.componentPropertyEdit.focused && !state.componentPropertyEdit.pending) || + componentPropertyEditOwnsRefresh()); + if (!refreshIsAllowed()) return; + if (state.refreshPending) { + const activeRequestCompletion = state.snapshotRequestCompletion; + if (!componentPropertyEditOwnsRefresh() || activeRequestCompletion === null) return; + await activeRequestCompletion; + if (!refreshIsAllowed() || state.refreshPending) return; + } state.refreshPending = true; + let resolveRequestCompletion; + const requestCompletion = new Promise(resolve => { + resolveRequestCompletion = resolve; + }); + state.snapshotRequestCompletion = requestCompletion; const requestTarget = state.target; const requestTargetGeneration = state.targetGeneration; const requestGeneration = ++state.snapshotRequestGeneration; @@ -876,11 +931,26 @@ async function refreshSnapshot() { state.snapshotRequestGeneration === requestGeneration; try { const snapshot = await requestJson('/api/devtools/snapshot', targetIdentityParameters(requestTarget), {}); - if (!requestIsCurrent()) return; + if ( + !requestIsCurrent() || + ((state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) && + !componentPropertyEditOwnsRefresh()) + ) + return; + if ( + snapshot.target?.id === requestTarget.id && + Array.isArray(snapshot.target.capabilities) && + snapshot.target.capabilities.length <= MAX_REGISTRY_CAPABILITIES && + snapshot.target.capabilities.every(capability => typeof capability === 'string' && capability.length <= 64) + ) { + state.target = { ...requestTarget, capabilities: [...snapshot.target.capabilities] }; + updateCapabilityUi(); + } snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree); const wasEmpty = !state.snapshot?.tree; const shouldClearHighlight = state.hoveredNodeId !== null || state.highlightMayBeActive; state.snapshot = snapshot; + state.componentPropertyEdit.error = null; state.snapshotGeneration++; if (state.highlightTimer) window.clearTimeout(state.highlightTimer); state.highlightTimer = null; @@ -909,7 +979,11 @@ async function refreshSnapshot() { } catch (error) { if (requestIsCurrent()) reportError(error); } finally { - if (requestIsCurrent()) state.refreshPending = false; + if (state.snapshotRequestCompletion === requestCompletion) { + state.snapshotRequestCompletion = null; + state.refreshPending = false; + } + resolveRequestCompletion(); } } @@ -919,6 +993,7 @@ function startRefreshTimer() { if (document.hidden) return; if (isDirectMode()) void refreshTargetRegistry(); if (!state.autoRefresh) return; + if (state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) return; if (state.activeSection === 'elements') void refreshSnapshot(); if (state.activeSection === 'performance') void refreshPerformance({ silent: true }); }, 1200); @@ -1025,6 +1100,241 @@ function propertyRows(attributes, options) { .join(''); } +function componentPropertyEditMetadata(node, propertyName, value) { + if ( + launchIdentity.mode !== 'inspected-page' || + state.componentPropertyEdit.error !== null || + !targetSupports('component-properties') || + !targetSupports('component-property-edit') || + !node.component || + typeof node.component.propertyEdits !== 'object' || + node.component.propertyEdits === null || + propertyName.trim().length === 0 || + FORBIDDEN_COMPONENT_PROPERTY_NAMES.has(propertyName) || + !['boolean', 'number', 'string'].includes(typeof value) || + (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value, -0))) + ) { + return null; + } + let metadataDescriptor; + try { + metadataDescriptor = Object.getOwnPropertyDescriptor(node.component.propertyEdits, propertyName); + } catch (_error) { + return null; + } + const metadata = metadataDescriptor?.value; + if ( + metadataDescriptor?.enumerable !== true || + metadataDescriptor.get !== undefined || + metadataDescriptor.set !== undefined || + typeof metadata !== 'object' || + metadata === null || + Array.isArray(metadata) + ) { + return null; + } + let componentTokenDescriptor; + let snapshotRevisionDescriptor; + try { + componentTokenDescriptor = Object.getOwnPropertyDescriptor(metadata, 'componentToken'); + snapshotRevisionDescriptor = Object.getOwnPropertyDescriptor(metadata, 'snapshotRevision'); + } catch (_error) { + return null; + } + const componentToken = componentTokenDescriptor?.value; + const snapshotRevision = snapshotRevisionDescriptor?.value; + if ( + componentTokenDescriptor?.enumerable !== true || + componentTokenDescriptor.get !== undefined || + componentTokenDescriptor.set !== undefined || + snapshotRevisionDescriptor?.enumerable !== true || + snapshotRevisionDescriptor.get !== undefined || + snapshotRevisionDescriptor.set !== undefined || + typeof componentToken !== 'string' || + !COMPONENT_PROPERTY_TOKEN_PATTERN.test(componentToken) || + !Number.isSafeInteger(snapshotRevision) || + snapshotRevision <= 0 + ) { + return null; + } + return { componentToken, snapshotRevision }; +} + +function createComponentPropertyEditor(node, propertyName, value, metadata) { + const valueType = typeof value; + const form = document.createElement('form'); + form.className = 'component-property-editor'; + form.dataset.componentPropertyEditor = ''; + const label = document.createElement('label'); + label.className = 'component-property-label'; + const propertyNameLabel = document.createElement('span'); + propertyNameLabel.className = 'property-name'; + propertyNameLabel.textContent = propertyName; + const separator = document.createElement('span'); + separator.setAttribute('aria-hidden', 'true'); + separator.textContent = ':'; + let editor; + if (valueType === 'string') { + editor = document.createElement('textarea'); + editor.className = 'component-property-input component-property-string-input'; + editor.setAttribute('aria-label', `Edit Valdi prop ${propertyName} as a JSON string literal`); + editor.setAttribute('rows', '1'); + editor.setAttribute('spellcheck', 'false'); + editor.value = JSON.stringify(value); + } else { + editor = document.createElement('input'); + editor.className = 'component-property-input'; + editor.setAttribute('aria-label', `Edit Valdi prop ${propertyName}`); + editor.setAttribute('type', valueType === 'boolean' ? 'checkbox' : 'number'); + if (valueType === 'boolean') editor.checked = value; + else { + editor.setAttribute('step', 'any'); + editor.value = String(value); + } + } + editor.dataset.componentPropertyInput = ''; + const applyButton = document.createElement('button'); + applyButton.className = 'component-property-apply'; + applyButton.setAttribute('aria-label', `Apply Valdi prop ${propertyName}`); + applyButton.setAttribute('type', 'submit'); + applyButton.disabled = state.componentPropertyEdit.pending; + applyButton.textContent = 'Apply'; + label.append(propertyNameLabel); + label.append(separator); + label.append(editor); + form.append(label); + form.append(applyButton); + componentPropertyEditorBindings.set(form, { + componentId: node.id, + componentToken: metadata.componentToken, + propertyName, + snapshotRevision: metadata.snapshotRevision, + valueType, + }); + return form; +} + +function componentPropertyRows(node, editorModels) { + const entries = Object.entries(node.component?.properties || {}).sort(([first], [second]) => + first.localeCompare(second), + ); + if (!entries.length) return '
${escapeHtml(textContent)}` : ''}`;
+ renderMarkup(
+ `${componentDetails}${componentProperties}${escapeHtml(textContent)}` : ''}`,
+ );
}
}
@@ -2165,6 +2486,45 @@ function wireEvents() {
if (row) queueHighlight(inspectedNodeId(findNode(row.dataset.nodeId)));
});
elements.tree.addEventListener('pointerleave', () => queueHighlight(null));
+ elements.inspector.addEventListener('focusin', event => {
+ if (event.target.closest?.('[data-component-property-editor]')) {
+ state.componentPropertyEdit.focused = true;
+ }
+ });
+ elements.inspector.addEventListener('focusout', () => {
+ window.setTimeout(() => {
+ state.componentPropertyEdit.focused = Boolean(
+ document.activeElement?.closest?.('[data-component-property-editor]'),
+ );
+ }, 0);
+ });
+ elements.inspector.addEventListener('submit', event => {
+ const form = event.target.closest?.('[data-component-property-editor]');
+ if (!form) return;
+ event.preventDefault();
+ const binding = componentPropertyEditorBindings.get(form);
+ const editor = form.querySelector('[data-component-property-input]');
+ const value = editor && binding ? readComponentPropertyEditorValue(editor, binding.valueType) : undefined;
+ if (
+ !binding ||
+ value === undefined ||
+ !Number.isSafeInteger(binding.snapshotRevision) ||
+ binding.snapshotRevision <= 0 ||
+ !COMPONENT_PROPERTY_TOKEN_PATTERN.test(binding.componentToken)
+ ) {
+ state.componentPropertyEdit.error = 'Enter a valid scalar value before applying this property.';
+ state.componentPropertyEdit.focused = false;
+ renderInspector();
+ return;
+ }
+ void submitComponentPropertyEdit(
+ binding.componentId,
+ binding.propertyName,
+ binding.componentToken,
+ binding.snapshotRevision,
+ value,
+ );
+ });
elements.breadcrumbs.addEventListener('click', event => {
const button = event.target.closest('[data-breadcrumb-id]');
if (button) selectNode(button.dataset.breadcrumbId);
diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
index 50b08f7b..b551483a 100644
--- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
+++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
@@ -6,7 +6,13 @@ import { Script } from 'node:vm';
interface DevToolsTreeNode {
bounds?: { height: number; width: number; x: number; y: number };
children: DevToolsTreeNode[];
- component?: { elementId?: string; key: string; name: string; properties?: Record