@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
- name: Install browser engines
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Browser tests
|
||||
run: npm run test:browser
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Added Toolbox manifest compatibility analysis for legacy requirements and
|
||||
required/optional capability profiles with explainable unknowns.
|
||||
- Added an explicitly initiated MediaCapabilities codec lab and downloadable,
|
||||
bucketed privacy-preserving evidence.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added passive display, input, codec, media-constraint, PWA and browser API inventory.
|
||||
|
||||
@@ -6,6 +6,9 @@ A local browser capability and privacy lab. It shows useful platform evidence wi
|
||||
|
||||
- Passive inventory for secure/top-level context, display/viewport/DPR, touch/pointer/hover, accessibility media queries, media constraints, codec declarations, storage/PWA and browser device-API presence.
|
||||
- Explicit individual probes for permission state, storage estimate/persistence, WebGL limits, WebGPU adapter presence, media-device counts, battery, network hints and camera/microphone/screen access.
|
||||
- Explainable failures that distinguish a missing API, insecure or embedded context, Permissions Policy, user permission, and unavailable/busy operating-system devices, with concrete remediation.
|
||||
- Toolbox application compatibility analysis from pasted manifest JSON, including legacy requirements plus required/optional capability profiles and unknown-capability evidence.
|
||||
- An explicit MediaCapabilities decoding lab for video/audio codec, dimensions, rate and bitrate, with support/smoothness/power-efficiency results and a separately downloadable bucketed report.
|
||||
- Camera, microphone and screen streams are never recorded and every returned track stops immediately.
|
||||
- WebGL deliberately avoids unmasked vendor/renderer extensions; media device IDs and labels are discarded.
|
||||
- Deterministic redacted JSON/CSV reports with broad buckets for high-entropy values and no timestamp, user agent, locale, identifier or score.
|
||||
@@ -13,13 +16,13 @@ A local browser capability and privacy lab. It shows useful platform evidence wi
|
||||
|
||||
## Privacy boundary
|
||||
|
||||
The passive inventory runs locally on load and does not request permission. Optional probes run only after their named button is pressed; buttons labelled “may prompt” can show browser or operating-system UI. The application performs no canvas/audio sample fingerprinting, persistent storage, telemetry or external request.
|
||||
The passive inventory runs locally on load and does not request permission. Optional probes run only after their named button is pressed; buttons labelled “may prompt” can show browser or operating-system UI. Application manifests are treated as inert JSON and no referenced code is loaded. MediaCapabilities checks are likewise opt-in because smoothness and power-efficiency answers can depend on the device. The application performs no canvas/audio sample fingerprinting, persistent storage, telemetry or external request.
|
||||
|
||||
Downloaded reports are deliberately less precise than the on-screen local view. They bucket exact dimensions, pixel ratio, storage, graphics limits, hardware counts, battery timing and connection quality. This is a diagnostics report, not proof that an API will work in every embedded or enterprise-policy context.
|
||||
|
||||
## Development
|
||||
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/device-tools-0.1.0.zip` with a SHA-256 sidecar.
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/device-tools-0.2.0.zip` with a SHA-256 sidecar.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Device Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Repository: https://git.add-ideas.de/lotobo/device-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
||||
- Artifact: `device-tools-0.1.0.zip`
|
||||
- Artifact: `device-tools-0.2.0.zip`
|
||||
|
||||
The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
|
||||
|
||||
Generated
+20
-20
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "device-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "device-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -42,24 +42,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -67,13 +67,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "device-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect browser capabilities and privacy-safe device probes locally.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -39,14 +39,14 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
||||
timeout: 180_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
testMatch: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Added Toolbox manifest compatibility analysis for legacy requirements and
|
||||
required/optional capability profiles with explainable unknowns.
|
||||
- Added an explicitly initiated MediaCapabilities codec lab and downloadable,
|
||||
bucketed privacy-preserving evidence.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added passive display, input, codec, media-constraint, PWA and browser API inventory.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-contract@0.2.3
|
||||
@add-ideas/toolbox-contract@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-helpers@0.1.0
|
||||
@add-ideas/toolbox-helpers@0.2.0
|
||||
Declared licence: GPL-3.0-or-later
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-shell-react@0.2.3
|
||||
@add-ideas/toolbox-shell-react@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
|
||||
+5
-2
@@ -6,6 +6,9 @@ A local browser capability and privacy lab. It shows useful platform evidence wi
|
||||
|
||||
- Passive inventory for secure/top-level context, display/viewport/DPR, touch/pointer/hover, accessibility media queries, media constraints, codec declarations, storage/PWA and browser device-API presence.
|
||||
- Explicit individual probes for permission state, storage estimate/persistence, WebGL limits, WebGPU adapter presence, media-device counts, battery, network hints and camera/microphone/screen access.
|
||||
- Explainable failures that distinguish a missing API, insecure or embedded context, Permissions Policy, user permission, and unavailable/busy operating-system devices, with concrete remediation.
|
||||
- Toolbox application compatibility analysis from pasted manifest JSON, including legacy requirements plus required/optional capability profiles and unknown-capability evidence.
|
||||
- An explicit MediaCapabilities decoding lab for video/audio codec, dimensions, rate and bitrate, with support/smoothness/power-efficiency results and a separately downloadable bucketed report.
|
||||
- Camera, microphone and screen streams are never recorded and every returned track stops immediately.
|
||||
- WebGL deliberately avoids unmasked vendor/renderer extensions; media device IDs and labels are discarded.
|
||||
- Deterministic redacted JSON/CSV reports with broad buckets for high-entropy values and no timestamp, user agent, locale, identifier or score.
|
||||
@@ -13,13 +16,13 @@ A local browser capability and privacy lab. It shows useful platform evidence wi
|
||||
|
||||
## Privacy boundary
|
||||
|
||||
The passive inventory runs locally on load and does not request permission. Optional probes run only after their named button is pressed; buttons labelled “may prompt” can show browser or operating-system UI. The application performs no canvas/audio sample fingerprinting, persistent storage, telemetry or external request.
|
||||
The passive inventory runs locally on load and does not request permission. Optional probes run only after their named button is pressed; buttons labelled “may prompt” can show browser or operating-system UI. Application manifests are treated as inert JSON and no referenced code is loaded. MediaCapabilities checks are likewise opt-in because smoothness and power-efficiency answers can depend on the device. The application performs no canvas/audio sample fingerprinting, persistent storage, telemetry or external request.
|
||||
|
||||
Downloaded reports are deliberately less precise than the on-screen local view. They bucket exact dimensions, pixel ratio, storage, graphics limits, hardware counts, battery timing and connection quality. This is a diagnostics report, not proof that an API will work in every embedded or enterprise-policy context.
|
||||
|
||||
## Development
|
||||
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/device-tools-0.1.0.zip` with a SHA-256 sidecar.
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/device-tools-0.2.0.zip` with a SHA-256 sidecar.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Device Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Repository: https://git.add-ideas.de/lotobo/device-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
||||
- Artifact: `device-tools-0.1.0.zip`
|
||||
- Artifact: `device-tools-0.2.0.zip`
|
||||
|
||||
The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
const CACHE_PREFIX = "device-tools-shell-";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
|
||||
+15
-1
@@ -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,20 @@
|
||||
"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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/device/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -2,8 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPassiveCapabilities,
|
||||
createRedactedReport,
|
||||
evaluateRequirementProfile,
|
||||
exportMediaCapabilityResult,
|
||||
exportReportCsv,
|
||||
exportReportJson,
|
||||
parseRequirementProfile,
|
||||
probeMediaCapabilities,
|
||||
runProbe,
|
||||
type Capability,
|
||||
type DeviceEnvironment,
|
||||
@@ -21,6 +25,7 @@ function environment(
|
||||
visualViewport: { width: 1200, height: 700 },
|
||||
matchMedia: (query: string) => ({ matches: query.includes("fine") }),
|
||||
WebGLRenderingContext: function WebGLRenderingContext() {},
|
||||
Worker: function Worker() {},
|
||||
indexedDB: {},
|
||||
caches: {},
|
||||
} as unknown as Window;
|
||||
@@ -81,6 +86,61 @@ describe("passive device inventory", () => {
|
||||
expect(capabilities.find((item) => item.id === "camera-api")?.state).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(
|
||||
capabilities.find((item) => item.id === "camera-api")?.availability,
|
||||
).toBe("missing-api");
|
||||
});
|
||||
|
||||
it("distinguishes insecure context and Permissions Policy failures", async () => {
|
||||
const insecure = environment();
|
||||
insecure.secureContext = false;
|
||||
expect(
|
||||
collectPassiveCapabilities(insecure).find(
|
||||
(item) => item.id === "camera-api",
|
||||
),
|
||||
).toMatchObject({ availability: "insecure-context" });
|
||||
expect(await runProbe("camera", insecure)).toMatchObject({
|
||||
state: "unsupported",
|
||||
availability: "insecure-context",
|
||||
});
|
||||
|
||||
const blocked = environment({
|
||||
mediaDevices: { getUserMedia: vi.fn() },
|
||||
});
|
||||
Object.assign(blocked.document, {
|
||||
permissionsPolicy: {
|
||||
allowsFeature: (feature: string) => feature !== "camera",
|
||||
},
|
||||
});
|
||||
expect(await runProbe("camera", blocked)).toMatchObject({
|
||||
state: "denied",
|
||||
availability: "permissions-policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes missing devices from denied permission", async () => {
|
||||
const missing = await runProbe(
|
||||
"camera",
|
||||
environment({
|
||||
mediaDevices: {
|
||||
getUserMedia: async () => {
|
||||
throw new DOMException("No camera", "NotFoundError");
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(missing).toMatchObject({ availability: "device-or-os" });
|
||||
const denied = await runProbe(
|
||||
"camera",
|
||||
environment({
|
||||
mediaDevices: {
|
||||
getUserMedia: async () => {
|
||||
throw new DOMException("Denied", "NotAllowedError");
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(denied).toMatchObject({ availability: "user-permission" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,3 +240,108 @@ describe("redacted exports", () => {
|
||||
expect(report.policy.join(" ")).toContain("No stable identifier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application requirement profiles", () => {
|
||||
it("combines legacy requirements with required and optional capabilities", () => {
|
||||
const profile = parseRequirementProfile({
|
||||
id: "de.example.tool",
|
||||
name: "Example",
|
||||
requirements: { secureContext: true, workers: true },
|
||||
capabilities: {
|
||||
required: ["file-system", "workers"],
|
||||
optional: ["webgpu-api", "workers"],
|
||||
},
|
||||
});
|
||||
expect(profile.required).toEqual([
|
||||
"file-system",
|
||||
"secure-context",
|
||||
"workers",
|
||||
]);
|
||||
expect(profile.optional).toEqual(["webgpu-api"]);
|
||||
|
||||
const evaluation = evaluateRequirementProfile(
|
||||
profile,
|
||||
collectPassiveCapabilities(environment()),
|
||||
);
|
||||
expect(evaluation.status).toBe("blocked");
|
||||
expect(
|
||||
evaluation.requirements.find((item) => item.id === "workers")?.state,
|
||||
).toBe("available");
|
||||
expect(
|
||||
evaluation.requirements.find((item) => item.id === "file-system")
|
||||
?.remediation,
|
||||
).toContain("browser/version");
|
||||
});
|
||||
|
||||
it("reports unknown required identifiers and rejects malformed profiles", () => {
|
||||
const profile = parseRequirementProfile(
|
||||
JSON.stringify({ capabilities: { required: ["future-api"] } }),
|
||||
);
|
||||
const result = evaluateRequirementProfile(profile, []);
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(result.requirements[0]).toMatchObject({ state: "unknown" });
|
||||
expect(() =>
|
||||
parseRequirementProfile({ capabilities: { required: ["Bad ID"] } }),
|
||||
).toThrow(/capability ID/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MediaCapabilities lab", () => {
|
||||
it("runs only the requested configuration and exports bucketed numbers", async () => {
|
||||
const decodingInfo = vi.fn(async () => ({
|
||||
supported: true,
|
||||
smooth: false,
|
||||
powerEfficient: true,
|
||||
}));
|
||||
const result = await probeMediaCapabilities(
|
||||
{
|
||||
kind: "video",
|
||||
contentType: 'video/mp4; codecs="avc1.42E01E"',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bitrate: 8_765_432,
|
||||
framerate: 30,
|
||||
},
|
||||
environment({ mediaCapabilities: { decodingInfo } }),
|
||||
);
|
||||
expect(decodingInfo).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({
|
||||
state: "complete",
|
||||
supported: true,
|
||||
smooth: false,
|
||||
powerEfficient: true,
|
||||
});
|
||||
const exported = exportMediaCapabilityResult(result);
|
||||
expect(exported).toContain("Full-HD");
|
||||
expect(exported).toContain("2-to-10-Mbit/s");
|
||||
expect(exported).not.toContain("8765432");
|
||||
expect(exported).not.toContain("1920");
|
||||
});
|
||||
|
||||
it("handles unsupported browsers and validates dangerous input", async () => {
|
||||
const unsupported = await probeMediaCapabilities(
|
||||
{
|
||||
kind: "audio",
|
||||
contentType: 'audio/webm; codecs="opus"',
|
||||
channels: "2",
|
||||
bitrate: 192_000,
|
||||
samplerate: 48_000,
|
||||
},
|
||||
environment(),
|
||||
);
|
||||
expect(unsupported.state).toBe("unsupported");
|
||||
await expect(
|
||||
probeMediaCapabilities(
|
||||
{
|
||||
kind: "video",
|
||||
contentType: "audio/mp4\ninvalid",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bitrate: 8_000_000,
|
||||
framerate: 30,
|
||||
},
|
||||
environment(),
|
||||
),
|
||||
).rejects.toThrow(/video MIME type/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user