@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user