Release Device Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 13:08:36 +02:00
parent 06f697e180
commit 0ae5c7b7af
21 changed files with 1490 additions and 50 deletions
+7
View File
@@ -41,6 +41,13 @@ export function HelpDialog({
microphone, screen and persistence checks can prompt; returned media
tracks are stopped immediately and no content is recorded.
</p>
<p>
The compatibility panel reads pasted Toolbox manifests as inert JSON and
explains whether each required or optional browser capability is
available. The MediaCapabilities lab checks only the codec profile you
explicitly submit; its downloadable form replaces exact performance
parameters with broad buckets.
</p>
<p>
Reports contain no timestamp, user agent, locale, identifier, hash or
uniqueness score. Exact high-entropy measurements are bucketed before
+329
View File
@@ -3,15 +3,38 @@ import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
collectPassiveCapabilities,
createRedactedReport,
evaluateRequirementProfile,
exportMediaCapabilityResult,
exportReportCsv,
exportReportJson,
parseRequirementProfile,
PROBES,
probeMediaCapabilities,
runProbe,
type Capability,
type MediaCapabilityResult,
type MediaProbeConfig,
type ProbeId,
type ProbeResult,
type RequirementProfile,
} from "../core/device";
const EXAMPLE_MANIFEST = `{
"id": "de.add-ideas.example-tool",
"name": "Example Tool",
"requirements": {
"secureContext": true,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"capabilities": {
"required": ["file-system"],
"optional": ["webgpu-api", "media-capabilities"]
}
}`;
export function Workbench() {
const [capabilities, setCapabilities] = useState<Capability[]>(() =>
collectPassiveCapabilities(),
@@ -21,6 +44,23 @@ export function Workbench() {
);
const [running, setRunning] = useState<ProbeId | null>(null);
const [filter, setFilter] = useState("");
const [profileSource, setProfileSource] = useState(EXAMPLE_MANIFEST);
const [profile, setProfile] = useState<RequirementProfile | null>(null);
const [profileError, setProfileError] = useState("");
const [mediaKind, setMediaKind] = useState<"video" | "audio">("video");
const [mediaContentType, setMediaContentType] = useState(
'video/mp4; codecs="avc1.42E01E"',
);
const [mediaWidth, setMediaWidth] = useState(1920);
const [mediaHeight, setMediaHeight] = useState(1080);
const [mediaBitrate, setMediaBitrate] = useState(8_000_000);
const [mediaFramerate, setMediaFramerate] = useState(30);
const [mediaChannels, setMediaChannels] = useState("2");
const [mediaSampleRate, setMediaSampleRate] = useState(48_000);
const [mediaResult, setMediaResult] = useState<MediaCapabilityResult | null>(
null,
);
const [mediaRunning, setMediaRunning] = useState(false);
const [status, setStatus] = useState(
"Passive API presence was checked locally. No permission was requested.",
);
@@ -50,6 +90,10 @@ export function Workbench() {
const available = capabilities.filter(
(item) => item.state === "available",
).length;
const requirementEvaluation = useMemo(
() => (profile ? evaluateRequirementProfile(profile, capabilities) : null),
[profile, capabilities],
);
async function performProbe(id: ProbeId): Promise<void> {
setRunning(id);
@@ -60,6 +104,53 @@ export function Workbench() {
setStatus(result.summary);
}
function inspectProfile(): void {
try {
setProfile(parseRequirementProfile(profileSource));
setProfileError("");
} catch (caught) {
setProfile(null);
setProfileError(
caught instanceof Error
? caught.message
: "The profile could not be read.",
);
}
}
async function inspectMediaConfiguration(): Promise<void> {
setMediaRunning(true);
setMediaResult(null);
try {
const configuration: MediaProbeConfig =
mediaKind === "video"
? {
kind: "video",
contentType: mediaContentType,
width: mediaWidth,
height: mediaHeight,
bitrate: mediaBitrate,
framerate: mediaFramerate,
}
: {
kind: "audio",
contentType: mediaContentType,
channels: mediaChannels,
bitrate: mediaBitrate,
samplerate: mediaSampleRate,
};
const result = await probeMediaCapabilities(configuration);
setMediaResult(result);
setStatus(result.summary);
} catch (caught) {
setStatus(
caught instanceof Error ? caught.message : "The media probe failed.",
);
} finally {
setMediaRunning(false);
}
}
return (
<main className="workbench">
<section className="hero">
@@ -153,6 +244,12 @@ export function Workbench() {
<td>{item.value}</td>
<td>
<Risk value={item.privacy} />
{item.state !== "available" && item.availability && (
<small>
{item.availability}
{item.remediation ? ` · ${item.remediation}` : ""}
</small>
)}
</td>
</tr>
))}
@@ -167,6 +264,185 @@ export function Workbench() {
</div>
</section>
<section className="panel" aria-labelledby="compatibility-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Application compatibility</p>
<h2 id="compatibility-title">Will this Toolbox app work here?</h2>
</div>
{requirementEvaluation && (
<span
className={`compatibility-status ${requirementEvaluation.status}`}
>
{requirementEvaluation.status}
</span>
)}
</div>
<p className="muted">
Paste a Toolbox manifest or capability profile. The file is parsed
locally and matched against the passive inventory; no application code
is loaded.
</p>
<div className="compatibility-layout">
<label>
Manifest JSON
<textarea
value={profileSource}
onChange={(event) => setProfileSource(event.target.value)}
spellCheck={false}
rows={13}
/>
</label>
<div className="compatibility-output">
<button type="button" onClick={inspectProfile}>
Analyse requirements
</button>
{profileError && (
<p className="validation-error" role="alert">
{profileError}
</p>
)}
{requirementEvaluation && (
<>
<p>
<strong>{requirementEvaluation.profile.name}</strong> is{" "}
<strong>{requirementEvaluation.status}</strong> in this
context.
</p>
{requirementEvaluation.requirements.length === 0 ? (
<p className="muted">The profile declares no requirements.</p>
) : (
<ul className="requirement-list">
{requirementEvaluation.requirements.map((item) => (
<li key={`${item.importance}-${item.id}`}>
<span>
<State state={item.state} />
<strong>{item.label}</strong>
<small>{item.importance}</small>
</span>
<p>{item.reason}</p>
{item.remediation && <p>{item.remediation}</p>}
</li>
))}
</ul>
)}
</>
)}
</div>
</div>
</section>
<section className="panel" aria-labelledby="media-capabilities-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Opt-in decoding check</p>
<h2 id="media-capabilities-title">MediaCapabilities lab</h2>
</div>
<Risk value="high" />
</div>
<p className="muted">
Ask the browser about one exact local decoding configuration. This can
reveal device-dependent performance characteristics, so it never runs
automatically and exported numbers are bucketed.
</p>
<div className="media-form">
<label>
Media kind
<select
value={mediaKind}
onChange={(event) => {
const kind = event.target.value as "video" | "audio";
setMediaKind(kind);
setMediaContentType(
kind === "video"
? 'video/mp4; codecs="avc1.42E01E"'
: 'audio/webm; codecs="opus"',
);
setMediaBitrate(kind === "video" ? 8_000_000 : 192_000);
setMediaResult(null);
}}
>
<option value="video">Video</option>
<option value="audio">Audio</option>
</select>
</label>
<label className="wide-field">
MIME type and codec
<input
value={mediaContentType}
onChange={(event) => setMediaContentType(event.target.value)}
spellCheck={false}
/>
</label>
{mediaKind === "video" ? (
<>
<NumberField
label="Width"
value={mediaWidth}
onChange={setMediaWidth}
/>
<NumberField
label="Height"
value={mediaHeight}
onChange={setMediaHeight}
/>
<NumberField
label="Frames per second"
value={mediaFramerate}
onChange={setMediaFramerate}
/>
</>
) : (
<>
<label>
Channels
<input
inputMode="numeric"
value={mediaChannels}
onChange={(event) => setMediaChannels(event.target.value)}
/>
</label>
<NumberField
label="Sample rate (Hz)"
value={mediaSampleRate}
onChange={setMediaSampleRate}
/>
</>
)}
<NumberField
label="Bitrate (bit/s)"
value={mediaBitrate}
onChange={setMediaBitrate}
/>
</div>
<div className="button-row">
<button
type="button"
className="primary-button"
disabled={mediaRunning}
onClick={() => void inspectMediaConfiguration()}
>
{mediaRunning ? "Checking…" : "Check this configuration"}
</button>
{mediaResult && (
<button
type="button"
onClick={() =>
download(
new Blob([exportMediaCapabilityResult(mediaResult)], {
type: "application/json",
}),
"media-capability-redacted.json",
)
}
>
Download bucketed result
</button>
)}
</div>
{mediaResult && <MediaOutcome result={mediaResult} />}
</section>
<section className="panel" aria-labelledby="probes-title">
<div className="panel-heading">
<div>
@@ -257,11 +533,64 @@ export function Workbench() {
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<label>
{label}
<input
type="number"
value={value}
onChange={(event) => onChange(event.target.valueAsNumber)}
/>
</label>
);
}
function MediaOutcome({ result }: { result: MediaCapabilityResult }) {
return (
<div className={`media-result ${result.state}`} role="status">
<strong>{result.state}</strong>
<p>{result.summary}</p>
{result.supported !== undefined && (
<dl>
<div>
<dt>Supported</dt>
<dd>{result.supported ? "Yes" : "No"}</dd>
</div>
<div>
<dt>Smooth</dt>
<dd>{result.smooth ? "Yes" : "No"}</dd>
</div>
<div>
<dt>Power efficient</dt>
<dd>{result.powerEfficient ? "Yes" : "No"}</dd>
</div>
</dl>
)}
{result.remediation && <p>{result.remediation}</p>}
</div>
);
}
function ProbeOutcome({ result }: { result: ProbeResult }) {
return (
<div className={"probe-result " + result.state}>
<strong>{result.state}</strong>
<p>{result.summary}</p>
{result.availability && result.availability !== "available" && (
<p>
<strong>Reason:</strong> {result.availability}
{result.remediation ? ` · ${result.remediation}` : ""}
</p>
)}
{result.details.length > 0 && (
<dl>
{result.details.map((detail) => (
+652 -4
View File
@@ -2,6 +2,15 @@ import { stableStringify, stringifyCsv } from "@add-ideas/toolbox-helpers";
export type CapabilityState = "available" | "unavailable" | "unknown";
export type PrivacyRisk = "low" | "medium" | "high";
export type AvailabilityReason =
| "available"
| "missing-api"
| "insecure-context"
| "embedded-context"
| "permissions-policy"
| "user-permission"
| "device-or-os"
| "unknown";
export interface Capability {
id: string;
@@ -11,6 +20,8 @@ export interface Capability {
value: string;
privacy: PrivacyRisk;
explanation: string;
availability?: AvailabilityReason;
remediation?: string;
}
export interface ProbeDetail {
@@ -25,6 +36,8 @@ export interface ProbeResult {
state: "complete" | "unsupported" | "denied" | "error";
summary: string;
details: ProbeDetail[];
availability?: AvailabilityReason;
remediation?: string;
}
export type ProbeId =
@@ -169,7 +182,7 @@ interface GpuAdapterLike {
limits?: Record<string, number>;
}
type ExtendedNavigator = Navigator & {
type ExtendedNavigator = Omit<Navigator, "mediaCapabilities"> & {
deviceMemory?: number;
connection?: ConnectionLike;
mozConnection?: ConnectionLike;
@@ -188,6 +201,13 @@ type ExtendedNavigator = Navigator & {
keyboard?: unknown;
userAgentData?: unknown;
requestMIDIAccess?: unknown;
mediaCapabilities?: {
decodingInfo(configuration: unknown): Promise<{
supported: boolean;
smooth: boolean;
powerEfficient: boolean;
}>;
};
};
export interface DeviceEnvironment {
@@ -199,6 +219,302 @@ export interface DeviceEnvironment {
isolated: boolean;
}
export interface RequirementProfile {
id: string;
name: string;
required: string[];
optional: string[];
}
export interface RequirementEvaluation {
status: "ready" | "degraded" | "blocked";
profile: RequirementProfile;
requirements: Array<{
id: string;
importance: "required" | "optional";
state: CapabilityState;
label: string;
reason: string;
remediation?: string;
}>;
}
export type MediaProbeConfig =
| {
kind: "video";
contentType: string;
width: number;
height: number;
bitrate: number;
framerate: number;
hdrMetadataType?: "smpteSt2086" | "smpteSt2094-10" | "smpteSt2094-40";
}
| {
kind: "audio";
contentType: string;
channels: string;
bitrate: number;
samplerate: number;
};
export interface MediaCapabilityResult {
state: "complete" | "unsupported" | "error";
summary: string;
configuration: MediaProbeConfig;
supported?: boolean;
smooth?: boolean;
powerEfficient?: boolean;
remediation?: string;
}
const LEGACY_REQUIREMENT_CAPABILITIES = {
secureContext: "secure-context",
workers: "workers",
indexedDb: "indexed-db",
crossOriginIsolated: "cross-origin-isolated",
topLevelContext: "top-level",
} as const;
const CAPABILITY_LABELS: Record<string, string> = {
"secure-context": "Secure context",
workers: "Web Workers",
"indexed-db": "IndexedDB",
"cross-origin-isolated": "Cross-origin isolation",
"top-level": "Top-level window",
"camera-api": "Camera API",
"screen-api": "Screen capture API",
"webgpu-api": "WebGPU",
"webgl-api": "WebGL",
"webgl2-api": "WebGL 2",
"storage-api": "Storage API",
"service-worker": "Service Worker",
"file-system": "File System Access",
webauthn: "WebAuthn",
clipboard: "Clipboard",
geolocation: "Geolocation",
"web-midi": "Web MIDI",
"media-capabilities": "Media Capabilities",
};
const PROFILE_LIMIT_BYTES = 128 * 1024;
const PROFILE_LIMIT_CAPABILITIES = 256;
/** Parse a Toolbox manifest (or a small capability profile) without executing it. */
export function parseRequirementProfile(input: unknown): RequirementProfile {
let value = input;
if (typeof input === "string") {
if (new TextEncoder().encode(input).byteLength > PROFILE_LIMIT_BYTES)
throw new Error("The manifest exceeds the 128 KiB inspection limit.");
try {
value = JSON.parse(input) as unknown;
} catch {
throw new Error("The manifest is not valid JSON.");
}
}
if (!isRecord(value)) throw new Error("The profile must be a JSON object.");
const id = boundedProfileText(value.id, "id", "local.application");
const name = boundedProfileText(value.name, "name", id);
const required = new Set<string>();
const optional = new Set<string>();
if (value.requirements !== undefined) {
if (!isRecord(value.requirements))
throw new Error("requirements must be an object.");
for (const [legacyName, capability] of Object.entries(
LEGACY_REQUIREMENT_CAPABILITIES,
)) {
const setting = value.requirements[legacyName];
if (setting !== undefined && typeof setting !== "boolean")
throw new Error(`requirements.${legacyName} must be a boolean.`);
if (setting === true) required.add(capability);
}
}
if (value.capabilities !== undefined) {
if (!isRecord(value.capabilities))
throw new Error("capabilities must be an object.");
for (const item of readCapabilityList(
value.capabilities.required,
"capabilities.required",
))
required.add(item);
for (const item of readCapabilityList(
value.capabilities.optional,
"capabilities.optional",
))
optional.add(item);
}
// A requirement always wins over an optional declaration.
for (const item of required) optional.delete(item);
if (required.size + optional.size > PROFILE_LIMIT_CAPABILITIES)
throw new Error("The profile contains too many capabilities.");
return {
id,
name,
required: [...required].sort(),
optional: [...optional].sort(),
};
}
export function evaluateRequirementProfile(
profile: RequirementProfile,
capabilities: readonly Capability[],
): RequirementEvaluation {
const inventory = new Map(capabilities.map((item) => [item.id, item]));
const evaluate = (
id: string,
importance: "required" | "optional",
): RequirementEvaluation["requirements"][number] => {
const item = inventory.get(id);
if (!item)
return {
id,
importance,
state: "unknown",
label: CAPABILITY_LABELS[id] ?? id,
reason:
"This capability is not known to the current Device Tools catalogue.",
remediation:
"Check the tool documentation or add a matching capability probe before relying on it.",
};
return {
id,
importance,
state: item.state,
label: item.label,
reason:
item.state === "available"
? item.explanation
: `Unavailable: ${item.availability ?? "unknown reason"}.`,
...(item.remediation ? { remediation: item.remediation } : {}),
};
};
const requirements = [
...profile.required.map((id) => evaluate(id, "required")),
...profile.optional.map((id) => evaluate(id, "optional")),
];
const blocked = requirements.some(
(item) => item.importance === "required" && item.state !== "available",
);
const degraded = requirements.some(
(item) => item.importance === "optional" && item.state !== "available",
);
return {
status: blocked ? "blocked" : degraded ? "degraded" : "ready",
profile,
requirements,
};
}
export async function probeMediaCapabilities(
input: MediaProbeConfig,
environment: DeviceEnvironment = liveEnvironment(),
): Promise<MediaCapabilityResult> {
const configuration = validateMediaProbeConfig(input);
const mediaCapabilities = environment.navigator.mediaCapabilities;
if (!mediaCapabilities?.decodingInfo)
return {
state: "unsupported",
summary: "MediaCapabilities.decodingInfo is unavailable.",
configuration,
remediation:
"Use a browser that implements the Media Capabilities decoding-info API.",
};
try {
const query =
configuration.kind === "video"
? {
type: "file",
video: {
contentType: configuration.contentType,
width: configuration.width,
height: configuration.height,
bitrate: configuration.bitrate,
framerate: configuration.framerate,
...(configuration.hdrMetadataType
? { hdrMetadataType: configuration.hdrMetadataType }
: {}),
},
}
: {
type: "file",
audio: {
contentType: configuration.contentType,
channels: configuration.channels,
bitrate: configuration.bitrate,
samplerate: configuration.samplerate,
},
};
const answer = await mediaCapabilities.decodingInfo(query);
return {
state: "complete",
summary: answer.supported
? "The browser reports support for this decoding configuration."
: "The browser does not report support for this decoding configuration.",
configuration,
supported: Boolean(answer.supported),
smooth: Boolean(answer.smooth),
powerEfficient: Boolean(answer.powerEfficient),
};
} catch (caught) {
return {
state: "error",
summary: safeError(caught),
configuration,
remediation:
"Check that the MIME type includes a valid codec string and that every numeric field is supported by this browser.",
};
}
}
export function exportMediaCapabilityResult(
result: MediaCapabilityResult,
): string {
const configuration =
result.configuration.kind === "video"
? {
kind: "video",
contentType: result.configuration.contentType,
dimensions: mediaDimensionClass(
result.configuration.width,
result.configuration.height,
),
bitrate: mediaBitrateClass(result.configuration.bitrate),
framerate: mediaFramerateClass(result.configuration.framerate),
...(result.configuration.hdrMetadataType
? { hdrMetadataType: result.configuration.hdrMetadataType }
: {}),
}
: {
kind: "audio",
contentType: result.configuration.contentType,
channels: channelClass(result.configuration.channels),
bitrate: mediaBitrateClass(result.configuration.bitrate),
samplerate: sampleRateClass(result.configuration.samplerate),
};
return (
stableStringify(
{
schemaVersion: 1,
privacy:
"Numeric media parameters are bucketed; no device identifier is included.",
state: result.state,
summary:
result.state === "error" ? "Probe failed locally." : result.summary,
configuration,
...(result.supported === undefined
? {}
: {
supported: result.supported,
smooth: result.smooth,
powerEfficient: result.powerEfficient,
}),
},
2,
{ maxDepth: 8, maxNodes: 1_000, maxTextChars: 100_000 },
) + "\n"
);
}
export function liveEnvironment(): DeviceEnvironment {
return {
window,
@@ -237,6 +553,7 @@ export function collectPassiveCapabilities(
value,
privacy,
explanation,
...availabilityFor(id, available, environment),
});
};
@@ -463,6 +780,18 @@ export function collectPassiveCapabilities(
"serviceWorker" in nav,
"Offline/PWA worker API presence",
],
[
"workers",
"Web Workers",
"Worker" in currentWindow,
"Dedicated worker API presence",
],
[
"media-capabilities",
"Media Capabilities",
Boolean(nav.mediaCapabilities?.decodingInfo),
"Configuration-specific decoding checks require an explicit probe",
],
[
"cache-storage",
"Cache Storage",
@@ -583,6 +912,8 @@ export async function runProbe(
id: ProbeId,
environment: DeviceEnvironment = liveEnvironment(),
): Promise<ProbeResult> {
const preflight = probePreflight(id, environment);
if (preflight) return preflight;
try {
switch (id) {
case "permissions":
@@ -615,6 +946,13 @@ export async function runProbe(
"SecurityError",
"PermissionDeniedError",
].includes(name);
const availability: AvailabilityReason = denied
? "user-permission"
: ["NotFoundError", "NotReadableError", "OverconstrainedError"].includes(
name,
)
? "device-or-os"
: "unknown";
return {
id,
state: denied ? "denied" : "error",
@@ -622,10 +960,139 @@ export async function runProbe(
? "The browser or policy denied this probe."
: safeError(caught),
details: [{ label: "Outcome", value: name }],
availability,
remediation:
availability === "user-permission"
? "Review this site's browser permission and operating-system privacy settings, then retry from a user gesture."
: availability === "device-or-os"
? "Connect or enable a compatible device and ensure it is not exclusively used by another application."
: "Review the local diagnostic and browser console; no network request was made.",
};
}
}
type PolicyDocument = Document & {
permissionsPolicy?: { allowsFeature(name: string): boolean };
featurePolicy?: { allowsFeature(name: string): boolean };
};
const SECURE_CONTEXT_CAPABILITIES = new Set([
"camera-api",
"screen-api",
"webgpu-api",
"clipboard",
"geolocation",
"notifications",
"bluetooth",
"usb",
"serial",
"hid",
"webauthn",
"web-midi",
]);
const CAPABILITY_POLICY: Record<string, string> = {
"camera-api": "camera",
"screen-api": "display-capture",
geolocation: "geolocation",
microphone: "microphone",
"web-midi": "midi",
fullscreen: "fullscreen",
};
function policyAllows(
document: Document,
feature: string,
): boolean | undefined {
const policyDocument = document as PolicyDocument;
const policy =
policyDocument.permissionsPolicy ?? policyDocument.featurePolicy;
if (!policy?.allowsFeature) return undefined;
try {
return policy.allowsFeature(feature);
} catch {
return undefined;
}
}
function availabilityFor(
id: string,
available: boolean,
environment: DeviceEnvironment,
): Pick<Capability, "availability" | "remediation"> {
if (available) return { availability: "available" };
if (SECURE_CONTEXT_CAPABILITIES.has(id) && !environment.secureContext) {
return {
availability: "insecure-context",
remediation: "Open the application over HTTPS or on localhost.",
};
}
const feature = CAPABILITY_POLICY[id];
if (feature && policyAllows(environment.document, feature) === false) {
return {
availability: "permissions-policy",
remediation: `Allow the ${feature} feature in the page's Permissions-Policy and iframe allow attribute.`,
};
}
return {
availability: "missing-api",
remediation:
"Use a browser/version that implements this API; vendor or operating-system support may also be required.",
};
}
function probePreflight(
id: ProbeId,
environment: DeviceEnvironment,
): ProbeResult | undefined {
const feature =
id === "camera"
? "camera"
: id === "microphone"
? "microphone"
: id === "screen-capture"
? "display-capture"
: undefined;
if (
["camera", "microphone", "screen-capture", "webgpu"].includes(id) &&
!environment.secureContext
) {
return {
id,
state: "unsupported",
summary: "This probe requires a secure context.",
details: [],
availability: "insecure-context",
remediation: "Open the application over HTTPS or on localhost.",
};
}
if (
["camera", "microphone", "screen-capture"].includes(id) &&
!safelyTopLevel(environment.window)
) {
return {
id,
state: "unsupported",
summary: "This probe is restricted in the current embedded context.",
details: [],
availability: "embedded-context",
remediation:
"Open the tool in its top-level tab, or configure an explicit iframe allow policy.",
};
}
if (feature && policyAllows(environment.document, feature) === false) {
return {
id,
state: "denied",
summary: `Permissions Policy blocks ${feature}.`,
details: [],
availability: "permissions-policy",
remediation: `Allow ${feature} in Permissions-Policy and any iframe allow attribute.`,
};
}
return undefined;
}
async function probePermissions(nav: ExtendedNavigator): Promise<ProbeResult> {
if (!nav.permissions?.query)
return unsupported("permissions", "Permissions API is unavailable.");
@@ -918,7 +1385,15 @@ async function probeScreen(nav: ExtendedNavigator): Promise<ProbeResult> {
}
function unsupported(id: ProbeId, summary: string): ProbeResult {
return { id, state: "unsupported", summary, details: [] };
return {
id,
state: "unsupported",
summary,
details: [],
availability: "missing-api",
remediation:
"Use a browser/version and operating system that implement this capability.",
};
}
function codecChecks(
@@ -1091,21 +1566,190 @@ function timeClass(value: number | undefined): string {
: "4-hours-or-more";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function boundedProfileText(
value: unknown,
field: string,
fallback: string,
): string {
if (value === undefined) return fallback;
if (typeof value !== "string" || value.trim().length === 0)
throw new Error(`${field} must be a non-empty string.`);
const result = value.trim();
if (result.length > 200) throw new Error(`${field} is too long.`);
return result;
}
function readCapabilityList(value: unknown, field: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error(`${field} must be an array.`);
return value.map((item, index) => {
if (
typeof item !== "string" ||
!/^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/.test(item)
)
throw new Error(`${field}[${index}] is not a valid capability ID.`);
return item;
});
}
function validateMediaProbeConfig(input: MediaProbeConfig): MediaProbeConfig {
if (!isRecord(input)) throw new Error("The media configuration is invalid.");
const kind = input.kind;
if (kind !== "video" && kind !== "audio")
throw new Error("Media kind must be video or audio.");
const contentType = validateMediaContentType(input.contentType, kind);
if (kind === "video") {
const width = boundedNumber(input.width, "width", 16, 16_384);
const height = boundedNumber(input.height, "height", 16, 16_384);
const bitrate = boundedNumber(
input.bitrate,
"bitrate",
1_000,
2_000_000_000,
);
const framerate = boundedNumber(input.framerate, "framerate", 0.1, 480);
const hdrMetadataType = input.hdrMetadataType;
if (
hdrMetadataType !== undefined &&
!["smpteSt2086", "smpteSt2094-10", "smpteSt2094-40"].includes(
hdrMetadataType,
)
)
throw new Error("Unknown HDR metadata type.");
return {
kind,
contentType,
width,
height,
bitrate,
framerate,
...(hdrMetadataType ? { hdrMetadataType } : {}),
};
}
const channels = input.channels;
if (typeof channels !== "string" || !/^(?:[1-9]|[1-9][0-9])$/.test(channels))
throw new Error("channels must be a number from 1 to 99.");
return {
kind,
contentType,
channels,
bitrate: boundedNumber(input.bitrate, "bitrate", 1_000, 100_000_000),
samplerate: boundedNumber(input.samplerate, "samplerate", 1_000, 768_000),
};
}
function validateMediaContentType(
value: unknown,
kind: "video" | "audio",
): string {
if (typeof value !== "string")
throw new Error("contentType must be a string.");
const contentType = value.trim();
if (
contentType.length === 0 ||
contentType.length > 240 ||
/[\r\n\0]/.test(contentType) ||
!contentType.toLowerCase().startsWith(`${kind}/`)
)
throw new Error(`contentType must be a bounded ${kind} MIME type.`);
return contentType;
}
function boundedNumber(
value: unknown,
field: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
)
throw new Error(`${field} must be between ${minimum} and ${maximum}.`);
return value;
}
function mediaDimensionClass(width: number, height: number): string {
const pixels = width * height;
return pixels <= 640 * 480
? "SD-or-smaller"
: pixels <= 1280 * 720
? "HD"
: pixels <= 1920 * 1080
? "Full-HD"
: pixels <= 3840 * 2160
? "4K"
: "over-4K";
}
function mediaBitrateClass(value: number): string {
return value < 256_000
? "under-256-kbit/s"
: value < 2_000_000
? "256-kbit/s-to-2-Mbit/s"
: value < 10_000_000
? "2-to-10-Mbit/s"
: value < 50_000_000
? "10-to-50-Mbit/s"
: "50-Mbit/s-or-more";
}
function mediaFramerateClass(value: number): string {
return value <= 24
? "up-to-24-fps"
: value <= 30
? "up-to-30-fps"
: value <= 60
? "up-to-60-fps"
: "over-60-fps";
}
function sampleRateClass(value: number): string {
return value <= 44_100
? "up-to-44.1-kHz"
: value <= 48_000
? "up-to-48-kHz"
: value <= 96_000
? "up-to-96-kHz"
: "over-96-kHz";
}
function channelClass(value: string): string {
const count = Number(value);
return count === 1
? "mono"
: count === 2
? "stereo"
: count <= 6
? "surround"
: "many-channel";
}
export interface RedactedDeviceReport {
schemaVersion: 1;
application: { id: "de.add-ideas.device-tools"; version: "0.1.0" };
application: { id: "de.add-ideas.device-tools"; version: "0.2.0" };
policy: string[];
capabilities: Array<{
id: string;
group: string;
state: CapabilityState;
value: string;
availability?: AvailabilityReason;
remediation?: string;
}>;
probes: Array<{
id: ProbeId;
state: ProbeResult["state"];
summary: string;
details: Array<{ label: string; value: string }>;
availability?: AvailabilityReason;
remediation?: string;
}>;
}
@@ -1115,7 +1759,7 @@ export function createRedactedReport(
): RedactedDeviceReport {
return {
schemaVersion: 1,
application: { id: "de.add-ideas.device-tools", version: "0.1.0" },
application: { id: "de.add-ideas.device-tools", version: "0.2.0" },
policy: [
"No stable identifier",
"No fingerprint score",
@@ -1127,6 +1771,8 @@ export function createRedactedReport(
group: item.group,
state: item.state,
value: redactCapability(item),
...(item.availability ? { availability: item.availability } : {}),
...(item.remediation ? { remediation: item.remediation } : {}),
})),
probes: results.map((result) => ({
id: result.id,
@@ -1139,6 +1785,8 @@ export function createRedactedReport(
label: detail.label,
value: detail.redacted ?? detail.value,
})),
...(result.availability ? { availability: result.availability } : {}),
...(result.remediation ? { remediation: result.remediation } : {}),
})),
};
}
+164 -4
View File
@@ -75,7 +75,7 @@ body {
.workbench :where(p, ul, dl) {
margin-block: 0;
}
.workbench :where(button, input),
.workbench :where(button, input, select, textarea),
.help-dialog button {
font: inherit;
}
@@ -114,13 +114,14 @@ body {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
:where(.workbench, .help-dialog) :where(button, input):focus-visible,
:where(.workbench, .help-dialog)
:where(button, input, select, textarea):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 {
.workbench :where(input, select, textarea) {
width: 100%;
min-width: 0;
min-height: 2.55rem;
@@ -130,6 +131,12 @@ body {
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.workbench textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.5;
tab-size: 2;
}
.workbench label {
min-width: 0;
display: grid;
@@ -223,6 +230,146 @@ body {
gap: 0.65rem;
margin-top: 0.85rem;
}
.compatibility-layout {
display: grid;
grid-template-columns: minmax(18rem, 0.9fr) minmax(20rem, 1.1fr);
gap: 0.85rem;
margin-top: 0.9rem;
}
.compatibility-output {
min-width: 0;
display: grid;
align-content: start;
gap: 0.65rem;
}
.compatibility-output > button {
justify-self: start;
}
.compatibility-output > p {
font-size: 0.82rem;
line-height: 1.5;
}
.compatibility-status {
padding: 0.38rem 0.64rem;
border-radius: 999px;
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
font-size: 0.69rem;
font-weight: 820;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.compatibility-status.ready {
background: color-mix(
in srgb,
var(--device-success) 11%,
var(--toolbox-surface)
);
color: var(--device-success);
}
.compatibility-status.degraded {
background: color-mix(
in srgb,
var(--device-warning) 11%,
var(--toolbox-surface)
);
color: var(--device-warning);
}
.compatibility-status.blocked {
background: color-mix(
in srgb,
var(--toolbox-danger) 10%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
}
.requirement-list {
max-height: 25rem;
display: grid;
gap: 0.5rem;
overflow: auto;
margin: 0;
padding: 0;
list-style: none;
scrollbar-gutter: stable;
}
.requirement-list li {
padding: 0.62rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
}
.requirement-list li > span {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.requirement-list li small {
color: var(--toolbox-muted);
font-size: 0.68rem;
text-transform: uppercase;
}
.requirement-list p {
margin-top: 0.35rem !important;
color: var(--toolbox-muted);
font-size: 0.73rem;
line-height: 1.45;
}
.validation-error {
padding: 0.6rem;
border-radius: 0.55rem;
background: color-mix(
in srgb,
var(--toolbox-danger) 9%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
}
.media-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 0.9rem;
}
.media-form .wide-field {
grid-column: span 2;
}
.media-result {
display: grid;
gap: 0.45rem;
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface-soft);
font-size: 0.78rem;
}
.media-result > strong {
color: var(--toolbox-muted);
font-size: 0.67rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.media-result.complete > strong {
color: var(--device-success);
}
.media-result.error > strong {
color: var(--toolbox-danger);
}
.media-result dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.media-result dl div {
padding: 0.5rem;
border-radius: 0.45rem;
background: var(--toolbox-surface);
}
.media-result dd {
margin: 0.15rem 0 0;
font-weight: 760;
}
.capability-groups {
display: grid;
gap: 0.85rem;
@@ -452,13 +599,19 @@ body {
}
@media (max-width: 48rem) {
.principles,
.inventory-toolbar {
.inventory-toolbar,
.compatibility-layout {
grid-template-columns: 1fr;
}
.inventory-toolbar button {
width: 100%;
}
}
@media (max-width: 54rem) {
.media-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 38rem) {
.hero,
.panel-heading {
@@ -471,6 +624,13 @@ body {
.probe-grid {
grid-template-columns: 1fr;
}
.media-form,
.media-result dl {
grid-template-columns: 1fr;
}
.media-form .wide-field {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
*,
+23 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.device-tools",
"name": "Device Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect capabilities without fingerprinting.",
"entry": "./",
"icon": "./favicon.svg",
@@ -28,6 +28,28 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/manifest+json",
"extensions": [".webmanifest"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
}
]
},
"capabilities": {
"required": [],
"optional": ["media-capabilities", "permissions-api", "secure-context"]
},
"privacy": {
"processing": "local",
"fileUploads": false,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";