mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.
The three dead controls:
- The volume slider works on Android only. The dashboard sends set_volume as
{ level: 0..1 }; the web player reads payload.value and Tizen reads
payload.value ?? payload.volume, so on both the number is undefined and the
handler quietly declines. Three complete, working volume implementations
that cannot be driven. The fix is one line in each player and belongs to
those files; audio.volume is out of the web and brightsign baselines until
it lands, held there by a biconditional test that fails the moment a player
starts reading `level`.
- Every #161 Tier-2 command was refused for the entire fleet. lock_now,
power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
system.device_owner, which no player declares and no baseline grants, so
supports() was false everywhere -- including on the device-owner panels the
feature was built for. The dashboard still drew the buttons because it also
gates on device.tier === 2. Fixed here: those five now accept
system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
under `if (isOwner)` and nothing else, and which no non-Android player
declares. Android should declare system.device_owner and retire the
stand-in.
- enable_system_capture required the capability it creates. It raises the
MediaProjection consent dialog -- the way a panel GAINS capture -- and was
gated on remote.screenshot, so the only panel that needs it was the one
panel that could not be sent it. Now ungated. The dashboard still hides the
button behind the same check; that half is a frontend change.
The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:
- android loses display.power (v1.9.28 answers screen_on with a logged no-op,
so the ON half is dead on every fielded panel and one capability renders
both buttons) and system.reboot (owner-only; off-owner it paints an
accessibility power dialog over the signage). Scheduled reboots now skip
undeclared Android panels rather than logging a reboot that never happened,
which is the reason that gate exists.
- tizen gains display.power: v1.9.28 implements both halves with no signing
and no panel API, so withholding it hid a working control.
- brightsign loses audio.volume, display.power, system.reboot,
system.restart_player and offline.cache. All need a host bridge the unit is
not known to have, and restart_player without one is the page reload that
darkened a panel on 2026-07-28.
Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.
The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
389 lines
21 KiB
JavaScript
389 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* What a player can actually do.
|
|
*
|
|
* The dashboard offered every control to every display. A browser tab cannot reboot its host, a
|
|
* Tizen TV has no device-owner concept, a BrightSign has no per-window brightness — so those
|
|
* buttons did nothing, silently, and looked like bugs. "UI that reports success and changes
|
|
* nothing" is a recurring shape in this codebase and this module exists to end it.
|
|
*
|
|
* The player DECLARES its capabilities at registration, because only the player knows at runtime:
|
|
* an Android device gains real screenshots when accessibility is switched on, and loses Tier-2
|
|
* commands when it is not device owner. A static per-platform table could never know that.
|
|
*
|
|
* ⚠️ Legacy displays declare nothing. A fleet of several hundred is not going to update before the
|
|
* next dashboard deploy, so an absent declaration falls back to a per-platform baseline rather
|
|
* than to "supports nothing" — which would strip the UI for every existing display at once. The
|
|
* baseline is deliberately optimistic for things that always worked, and pessimistic for anything
|
|
* that depends on runtime state.
|
|
*/
|
|
|
|
/*
|
|
* The vocabulary. Stable strings, because they are persisted per device and sent over the wire —
|
|
* renaming one silently disables a control on every display that still reports the old name.
|
|
* Grouped by what the operator is trying to do, not by how it is implemented.
|
|
*/
|
|
const CAPABILITIES = [
|
|
// playback surface
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
// audio
|
|
'audio.mute', 'audio.volume',
|
|
// display
|
|
'display.rotation', 'display.power', 'display.resolution', 'display.brightness',
|
|
// remote view / control
|
|
'remote.screenshot', 'remote.stream', 'remote.input',
|
|
// lifecycle
|
|
'system.reboot', 'system.restart_player', 'system.self_update',
|
|
// device management (Android device-owner territory)
|
|
'system.kiosk', 'system.brightness', 'system.screen_timeout',
|
|
'system.install_apk', 'system.shell', 'system.time',
|
|
// The rest of the Tier-2 surface: lock the screen now, show the power menu, hide the status
|
|
// bar, block uninstall. Separate from 'system.kiosk' because kiosk means lock-task specifically
|
|
// and a panel can hold one without the other — and separate from the individual names above
|
|
// because these four are only ever available together, gated by the same device-owner check.
|
|
// Runtime state, not a platform fact: a panel that loses device owner loses all of them.
|
|
'system.device_owner',
|
|
// synchronisation
|
|
'sync.clock', 'sync.native',
|
|
// resilience
|
|
'offline.cache',
|
|
];
|
|
|
|
const CAP_SET = new Set(CAPABILITIES);
|
|
|
|
/*
|
|
* Baselines for displays that declare nothing.
|
|
*
|
|
* THE RULE, and it is the only one that keeps this table honest: a baseline entry describes what
|
|
* the LAST RELEASED player for that platform does, unconditionally, with no privilege it might not
|
|
* have been granted. Not what HEAD does — HEAD declares for itself. Not what the platform could do
|
|
* — a capability nobody shipped is a button nobody can press.
|
|
*
|
|
* Every entry below was checked against `git show v1.9.28:<player source>`, the last release before
|
|
* capability declaration existed at all, because v1.9.29 is the first build in which any player
|
|
* declares anything. Every display that falls back to a baseline is therefore running v1.9.28 or
|
|
* older by construction, and that is the build the justifications cite.
|
|
*/
|
|
const BASELINE = {
|
|
android: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
// set_volume and set_brightness are #160 Track-A, released in v1.9.10 — long before anything
|
|
// still in the field. Both are Tier 0: MainActivity applies them with no owner, no admin and
|
|
// no WRITE_SETTINGS, so they are unconditional on any build a fielded panel could be running.
|
|
'audio.mute', 'audio.volume',
|
|
'display.rotation', 'display.brightness',
|
|
// Capture without accessibility falls back to ScreenshotCapture.captureView, which is a real
|
|
// frame of the player's own view — i.e. of the content. Narrower than the full-screen path,
|
|
// but the operator gets a picture, not a dead button.
|
|
'remote.screenshot', 'remote.stream',
|
|
'remote.input',
|
|
'system.restart_player', 'system.self_update',
|
|
'sync.clock', 'offline.cache',
|
|
// NOT display.power. v1.9.28 MainActivity answers screen_on with
|
|
// Log.w("screen_on: no privileged wake path on a non-rooted panel — no-op")
|
|
// so the ON half is dead on 100% of fielded Android panels, and screen_off only works with
|
|
// owner / device-admin / accessibility. The dashboard renders BOTH buttons off this one
|
|
// capability. A panel you can sleep and cannot wake is the worst possible version of this
|
|
// feature, which is exactly why PlayerCapabilities.kt gates its own claim on both halves.
|
|
//
|
|
// NOT system.reboot. STPolicy.reboot() requires device owner; off-owner v1.9.28 falls back to
|
|
// the accessibility power DIALOG, which needs a human standing at the screen — and on the
|
|
// accessibility-enabled panels that are common in this fleet it paints that dialog OVER the
|
|
// signage. Device-owner provisioning is not released (#161/PR #168 is still open), so the set
|
|
// of panels that are both device owner AND pre-1.9.29 is effectively empty.
|
|
// ⚠️ Consequence, deliberately accepted: services/scheduler.js gates the nightly scheduled
|
|
// reboot on this capability, so scheduled reboots now no-op for undeclared Android panels
|
|
// instead of logging "scheduled reboot fired" for a panel that never rebooted. That log line
|
|
// is the reason the gate is there; the honest answer is to skip, not to claim.
|
|
//
|
|
// NOT system.shell / system.kiosk / system.time / system.install_apk / system.brightness /
|
|
// system.screen_timeout: every one is device-owner or WRITE_SETTINGS conditional.
|
|
],
|
|
tizen: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
// audio.mute only. `git show v1.9.28:tizen/js/app.js` has NO set_volume handler — the command
|
|
// falls through STDeviceControl.run to "unknown command", so the dashboard slider does nothing
|
|
// on every fielded panel. (HEAD ships applyVolume, but see the note on BASELINE.web: it reads
|
|
// payload.value while the dashboard sends payload.level, so even HEAD's slider is dead. The
|
|
// baseline stays out until a released .wgt honours the payload the product actually sends.)
|
|
'audio.mute',
|
|
'display.rotation',
|
|
// Both really are implemented in the shipped player (captureAndSend / startStreaming), so
|
|
// omitting them would have hidden working controls on every legacy Tizen panel.
|
|
'remote.screenshot', 'remote.stream',
|
|
'remote.input',
|
|
// ADDED after audit. v1.9.28 app.js implements BOTH halves with no partner signing and no
|
|
// panel API: screen_off -> showScreenOff() paints the blanking overlay, screen_on ->
|
|
// clearScreenOff() + keepAwake(). Unlike Android above, neither half is privilege-gated, so
|
|
// the pair is honest. The panel backlight stays lit — the log line says which mechanism ran —
|
|
// but the screen genuinely goes dark, and HEAD's capabilities.js declares it for that reason.
|
|
'display.power',
|
|
'system.restart_player',
|
|
'sync.clock',
|
|
// NOT offline.cache: v1.9.28 has no tizen/js/media-cache.js at all (the file is new at HEAD).
|
|
// The fielded player caches only the playlist JSON (st_payload_cache in localStorage), so an
|
|
// outage leaves the panel knowing exactly what it cannot show. My first baseline claimed it —
|
|
// caught by the platform audit, and exactly the kind of optimistic claim this model exists to
|
|
// stop.
|
|
],
|
|
/*
|
|
* A BrightSign that declares nothing is a BrightSign we cannot prove has a host bridge, and that
|
|
* is the whole story of this baseline.
|
|
*
|
|
* The JS half of the bridge is served BY US (server.js routes /player/st-bridge.js at
|
|
* brightsign/st-bridge.js), so it is always current — but it is only half. `port` exists only
|
|
* inside an roHtmlWidget created with nodejs_enabled:true, which is the on-device BrightScript's
|
|
* decision, and `git ls-tree v1.9.28 brightsign/` shows no st-bridge.js at all: no released
|
|
* package ever shipped the two halves as a pair. The one real BrightSign we have runs BSN
|
|
* Supervisor's widget rather than our autorun.brs, and BS.hasHost() is false on it.
|
|
*
|
|
* A unit that DOES have a bridge declares for itself and never reads this list — the page
|
|
* computes hasHost() at registration. So this baseline only ever answers for a row that has not
|
|
* re-registered, and the right answer for a display we know nothing about is the floor:
|
|
* everything below is "the web player with no bridge", and nothing above that.
|
|
*/
|
|
brightsign: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
'audio.mute',
|
|
// CSS transform. Graphics rotate; with hwz the video sits on a hardware plane that ignores it,
|
|
// so this is partial — but nothing routes a COMMAND to display.rotation and no control is
|
|
// gated on it, so the entry describes content rendering rather than offering a button.
|
|
'display.rotation',
|
|
'remote.input',
|
|
'sync.clock',
|
|
// NOT offline.cache. This is the documented case, not a hypothetical: the XT245 on alpha has
|
|
// navigator.serviceWorker, passes every presence check, and then never fetches sw.js because
|
|
// its widget refuses the registration. It advertised offline caching to the fleet and could
|
|
// not cache one byte. A widget with no storage_path has no persistent storage at all, and the
|
|
// baseline cannot know which kind of widget it is talking to.
|
|
//
|
|
// NOT system.restart_player. `refresh` reaches restartPlayer(), which without a host does
|
|
// location.reload() — and a page-initiated reload does not reliably bring an roHtmlWidget
|
|
// back. That is what darkened a customer's panel on 2026-07-28. st-bridge.js withholds this
|
|
// for the same reason; a baseline that hands it to every undeclared unit undoes that.
|
|
//
|
|
// NOT system.reboot / display.power / display.resolution / system.self_update: all four are
|
|
// BrightScript calls through a bridge this unit is not known to have.
|
|
//
|
|
// NOT audio.volume / remote.screenshot / remote.stream: see BASELINE.web — the volume payload
|
|
// never lands, and a canvas capture on a hwz player cannot read the video plane, so it returns
|
|
// a frame with a hole where the content is.
|
|
],
|
|
// A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or
|
|
// capture anything outside its own document.
|
|
web: [
|
|
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
|
'playback.zones', 'playback.transitions', 'playback.pip',
|
|
'audio.mute',
|
|
'display.rotation',
|
|
'remote.screenshot', 'remote.stream', 'remote.input',
|
|
'system.restart_player',
|
|
'sync.clock', 'offline.cache',
|
|
// NOT audio.volume, removed after audit, and for two independent reasons:
|
|
// 1. `git show v1.9.28:server/player/index.html` has no set_volume handler at all — zero
|
|
// occurrences of the string. The fielded browser player ignores the command outright.
|
|
// 2. Even at HEAD the slider cannot work: index.html reads `data.payload?.value ?? data.value`
|
|
// and tizen/js/app.js reads `payload.value ?? payload.volume`, while the dashboard sends
|
|
// `{ level: 0..1 }` (frontend/js/views/device-detail.js bindLevel). Only the Android
|
|
// handler reads `level`. Fixing that is one line in each player, and
|
|
// test/player-parity-baselines.test.js is written as a BICONDITIONAL: the moment a player
|
|
// accepts `level`, the test fails and tells you to put the baseline entry back.
|
|
],
|
|
};
|
|
|
|
/*
|
|
* Which baseline a device falls back to. Keyed off the same `platform` field the sync resolver
|
|
* uses, so a device is classified one way across the whole product.
|
|
*/
|
|
function platformFamily(device) {
|
|
const platform = String((device && device.platform) || '').toLowerCase();
|
|
const android = String((device && device.android_version) || '');
|
|
if (platform.includes('brightsign')) return 'brightsign';
|
|
if (platform.includes('tizen')) return 'tizen';
|
|
// client_type 'apk' is the Android player; android_version that is NOT the web player's
|
|
// "Web/..." shape is the older signal for the same thing.
|
|
if ((device && device.client_type === 'apk') || (android && !android.startsWith('Web/'))) return 'android';
|
|
return 'web';
|
|
}
|
|
|
|
/**
|
|
* The capability set for a device, as an array of known capability strings.
|
|
*
|
|
* @param {object} device a device row; may carry `capabilities` (JSON array or string)
|
|
* @returns {string[]}
|
|
*/
|
|
function capabilitiesFor(device) {
|
|
const declared = parseDeclared(device && device.capabilities);
|
|
if (declared) return declared;
|
|
return (BASELINE[platformFamily(device)] || BASELINE.web).slice();
|
|
}
|
|
|
|
/**
|
|
* True when the device supports `cap`. Unknown capability names are always false.
|
|
*
|
|
* A missing device supports nothing. It would otherwise fall through to the web baseline and
|
|
* claim video playback for a row that does not exist — a caller rendering controls from a failed
|
|
* lookup should get an empty panel, not a plausible-looking one.
|
|
*/
|
|
function supports(device, cap) {
|
|
if (!device) return false;
|
|
if (!CAP_SET.has(cap)) return false;
|
|
return capabilitiesFor(device).includes(cap);
|
|
}
|
|
|
|
/*
|
|
* Parse whatever the device sent. Returns null when there is no usable declaration, which is the
|
|
* signal to fall back to the baseline — distinct from an EMPTY declaration, which is a player
|
|
* genuinely saying "I can do nothing" and must be honoured.
|
|
*/
|
|
function parseDeclared(raw) {
|
|
if (raw === null || raw === undefined) return null;
|
|
let list = raw;
|
|
if (typeof raw === 'string') {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
try { list = JSON.parse(trimmed); } catch (e) { return null; }
|
|
}
|
|
if (!Array.isArray(list)) return null;
|
|
// Unknown strings are dropped rather than rejected wholesale: a newer player declaring a
|
|
// capability this server has never heard of must not lose the ones it does understand.
|
|
return list.filter((c) => CAP_SET.has(c));
|
|
}
|
|
|
|
/*
|
|
* Which capability a fleet command needs.
|
|
*
|
|
* The dashboard and the socket layer both dispatch commands by string name, so the check has to
|
|
* happen against that name or it does not happen at all. Kept here rather than in the socket
|
|
* handler because two call sites dispatch commands — dashboardSocket for a single device and the
|
|
* group route for many — and a map that lives in one of them protects only that one.
|
|
*
|
|
* A command mapped to null needs no capability: it is a diagnostic every player understands, and
|
|
* refusing it would remove the tool you use to work out why a panel is misbehaving.
|
|
*
|
|
* A command may map to a LIST, meaning any one of them is enough. That is not a convenience: it is
|
|
* how a capability name that no shipped player declares stays in the vocabulary without taking its
|
|
* commands down with it. The first name in the list is the canonical one and is what a refusal
|
|
* reports, so the operator is told what the panel is missing in the vocabulary they see elsewhere.
|
|
*/
|
|
const COMMAND_CAPABILITY = {
|
|
// lifecycle
|
|
reboot: 'system.reboot',
|
|
// Power-off shares the reboot capability: it is the same "device power lifecycle" privilege, and
|
|
// no platform we ship implements one without the other. Split it if that ever stops being true.
|
|
shutdown: 'system.reboot',
|
|
launch: 'system.restart_player',
|
|
refresh: 'system.restart_player',
|
|
update: 'system.self_update',
|
|
|
|
// display
|
|
screen_on: 'display.power',
|
|
screen_off: 'display.power',
|
|
|
|
// audio
|
|
set_volume: 'audio.volume',
|
|
|
|
// system control (#160 Track-A)
|
|
set_brightness: 'display.brightness', // per-window overlay dim (Tier 0)
|
|
set_system_brightness: 'system.brightness',
|
|
set_screen_timeout: 'system.screen_timeout',
|
|
|
|
// device-owner surface (#161 Tier-2)
|
|
kiosk_lock: 'system.kiosk',
|
|
kiosk_unlock: 'system.kiosk',
|
|
/*
|
|
* ⚠️ These five were UNREACHABLE for the entire fleet until this audit, and nothing failed
|
|
* loudly enough to notice.
|
|
*
|
|
* 'system.device_owner' is declared by NO player. It is not in PlayerCapabilities.kt, not in
|
|
* tizen/js/capabilities.js, not in the web player's declaredCapabilities(), not in st-bridge.js,
|
|
* and not in any baseline. So `supports()` returned false for every device on every platform,
|
|
* and every one of these commands was refused — including on the device-owner panels the whole
|
|
* #161 Tier-2 surface was built for. The dashboard still rendered the buttons, because
|
|
* device-detail.js gates that block on `device.tier === 2 ||` as well, so an operator on a real
|
|
* owner panel pressed "Lock now" and got a silent server-side refusal.
|
|
*
|
|
* Until a player declares 'system.device_owner' for itself, 'system.kiosk' stands in, and it is
|
|
* an exact stand-in rather than a loose one: PlayerCapabilities.kt declares system.kiosk under
|
|
* `if (isOwner)` and nothing else, which is precisely the condition under which STPolicy's
|
|
* owned() actions — setStatusBarDisabled, setUninstallBlocked, lockNow, reboot — do anything.
|
|
* No non-Android player declares system.kiosk; Tizen and BrightSign both refuse it explicitly
|
|
* and in writing, so this cannot leak the commands onto a platform that would swallow them.
|
|
*
|
|
* The canonical name stays first so a refusal still says 'system.device_owner'.
|
|
*/
|
|
lock_now: ['system.device_owner', 'system.kiosk'],
|
|
power_menu: ['system.device_owner', 'system.kiosk'],
|
|
status_bar: ['system.device_owner', 'system.kiosk'],
|
|
block_uninstall: ['system.device_owner', 'system.kiosk'],
|
|
unblock_uninstall: ['system.device_owner', 'system.kiosk'],
|
|
set_time: 'system.time',
|
|
set_timezone: 'system.time',
|
|
shell: 'system.shell',
|
|
install_apk: 'system.install_apk',
|
|
|
|
/*
|
|
* Remote view. Ungated, and the reason is a circle: enable_system_capture asks Android to raise
|
|
* the MediaProjection consent dialog, which is how a panel GAINS full-screen capture. Gating it
|
|
* on 'remote.screenshot' meant the only panel that needs it — one with neither accessibility nor
|
|
* a projection grant, which therefore declares no remote.screenshot — was the one panel that
|
|
* could not be sent it. A bootstrap cannot require the thing it bootstraps.
|
|
*
|
|
* ⚠️ The dashboard still has the other half of this bug: device-detail.js renders the "enable
|
|
* system view" button behind `can('remote.screenshot')`. Fixing that is a frontend change and is
|
|
* written up in docs/player-parity.md; ungating the command is the half that lives here.
|
|
*/
|
|
enable_system_capture: null,
|
|
|
|
// Diagnostics: deliberately unrestricted. set_debug turns on the log stream you need precisely
|
|
// when a panel is behaving in a way its capability declaration did not predict.
|
|
set_debug: null,
|
|
};
|
|
|
|
/**
|
|
* Every capability that would satisfy `type`, as an array. Empty means the command is ungated.
|
|
* Unknown commands are ungated too — this map gates, it does not authorise: the allow-list of
|
|
* valid command names lives with the routes, and duplicating it here would mean a new command
|
|
* silently stops working until someone remembers to add it in two places.
|
|
*
|
|
* @param {string} type
|
|
* @returns {string[]}
|
|
*/
|
|
function capabilitiesForCommand(type) {
|
|
if (!Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type)) return [];
|
|
const value = COMMAND_CAPABILITY[type];
|
|
if (value === null || value === undefined) return [];
|
|
return Array.isArray(value) ? value.slice() : [value];
|
|
}
|
|
|
|
/**
|
|
* The CANONICAL capability a command requires, or null when it needs none.
|
|
* Kept returning a single string because that is what a refusal reports and what the dashboard
|
|
* puts in front of an operator: "needs system.device_owner" is an answer, an array is a puzzle.
|
|
*/
|
|
function capabilityForCommand(type) {
|
|
const list = capabilitiesForCommand(type);
|
|
return list.length ? list[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Can this device be sent this command?
|
|
* @returns {{ok: true} | {ok: false, capability: string}}
|
|
*/
|
|
function commandAllowed(device, type) {
|
|
const needed = capabilitiesForCommand(type);
|
|
if (!needed.length) return { ok: true };
|
|
if (needed.some((cap) => supports(device, cap))) return { ok: true };
|
|
return { ok: false, capability: needed[0] };
|
|
}
|
|
|
|
module.exports = {
|
|
CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared,
|
|
COMMAND_CAPABILITY, capabilityForCommand, capabilitiesForCommand, commandAllowed,
|
|
};
|