Release Device Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Device Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Device Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
The passive inventory checks browser API presence, viewport and input
|
||||
hints, declared codec support and media constraints without requesting
|
||||
permissions or enumerating hardware.
|
||||
</p>
|
||||
<p>
|
||||
Optional probes run only from their individual buttons. Camera,
|
||||
microphone, screen and persistence checks can prompt; returned media
|
||||
tracks are stopped immediately and no content is recorded.
|
||||
</p>
|
||||
<p>
|
||||
Reports contain no timestamp, user agent, locale, identifier, hash or
|
||||
uniqueness score. Exact high-entropy measurements are bucketed before
|
||||
JSON or CSV export. Nothing is uploaded.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
collectPassiveCapabilities,
|
||||
createRedactedReport,
|
||||
exportReportCsv,
|
||||
exportReportJson,
|
||||
PROBES,
|
||||
runProbe,
|
||||
type Capability,
|
||||
type ProbeId,
|
||||
type ProbeResult,
|
||||
} from "../core/device";
|
||||
|
||||
export function Workbench() {
|
||||
const [capabilities, setCapabilities] = useState<Capability[]>(() =>
|
||||
collectPassiveCapabilities(),
|
||||
);
|
||||
const [results, setResults] = useState<Partial<Record<ProbeId, ProbeResult>>>(
|
||||
{},
|
||||
);
|
||||
const [running, setRunning] = useState<ProbeId | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [status, setStatus] = useState(
|
||||
"Passive API presence was checked locally. No permission was requested.",
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
return needle === ""
|
||||
? capabilities
|
||||
: capabilities.filter((item) =>
|
||||
(item.label + " " + item.group + " " + item.value)
|
||||
.toLowerCase()
|
||||
.includes(needle),
|
||||
);
|
||||
}, [capabilities, filter]);
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
[...new Set(filtered.map((item) => item.group))].map((group) => ({
|
||||
group,
|
||||
items: filtered.filter((item) => item.group === group),
|
||||
})),
|
||||
[filtered],
|
||||
);
|
||||
const completed = PROBES.flatMap((probe) => {
|
||||
const result = results[probe.id];
|
||||
return result ? [result] : [];
|
||||
});
|
||||
const report = createRedactedReport(capabilities, completed);
|
||||
const available = capabilities.filter(
|
||||
(item) => item.state === "available",
|
||||
).length;
|
||||
|
||||
async function performProbe(id: ProbeId): Promise<void> {
|
||||
setRunning(id);
|
||||
setStatus("Running the selected probe locally…");
|
||||
const result = await runProbe(id);
|
||||
setResults((current) => ({ ...current, [id]: result }));
|
||||
setRunning(null);
|
||||
setStatus(result.summary);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Browser capability and privacy lab</p>
|
||||
<h1>Inspect capabilities, not identity.</h1>
|
||||
<p>
|
||||
See what this browser exposes, run sensitive probes only when you
|
||||
choose, and export a deliberately redacted report without a device
|
||||
fingerprint or score.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">No exfiltration</span>
|
||||
</section>
|
||||
|
||||
<section className="principles" aria-labelledby="principles-title">
|
||||
<div>
|
||||
<p className="eyebrow">Privacy contract</p>
|
||||
<h2 id="principles-title">Useful evidence with a hard boundary</h2>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
No user agent, locale, device name, stable identifier or hash.
|
||||
</li>
|
||||
<li>No canvas/audio fingerprint sample and no uniqueness score.</li>
|
||||
<li>
|
||||
No API request leaves this origin; media tracks stop immediately.
|
||||
</li>
|
||||
<li>Downloaded reports bucket exact high-entropy measurements.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="panel" aria-labelledby="inventory-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Passive inventory</p>
|
||||
<h2 id="inventory-title">Browser and display capabilities</h2>
|
||||
</div>
|
||||
<span className="count-pill">
|
||||
{available} of {capabilities.length} available
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
These checks read API presence, media queries, viewport values,
|
||||
supported constraints and codec declarations. They do not request
|
||||
permission or enumerate hardware.
|
||||
</p>
|
||||
<div className="inventory-toolbar">
|
||||
<label>
|
||||
Filter capabilities
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="display, media, storage…"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCapabilities(collectPassiveCapabilities());
|
||||
setStatus(
|
||||
"Passive inventory refreshed; no permission was requested.",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Refresh passive inventory
|
||||
</button>
|
||||
</div>
|
||||
<div className="capability-groups">
|
||||
{groups.map(({ group, items }) => (
|
||||
<section key={group} className="capability-group">
|
||||
<h3>{group}</h3>
|
||||
<div className="capability-table" tabIndex={0}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>State</th>
|
||||
<th>Local value</th>
|
||||
<th>Privacy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} title={item.explanation}>
|
||||
<th scope="row">{item.label}</th>
|
||||
<td>
|
||||
<State state={item.state} />
|
||||
</td>
|
||||
<td>{item.value}</td>
|
||||
<td>
|
||||
<Risk value={item.privacy} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="empty-state">No capability matches this filter.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" aria-labelledby="probes-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Explicit user-gated checks</p>
|
||||
<h2 id="probes-title">Optional probes</h2>
|
||||
</div>
|
||||
<span className="count-pill">{completed.length} run</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Nothing below runs automatically. Buttons marked “may prompt” can
|
||||
trigger browser or operating-system UI.
|
||||
</p>
|
||||
<div className="probe-grid">
|
||||
{PROBES.map((probe) => {
|
||||
const result = results[probe.id];
|
||||
return (
|
||||
<article className="probe-card" key={probe.id}>
|
||||
<div className="probe-heading">
|
||||
<h3>{probe.title}</h3>
|
||||
<Risk value={probe.privacy} />
|
||||
</div>
|
||||
<p>{probe.description}</p>
|
||||
{probe.prompts && (
|
||||
<strong className="prompt-note">May prompt</strong>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={running !== null}
|
||||
onClick={() => void performProbe(probe.id)}
|
||||
>
|
||||
{running === probe.id ? "Running…" : probe.action}
|
||||
</button>
|
||||
{result && <ProbeOutcome result={result} />}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="status" role="status" aria-live="polite">
|
||||
{status}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel report-panel" aria-labelledby="report-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Shareable output</p>
|
||||
<h2 id="report-title">Redacted capability report</h2>
|
||||
</div>
|
||||
<span className="count-pill">No timestamp or identifier</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
The preview is the exact downloaded payload. Screen/viewport/DPR,
|
||||
storage, GPU limits, device counts, battery timing and network quality
|
||||
are reduced to broad buckets.
|
||||
</p>
|
||||
<pre tabIndex={0} aria-label="Redacted JSON report">
|
||||
{exportReportJson(report)}
|
||||
</pre>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([exportReportJson(report)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
"device-capabilities-redacted.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download redacted JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([exportReportCsv(report)], { type: "text/csv" }),
|
||||
"device-capabilities-redacted.csv",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download redacted CSV
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ProbeOutcome({ result }: { result: ProbeResult }) {
|
||||
return (
|
||||
<div className={"probe-result " + result.state}>
|
||||
<strong>{result.state}</strong>
|
||||
<p>{result.summary}</p>
|
||||
{result.details.length > 0 && (
|
||||
<dl>
|
||||
{result.details.map((detail) => (
|
||||
<div key={detail.label}>
|
||||
<dt>{detail.label}</dt>
|
||||
<dd>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function State({ state }: { state: Capability["state"] }) {
|
||||
return <span className={"state-chip " + state}>{state}</span>;
|
||||
}
|
||||
|
||||
function Risk({ value }: { value: Capability["privacy"] }) {
|
||||
return <span className={"risk-chip " + value}>{value}</span>;
|
||||
}
|
||||
|
||||
function download(blob: Blob, name: string): void {
|
||||
triggerBlobDownload(blob, name);
|
||||
}
|
||||
Reference in New Issue
Block a user