@@ -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
|
# 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/`.
|
||||||
|
|||||||
Generated
+20
-20
@@ -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
@@ -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
@@ -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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user