1 Commits
Author SHA1 Message Date
zemion 0ae5c7b7af Release Device Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 13:08:36 +02:00
21 changed files with 1490 additions and 50 deletions
+39
View File
@@ -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
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added passive display, input, codec, media-constraint, PWA and browser API inventory. - Added passive display, input, codec, media-constraint, PWA and browser API inventory.
+5 -2
View File
@@ -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. - 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. - 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. - 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. - 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. - 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 ## 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. 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 ## 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 ## Licence
+2 -2
View File
@@ -1,10 +1,10 @@
# Source identity # Source identity
- Project: Device Tools - Project: Device Tools
- Version: 0.1.0 - Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/device-tools - Repository: https://git.add-ideas.de/lotobo/device-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact` - 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/`. The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
+20 -20
View File
@@ -1,22 +1,22 @@
{ {
"name": "device-tools", "name": "device-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "device-tools", "name": "device-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", "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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"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", "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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,13 +67,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "device-tools", "name": "device-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect browser capabilities and privacy-safe device probes locally.", "description": "Inspect browser capabilities and privacy-safe device probes locally.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added passive display, input, codec, media-constraint, PWA and browser API inventory. - Added passive display, input, codec, media-constraint, PWA and browser API inventory.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+5 -2
View File
@@ -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. - 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. - 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. - 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. - 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. - 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 ## 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. 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 ## 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 ## Licence
+2 -2
View File
@@ -1,10 +1,10 @@
# Source identity # Source identity
- Project: Device Tools - Project: Device Tools
- Version: 0.1.0 - Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/device-tools - Repository: https://git.add-ideas.de/lotobo/device-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact` - 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/`. The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "device-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+15 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.device-tools", "id": "de.add-ideas.device-tools",
"name": "Device Tools", "name": "Device Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect capabilities without fingerprinting.", "description": "Inspect capabilities without fingerprinting.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -28,6 +28,20 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": false,
+7
View File
@@ -41,6 +41,13 @@ export function HelpDialog({
microphone, screen and persistence checks can prompt; returned media microphone, screen and persistence checks can prompt; returned media
tracks are stopped immediately and no content is recorded. tracks are stopped immediately and no content is recorded.
</p> </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> <p>
Reports contain no timestamp, user agent, locale, identifier, hash or Reports contain no timestamp, user agent, locale, identifier, hash or
uniqueness score. Exact high-entropy measurements are bucketed before uniqueness score. Exact high-entropy measurements are bucketed before
+329
View File
@@ -3,15 +3,38 @@ import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { import {
collectPassiveCapabilities, collectPassiveCapabilities,
createRedactedReport, createRedactedReport,
evaluateRequirementProfile,
exportMediaCapabilityResult,
exportReportCsv, exportReportCsv,
exportReportJson, exportReportJson,
parseRequirementProfile,
PROBES, PROBES,
probeMediaCapabilities,
runProbe, runProbe,
type Capability, type Capability,
type MediaCapabilityResult,
type MediaProbeConfig,
type ProbeId, type ProbeId,
type ProbeResult, type ProbeResult,
type RequirementProfile,
} from "../core/device"; } 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() { export function Workbench() {
const [capabilities, setCapabilities] = useState<Capability[]>(() => const [capabilities, setCapabilities] = useState<Capability[]>(() =>
collectPassiveCapabilities(), collectPassiveCapabilities(),
@@ -21,6 +44,23 @@ export function Workbench() {
); );
const [running, setRunning] = useState<ProbeId | null>(null); const [running, setRunning] = useState<ProbeId | null>(null);
const [filter, setFilter] = useState(""); 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( const [status, setStatus] = useState(
"Passive API presence was checked locally. No permission was requested.", "Passive API presence was checked locally. No permission was requested.",
); );
@@ -50,6 +90,10 @@ export function Workbench() {
const available = capabilities.filter( const available = capabilities.filter(
(item) => item.state === "available", (item) => item.state === "available",
).length; ).length;
const requirementEvaluation = useMemo(
() => (profile ? evaluateRequirementProfile(profile, capabilities) : null),
[profile, capabilities],
);
async function performProbe(id: ProbeId): Promise<void> { async function performProbe(id: ProbeId): Promise<void> {
setRunning(id); setRunning(id);
@@ -60,6 +104,53 @@ export function Workbench() {
setStatus(result.summary); 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 ( return (
<main className="workbench"> <main className="workbench">
<section className="hero"> <section className="hero">
@@ -153,6 +244,12 @@ export function Workbench() {
<td>{item.value}</td> <td>{item.value}</td>
<td> <td>
<Risk value={item.privacy} /> <Risk value={item.privacy} />
{item.state !== "available" && item.availability && (
<small>
{item.availability}
{item.remediation ? ` · ${item.remediation}` : ""}
</small>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -167,6 +264,185 @@ export function Workbench() {
</div> </div>
</section> </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"> <section className="panel" aria-labelledby="probes-title">
<div className="panel-heading"> <div className="panel-heading">
<div> <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 }) { function ProbeOutcome({ result }: { result: ProbeResult }) {
return ( return (
<div className={"probe-result " + result.state}> <div className={"probe-result " + result.state}>
<strong>{result.state}</strong> <strong>{result.state}</strong>
<p>{result.summary}</p> <p>{result.summary}</p>
{result.availability && result.availability !== "available" && (
<p>
<strong>Reason:</strong> {result.availability}
{result.remediation ? ` · ${result.remediation}` : ""}
</p>
)}
{result.details.length > 0 && ( {result.details.length > 0 && (
<dl> <dl>
{result.details.map((detail) => ( {result.details.map((detail) => (
+652 -4
View File
@@ -2,6 +2,15 @@ import { stableStringify, stringifyCsv } from "@add-ideas/toolbox-helpers";
export type CapabilityState = "available" | "unavailable" | "unknown"; export type CapabilityState = "available" | "unavailable" | "unknown";
export type PrivacyRisk = "low" | "medium" | "high"; 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 { export interface Capability {
id: string; id: string;
@@ -11,6 +20,8 @@ export interface Capability {
value: string; value: string;
privacy: PrivacyRisk; privacy: PrivacyRisk;
explanation: string; explanation: string;
availability?: AvailabilityReason;
remediation?: string;
} }
export interface ProbeDetail { export interface ProbeDetail {
@@ -25,6 +36,8 @@ export interface ProbeResult {
state: "complete" | "unsupported" | "denied" | "error"; state: "complete" | "unsupported" | "denied" | "error";
summary: string; summary: string;
details: ProbeDetail[]; details: ProbeDetail[];
availability?: AvailabilityReason;
remediation?: string;
} }
export type ProbeId = export type ProbeId =
@@ -169,7 +182,7 @@ interface GpuAdapterLike {
limits?: Record<string, number>; limits?: Record<string, number>;
} }
type ExtendedNavigator = Navigator & { type ExtendedNavigator = Omit<Navigator, "mediaCapabilities"> & {
deviceMemory?: number; deviceMemory?: number;
connection?: ConnectionLike; connection?: ConnectionLike;
mozConnection?: ConnectionLike; mozConnection?: ConnectionLike;
@@ -188,6 +201,13 @@ type ExtendedNavigator = Navigator & {
keyboard?: unknown; keyboard?: unknown;
userAgentData?: unknown; userAgentData?: unknown;
requestMIDIAccess?: unknown; requestMIDIAccess?: unknown;
mediaCapabilities?: {
decodingInfo(configuration: unknown): Promise<{
supported: boolean;
smooth: boolean;
powerEfficient: boolean;
}>;
};
}; };
export interface DeviceEnvironment { export interface DeviceEnvironment {
@@ -199,6 +219,302 @@ export interface DeviceEnvironment {
isolated: boolean; 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 { export function liveEnvironment(): DeviceEnvironment {
return { return {
window, window,
@@ -237,6 +553,7 @@ export function collectPassiveCapabilities(
value, value,
privacy, privacy,
explanation, explanation,
...availabilityFor(id, available, environment),
}); });
}; };
@@ -463,6 +780,18 @@ export function collectPassiveCapabilities(
"serviceWorker" in nav, "serviceWorker" in nav,
"Offline/PWA worker API presence", "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",
"Cache Storage", "Cache Storage",
@@ -583,6 +912,8 @@ export async function runProbe(
id: ProbeId, id: ProbeId,
environment: DeviceEnvironment = liveEnvironment(), environment: DeviceEnvironment = liveEnvironment(),
): Promise<ProbeResult> { ): Promise<ProbeResult> {
const preflight = probePreflight(id, environment);
if (preflight) return preflight;
try { try {
switch (id) { switch (id) {
case "permissions": case "permissions":
@@ -615,6 +946,13 @@ export async function runProbe(
"SecurityError", "SecurityError",
"PermissionDeniedError", "PermissionDeniedError",
].includes(name); ].includes(name);
const availability: AvailabilityReason = denied
? "user-permission"
: ["NotFoundError", "NotReadableError", "OverconstrainedError"].includes(
name,
)
? "device-or-os"
: "unknown";
return { return {
id, id,
state: denied ? "denied" : "error", state: denied ? "denied" : "error",
@@ -622,10 +960,139 @@ export async function runProbe(
? "The browser or policy denied this probe." ? "The browser or policy denied this probe."
: safeError(caught), : safeError(caught),
details: [{ label: "Outcome", value: name }], 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> { async function probePermissions(nav: ExtendedNavigator): Promise<ProbeResult> {
if (!nav.permissions?.query) if (!nav.permissions?.query)
return unsupported("permissions", "Permissions API is unavailable."); 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 { 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( function codecChecks(
@@ -1091,21 +1566,190 @@ function timeClass(value: number | undefined): string {
: "4-hours-or-more"; : "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 { export interface RedactedDeviceReport {
schemaVersion: 1; 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[]; policy: string[];
capabilities: Array<{ capabilities: Array<{
id: string; id: string;
group: string; group: string;
state: CapabilityState; state: CapabilityState;
value: string; value: string;
availability?: AvailabilityReason;
remediation?: string;
}>; }>;
probes: Array<{ probes: Array<{
id: ProbeId; id: ProbeId;
state: ProbeResult["state"]; state: ProbeResult["state"];
summary: string; summary: string;
details: Array<{ label: string; value: string }>; details: Array<{ label: string; value: string }>;
availability?: AvailabilityReason;
remediation?: string;
}>; }>;
} }
@@ -1115,7 +1759,7 @@ export function createRedactedReport(
): RedactedDeviceReport { ): RedactedDeviceReport {
return { return {
schemaVersion: 1, 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: [ policy: [
"No stable identifier", "No stable identifier",
"No fingerprint score", "No fingerprint score",
@@ -1127,6 +1771,8 @@ export function createRedactedReport(
group: item.group, group: item.group,
state: item.state, state: item.state,
value: redactCapability(item), value: redactCapability(item),
...(item.availability ? { availability: item.availability } : {}),
...(item.remediation ? { remediation: item.remediation } : {}),
})), })),
probes: results.map((result) => ({ probes: results.map((result) => ({
id: result.id, id: result.id,
@@ -1139,6 +1785,8 @@ export function createRedactedReport(
label: detail.label, label: detail.label,
value: detail.redacted ?? detail.value, value: detail.redacted ?? detail.value,
})), })),
...(result.availability ? { availability: result.availability } : {}),
...(result.remediation ? { remediation: result.remediation } : {}),
})), })),
}; };
} }
+164 -4
View File
@@ -75,7 +75,7 @@ body {
.workbench :where(p, ul, dl) { .workbench :where(p, ul, dl) {
margin-block: 0; margin-block: 0;
} }
.workbench :where(button, input), .workbench :where(button, input, select, textarea),
.help-dialog button { .help-dialog button {
font: inherit; font: inherit;
} }
@@ -114,13 +114,14 @@ body {
border-color: var(--toolbox-accent-hover); border-color: var(--toolbox-accent-hover);
background: 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, .capability-table:focus-visible,
.report-panel pre:focus-visible { .report-panel pre:focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent); outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px; outline-offset: 2px;
} }
.workbench input { .workbench :where(input, select, textarea) {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 2.55rem; min-height: 2.55rem;
@@ -130,6 +131,12 @@ body {
background: var(--toolbox-surface); background: var(--toolbox-surface);
color: var(--toolbox-text); 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 { .workbench label {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -223,6 +230,146 @@ body {
gap: 0.65rem; gap: 0.65rem;
margin-top: 0.85rem; 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 { .capability-groups {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
@@ -452,13 +599,19 @@ body {
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.principles, .principles,
.inventory-toolbar { .inventory-toolbar,
.compatibility-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.inventory-toolbar button { .inventory-toolbar button {
width: 100%; width: 100%;
} }
} }
@media (max-width: 54rem) {
.media-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 38rem) { @media (max-width: 38rem) {
.hero, .hero,
.panel-heading { .panel-heading {
@@ -471,6 +624,13 @@ body {
.probe-grid { .probe-grid {
grid-template-columns: 1fr; 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) { @media (prefers-reduced-motion: reduce) {
*, *,
+23 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.device-tools", "id": "de.add-ideas.device-tools",
"name": "Device Tools", "name": "Device Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect capabilities without fingerprinting.", "description": "Inspect capabilities without fingerprinting.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -28,6 +28,28 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": false,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+18
View File
@@ -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);
});
+165
View File
@@ -2,8 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import { import {
collectPassiveCapabilities, collectPassiveCapabilities,
createRedactedReport, createRedactedReport,
evaluateRequirementProfile,
exportMediaCapabilityResult,
exportReportCsv, exportReportCsv,
exportReportJson, exportReportJson,
parseRequirementProfile,
probeMediaCapabilities,
runProbe, runProbe,
type Capability, type Capability,
type DeviceEnvironment, type DeviceEnvironment,
@@ -21,6 +25,7 @@ function environment(
visualViewport: { width: 1200, height: 700 }, visualViewport: { width: 1200, height: 700 },
matchMedia: (query: string) => ({ matches: query.includes("fine") }), matchMedia: (query: string) => ({ matches: query.includes("fine") }),
WebGLRenderingContext: function WebGLRenderingContext() {}, WebGLRenderingContext: function WebGLRenderingContext() {},
Worker: function Worker() {},
indexedDB: {}, indexedDB: {},
caches: {}, caches: {},
} as unknown as Window; } as unknown as Window;
@@ -81,6 +86,61 @@ describe("passive device inventory", () => {
expect(capabilities.find((item) => item.id === "camera-api")?.state).toBe( expect(capabilities.find((item) => item.id === "camera-api")?.state).toBe(
"unavailable", "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"); 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/);
});
});