Release Device Tools v0.1.0

This commit is contained in:
2026-09-01 14:22:42 +02:00
commit 06f697e180
57 changed files with 9775 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing Device Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -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;
}
}
+51
View File
@@ -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>
);
}
+289
View File
@@ -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);
}
+1205
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+484
View File
@@ -0,0 +1,484 @@
:root {
--toolbox-background: #f5f7f8;
--toolbox-surface: #fff;
--toolbox-surface-soft: #edf2f3;
--toolbox-text: #18272d;
--toolbox-muted: #617078;
--toolbox-border: #d5dfe2;
--toolbox-accent: #136d72;
--toolbox-accent-hover: #0d585d;
--toolbox-accent-soft: #ddf1f1;
--toolbox-accent-contrast: #fff;
--toolbox-focus: #8055c7;
--toolbox-danger: #ad2948;
--device-warning: #9c610a;
--device-success: #13735c;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
min-height: 100%;
background: var(--toolbox-background);
scrollbar-gutter: stable;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--toolbox-background);
color: var(--toolbox-text);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
text-rendering: optimizeLegibility;
}
#root {
min-height: 100vh;
}
::selection {
background: color-mix(in srgb, var(--toolbox-focus) 35%, transparent);
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
color: var(--toolbox-text);
}
.workbench :where(h1, h2, h3),
.help-dialog :where(h2, h3),
.fatal h1 {
margin: 0;
color: var(--toolbox-text);
font-weight: 760;
letter-spacing: -0.027em;
line-height: 1.16;
}
.workbench h1 {
font-size: clamp(1.75rem, 3vw, 2.55rem);
}
.workbench h2,
.help-dialog h2 {
font-size: clamp(1.18rem, 2vw, 1.5rem);
}
.workbench h3 {
font-size: 0.96rem;
}
.workbench :where(p, ul, dl) {
margin-block: 0;
}
.workbench :where(button, input),
.help-dialog button {
font: inherit;
}
.workbench button,
.help-dialog button {
min-height: 2.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.64rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-size: 0.82rem;
font-weight: 720;
line-height: 1.2;
cursor: pointer;
touch-action: manipulation;
}
.workbench button:hover:not(:disabled) {
border-color: var(--toolbox-accent);
background: var(--toolbox-surface-soft);
}
.workbench button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.workbench .primary-button {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.workbench .primary-button:hover:not(:disabled) {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
:where(.workbench, .help-dialog) :where(button, input):focus-visible,
.capability-table:focus-visible,
.report-panel pre:focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
}
.workbench input {
width: 100%;
min-width: 0;
min-height: 2.55rem;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.workbench label {
min-width: 0;
display: grid;
gap: 0.34rem;
color: var(--toolbox-muted);
font-size: 0.77rem;
font-weight: 720;
}
.hero,
.panel,
.principles {
min-width: 0;
border: 1px solid var(--toolbox-border);
border-radius: 0.82rem;
background: var(--toolbox-surface);
box-shadow:
0 1px 2px rgb(24 31 65 / 4%),
0 10px 30px rgb(24 31 65 / 3%);
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero p:not(.eyebrow) {
max-width: 60rem;
margin-top: 0.55rem;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem !important;
color: var(--toolbox-accent);
font-size: 0.68rem;
font-weight: 820;
letter-spacing: 0.115em;
line-height: 1.35;
text-transform: uppercase;
}
.privacy-pill,
.count-pill {
flex: 0 0 auto;
padding: 0.38rem 0.64rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 780;
}
.principles {
display: grid;
grid-template-columns: minmax(15rem, 0.65fr) minmax(18rem, 1.35fr);
gap: 1.2rem;
padding: clamp(0.9rem, 2vw, 1.2rem);
border-left: 0.28rem solid var(--toolbox-accent);
}
.principles ul {
display: grid;
gap: 0.38rem;
padding-left: 1.2rem;
color: var(--toolbox-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.panel {
padding: clamp(0.9rem, 2vw, 1.2rem);
}
.panel-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.muted,
.status {
color: var(--toolbox-muted);
font-size: 0.82rem;
line-height: 1.55;
}
.status {
min-height: 1.3rem;
margin-top: 0.8rem !important;
}
.inventory-toolbar {
display: grid;
grid-template-columns: minmax(15rem, 1fr) auto;
align-items: end;
gap: 0.65rem;
margin-top: 0.85rem;
}
.capability-groups {
display: grid;
gap: 0.85rem;
margin-top: 1rem;
}
.capability-group {
min-width: 0;
}
.capability-group h3 {
margin-bottom: 0.4rem;
}
.capability-table {
max-width: 100%;
max-height: 25rem;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
scrollbar-gutter: stable;
}
.capability-table table {
width: 100%;
min-width: 46rem;
border-collapse: collapse;
font-size: 0.76rem;
}
.capability-table :where(th, td) {
padding: 0.48rem 0.62rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
.capability-table thead th {
position: sticky;
z-index: 1;
top: 0;
background: var(--toolbox-surface-soft);
}
.capability-table tbody th {
width: 24%;
font-weight: 720;
}
.capability-table tbody tr:hover {
background: var(--toolbox-surface-soft);
}
.state-chip,
.risk-chip,
.prompt-note {
display: inline-flex;
width: fit-content;
padding: 0.2rem 0.38rem;
border-radius: 999px;
font-size: 0.64rem;
font-weight: 820;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.state-chip.available {
background: color-mix(
in srgb,
var(--device-success) 11%,
var(--toolbox-surface)
);
color: var(--device-success);
}
.state-chip.unavailable,
.state-chip.unknown {
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
}
.risk-chip.low {
background: color-mix(
in srgb,
var(--device-success) 11%,
var(--toolbox-surface)
);
color: var(--device-success);
}
.risk-chip.medium {
background: color-mix(
in srgb,
var(--device-warning) 11%,
var(--toolbox-surface)
);
color: var(--device-warning);
}
.risk-chip.high,
.prompt-note {
background: color-mix(
in srgb,
var(--toolbox-danger) 10%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
}
.empty-state {
padding: 1rem;
border: 1px dashed var(--toolbox-border);
border-radius: 0.62rem;
color: var(--toolbox-muted);
}
.probe-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.9rem;
}
.probe-card {
min-width: 0;
display: grid;
align-content: start;
gap: 0.55rem;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
}
.probe-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.6rem;
}
.probe-card > p {
min-height: 2.4rem;
color: var(--toolbox-muted);
font-size: 0.76rem;
line-height: 1.5;
}
.probe-card > button {
width: 100%;
margin-top: auto;
}
.probe-result {
display: grid;
gap: 0.35rem;
padding: 0.6rem;
border-radius: 0.55rem;
background: var(--toolbox-surface-soft);
}
.probe-result > strong {
color: var(--toolbox-muted);
font-size: 0.65rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.probe-result.complete > strong {
color: var(--device-success);
}
.probe-result.denied > strong,
.probe-result.error > strong {
color: var(--toolbox-danger);
}
.probe-result p {
min-height: 0;
font-size: 0.73rem;
}
.probe-result dl {
display: grid;
gap: 0.25rem;
}
.probe-result dl div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.69rem;
}
.probe-result dt {
color: var(--toolbox-muted);
}
.probe-result dd {
margin: 0;
overflow-wrap: anywhere;
text-align: right;
}
.report-panel pre {
max-height: 30rem;
overflow: auto;
margin: 0.8rem 0 0;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface-soft);
color: var(--toolbox-text);
font:
0.73rem/1.55 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
scrollbar-gutter: stable;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
margin-top: 0.7rem;
}
.loading,
.fatal {
width: min(100% - 2rem, 60rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(38rem, calc(100% - 2rem));
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.help-dialog::backdrop {
background: rgb(20 24 45 / 55%);
}
.dialog-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
.help-dialog p,
.help-dialog li {
line-height: 1.55;
}
@media (max-width: 68rem) {
.probe-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 48rem) {
.principles,
.inventory-toolbar {
grid-template-columns: 1fr;
}
.inventory-toolbar button {
width: 100%;
}
}
@media (max-width: 38rem) {
.hero,
.panel-heading {
flex-direction: column;
align-items: flex-start;
}
.privacy-pill {
order: -1;
}
.probe-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.device-tools",
"name": "Device Tools",
"version": "0.1.0",
"description": "Inspect capabilities without fingerprinting.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "privacy", "system"],
"tags": [
"device",
"browser",
"capabilities",
"permissions",
"privacy",
"webgl"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/device-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/device-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />