@@ -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) => (
|
||||
|
||||
+652
-4
@@ -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
@@ -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) {
|
||||
*,
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user