ps-access 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +210 -0
- package/README.md +203 -0
- package/bridge.mjs +231 -0
- package/cli.mjs +339 -0
- package/lib/bridge-sinks.mjs +81 -0
- package/lib/hid-node.mjs +67 -0
- package/lib/uinput-helper.py +143 -0
- package/package.json +53 -0
- package/web/access-protocol.mjs +310 -0
- package/web/bridge-core.mjs +132 -0
- package/web/bridge-map.mjs +102 -0
- package/web/controller-render.mjs +79 -0
- package/web/hid-capture.html +142 -0
- package/web/hid-web.mjs +65 -0
- package/web/icon.svg +14 -0
- package/web/index.html +346 -0
- package/web/manifest.webmanifest +14 -0
- package/web/monitor.html +121 -0
- package/web/monitor.js +117 -0
- package/web/profile-library.mjs +181 -0
- package/web/serve.json +10 -0
- package/web/sw.js +39 -0
- package/web/xmb.js +1069 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Access Controller — input report RE</title>
|
|
6
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Crect width=%2732%27 height=%2732%27 rx=%277%27 fill=%27%23121a26%27/%3E%3Ccircle cx=%2716%27 cy=%2716%27 r=%277%27 fill=%27%236fa8ff%27/%3E%3C/svg%3E" />
|
|
7
|
+
<style>
|
|
8
|
+
body { font: 13px ui-monospace, Menlo, monospace; background: #0f1216; color: #e6edf3; margin: 0; padding: 18px; }
|
|
9
|
+
h1 { font-size: 15px; margin: 0 0 6px; }
|
|
10
|
+
.sub { color: #93a1b0; margin-bottom: 14px; }
|
|
11
|
+
button { font: inherit; background: #2f6bd6; color: #fff; border: 0; padding: 8px 14px; border-radius: 8px; cursor: pointer; margin-right: 8px; }
|
|
12
|
+
button.alt { background: #1f2630; border: 1px solid #2b333f; }
|
|
13
|
+
#status { margin: 10px 0; color: #45d29a; }
|
|
14
|
+
/* live byte/bit grid */
|
|
15
|
+
#grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px 14px; margin: 14px 0; max-width: 1100px; }
|
|
16
|
+
.byte { border: 1px solid #2b333f; border-radius: 6px; padding: 4px 6px; }
|
|
17
|
+
.byte.volatile { opacity: .35; }
|
|
18
|
+
.byte .idx { color: #93a1b0; font-size: 11px; }
|
|
19
|
+
.byte .bits { display: flex; gap: 2px; margin-top: 2px; }
|
|
20
|
+
.bit { width: 14px; height: 16px; display: grid; place-items: center; border-radius: 3px; background: #1a2029; color: #5c6b7a; font-size: 10px; }
|
|
21
|
+
.bit.hi { background: #2f6bd6; color: #fff; }
|
|
22
|
+
.bit.changed { outline: 2px solid #ffcd5e; }
|
|
23
|
+
#log { white-space: pre; background: #0a0d12; border: 1px solid #2b333f; border-radius: 8px; padding: 12px; max-height: 320px; overflow: auto; }
|
|
24
|
+
.row { margin: 2px 0; }
|
|
25
|
+
.hl { color: #ffcd5e; }
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<h1>Access Controller — input report reverse-engineering</h1>
|
|
30
|
+
<div class="sub">Connect, stay still for the idle baseline, then press <b>one physical button at a time</b>. Each distinct press is logged with the bits that flipped.</div>
|
|
31
|
+
<button id="connect">Connect controller</button>
|
|
32
|
+
<button id="rebaseline" class="alt">Re-take idle baseline</button>
|
|
33
|
+
<button id="clear" class="alt">Clear log</button>
|
|
34
|
+
<div id="status">Not connected.</div>
|
|
35
|
+
|
|
36
|
+
<div id="grid"></div>
|
|
37
|
+
|
|
38
|
+
<h1>Press log</h1>
|
|
39
|
+
<div id="log">(press buttons after baseline is taken)</div>
|
|
40
|
+
|
|
41
|
+
<script type="module">
|
|
42
|
+
const VID = 0x054c, PID = 0x0e5f;
|
|
43
|
+
const $ = (s) => document.querySelector(s);
|
|
44
|
+
let device = null;
|
|
45
|
+
let baseline = null; // stable baseline byte array (data, no report id)
|
|
46
|
+
let volatile = new Set(); // byte indices that vary while idle
|
|
47
|
+
let idle = []; // idle samples
|
|
48
|
+
let phase = "init"; // init -> baselining -> watch
|
|
49
|
+
let lastKey = "";
|
|
50
|
+
const log = [];
|
|
51
|
+
|
|
52
|
+
async function connect() {
|
|
53
|
+
try {
|
|
54
|
+
let devs = (await navigator.hid.getDevices()).filter(d => d.vendorId === VID && d.productId === PID);
|
|
55
|
+
if (!devs.length) devs = await navigator.hid.requestDevice({ filters: [{ vendorId: VID, productId: PID }] });
|
|
56
|
+
device = devs[0];
|
|
57
|
+
if (!device) { $("#status").textContent = "No controller selected."; return; }
|
|
58
|
+
if (!device.opened) await device.open();
|
|
59
|
+
device.oninputreport = onReport;
|
|
60
|
+
startBaseline();
|
|
61
|
+
} catch (e) { $("#status").textContent = "Error: " + (e.message || e); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startBaseline() {
|
|
65
|
+
baseline = null; volatile = new Set(); idle = []; phase = "baselining";
|
|
66
|
+
$("#status").textContent = "Taking idle baseline — keep hands OFF the controller…";
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
if (!idle.length) { $("#status").textContent = "No input reports received (is it on USB?)."; return; }
|
|
69
|
+
const len = idle[0].length;
|
|
70
|
+
baseline = idle[idle.length - 1].slice();
|
|
71
|
+
for (let i = 0; i < len; i++) {
|
|
72
|
+
const vals = new Set(idle.map(s => s[i]));
|
|
73
|
+
if (vals.size > 1) volatile.add(i); // changes while idle => counter/timestamp/axis jitter
|
|
74
|
+
}
|
|
75
|
+
phase = "watch";
|
|
76
|
+
$("#status").textContent = `Baseline set (${idle.length} samples, ${len} bytes). Volatile bytes ignored: [${[...volatile].join(", ")}]. Now press buttons one at a time.`;
|
|
77
|
+
}, 1600);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Physical-button bytes (found via RE): byte 15 = the 8 perimeter buttons, byte 16 = center + stick-click.
|
|
81
|
+
const BTN_BYTES = [15, 16];
|
|
82
|
+
function onReport(e) {
|
|
83
|
+
const b = new Uint8Array(e.data.buffer.slice(e.data.byteOffset, e.data.byteOffset + e.data.byteLength));
|
|
84
|
+
if (phase === "baselining") { idle.push([...b]); return; }
|
|
85
|
+
if (phase !== "watch") return;
|
|
86
|
+
// grid shows all non-volatile changes; the press LOG keys only on the button bytes (noise-free)
|
|
87
|
+
const changedAll = [], changedBtn = [];
|
|
88
|
+
for (let i = 0; i < b.length; i++) {
|
|
89
|
+
if (volatile.has(i)) continue;
|
|
90
|
+
const diff = (b[i] ^ baseline[i]) & 0xff;
|
|
91
|
+
if (!diff) continue;
|
|
92
|
+
for (let bit = 0; bit < 8; bit++) if (diff & (1 << bit)) {
|
|
93
|
+
changedAll.push(`${i}.${bit}`);
|
|
94
|
+
if (BTN_BYTES.includes(i)) changedBtn.push(`${i}.${bit}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
renderGrid(b, changedAll);
|
|
98
|
+
const key = changedBtn.slice().sort().join(",");
|
|
99
|
+
if (key && key !== lastKey) {
|
|
100
|
+
log.push({ key, hex: [...b].map(x => x.toString(16).padStart(2, "0")).join(" ") });
|
|
101
|
+
renderLog();
|
|
102
|
+
}
|
|
103
|
+
lastKey = key;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderGrid(b, changed) {
|
|
107
|
+
const g = $("#grid");
|
|
108
|
+
if (!g.children.length || g.children.length !== b.length) {
|
|
109
|
+
g.innerHTML = "";
|
|
110
|
+
for (let i = 0; i < b.length; i++) {
|
|
111
|
+
const d = document.createElement("div"); d.className = "byte"; d.dataset.i = i;
|
|
112
|
+
d.innerHTML = `<div class="idx">b${i}</div><div class="bits"></div>`;
|
|
113
|
+
g.append(d);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const changedSet = new Set(changed);
|
|
117
|
+
for (let i = 0; i < b.length; i++) {
|
|
118
|
+
const cell = g.children[i];
|
|
119
|
+
cell.classList.toggle("volatile", volatile.has(i));
|
|
120
|
+
const bitsEl = cell.querySelector(".bits");
|
|
121
|
+
if (bitsEl.children.length !== 8) { bitsEl.innerHTML = ""; for (let bit = 7; bit >= 0; bit--) { const s = document.createElement("div"); s.className = "bit"; s.textContent = bit; bitsEl.append(s); } }
|
|
122
|
+
for (let bit = 7; bit >= 0; bit--) {
|
|
123
|
+
const el = bitsEl.children[7 - bit];
|
|
124
|
+
el.classList.toggle("hi", !!(b[i] & (1 << bit)));
|
|
125
|
+
el.classList.toggle("changed", changedSet.has(`${i}.${bit}`));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderLog() {
|
|
131
|
+
$("#log").innerHTML = log.map((e, n) =>
|
|
132
|
+
`<div class="row"><span class="hl">press #${n + 1}</span> bits: ${e.key || "(none)"}</div>`
|
|
133
|
+
).join("") || "(none)";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
$("#connect").onclick = connect;
|
|
137
|
+
$("#rebaseline").onclick = startBaseline;
|
|
138
|
+
$("#clear").onclick = () => { log.length = 0; lastKey = ""; renderLog(); };
|
|
139
|
+
if (!("hid" in navigator)) $("#status").textContent = "WebHID not supported — use Chrome/Edge.";
|
|
140
|
+
</script>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|
package/web/hid-web.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// WebHID transport for the Access Controller, mirroring lib/hid-node.mjs.
|
|
2
|
+
import {
|
|
3
|
+
VENDOR_ID, PRODUCT_ID, REPORT_ID_CMD, REPORT_ID_DATA, BT_ONLY_REPORT_ID,
|
|
4
|
+
PROFILE_SIZE, PACKETS_PER_PROFILE, buildReadCommand, assembleProfile, buildWritePackets,
|
|
5
|
+
buildSetActiveCommand,
|
|
6
|
+
} from "./access-protocol.mjs";
|
|
7
|
+
|
|
8
|
+
export function hidSupported() {
|
|
9
|
+
return "hid" in navigator;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Already-granted Access Controllers.
|
|
13
|
+
export async function grantedControllers() {
|
|
14
|
+
const devices = await navigator.hid.getDevices();
|
|
15
|
+
return devices.filter((d) => d.vendorId === VENDOR_ID && d.productId === PRODUCT_ID);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Prompt the user to grant access to one or more controllers.
|
|
19
|
+
export async function requestControllers() {
|
|
20
|
+
const devices = await navigator.hid.requestDevice({
|
|
21
|
+
filters: [{ vendorId: VENDOR_ID, productId: PRODUCT_ID }],
|
|
22
|
+
});
|
|
23
|
+
return devices.filter((d) => d.vendorId === VENDOR_ID && d.productId === PRODUCT_ID);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureOpen(device) {
|
|
27
|
+
if (!device.opened) await device.open();
|
|
28
|
+
const ids = device.collections?.[0]?.featureReports?.map((r) => r.reportId) ?? [];
|
|
29
|
+
// The profile channel (0x60/0x61) is only exposed over USB. Report 99 marks the
|
|
30
|
+
// Bluetooth collection, where profile read/write isn't available.
|
|
31
|
+
const usbReady = ids.includes(REPORT_ID_CMD) && ids.includes(REPORT_ID_DATA) && !ids.includes(BT_ONLY_REPORT_ID);
|
|
32
|
+
if (!usbReady) throw new Error("Connect the Access controller with a USB-C cable — profiles can't be read/written over Bluetooth.");
|
|
33
|
+
return device;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dvToU8(dv) {
|
|
37
|
+
return new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function readProfileRaw(device, profileNumber) {
|
|
41
|
+
const cmd = buildReadCommand(profileNumber);
|
|
42
|
+
await device.sendFeatureReport(REPORT_ID_CMD, cmd);
|
|
43
|
+
const packets = [];
|
|
44
|
+
for (let i = 0; i < PACKETS_PER_PROFILE; i++) {
|
|
45
|
+
const dv = await device.receiveFeatureReport(REPORT_ID_DATA);
|
|
46
|
+
packets.push(dvToU8(dv));
|
|
47
|
+
}
|
|
48
|
+
return assembleProfile(packets);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Switch the controller's active profile (1..3) — like pressing its profile button.
|
|
52
|
+
export async function setActiveProfile(device, profileNumber) {
|
|
53
|
+
await device.sendFeatureReport(REPORT_ID_CMD, buildSetActiveCommand(profileNumber));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function writeProfileRaw(device, profileNumber, profileBytes) {
|
|
57
|
+
if (profileBytes.length < PROFILE_SIZE) throw new Error(`profile must be ${PROFILE_SIZE} bytes`);
|
|
58
|
+
const packets = buildWritePackets(profileNumber, profileBytes);
|
|
59
|
+
for (const pkt of packets) await device.sendFeatureReport(REPORT_ID_CMD, pkt);
|
|
60
|
+
// Drain post-write status until "remaining" byte (offset 2) is zero.
|
|
61
|
+
for (let i = 0; i < 32; i++) {
|
|
62
|
+
const dv = await device.receiveFeatureReport(REPORT_ID_DATA);
|
|
63
|
+
if (dvToU8(dv)[2] === 0) break;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/web/icon.svg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ps-access">
|
|
2
|
+
<rect width="512" height="512" rx="112" fill="#0b1320"/>
|
|
3
|
+
<!-- stylized Access Controller: ring of buttons around a central stick -->
|
|
4
|
+
<g fill="none" stroke="#6fa8ff" stroke-width="22">
|
|
5
|
+
<circle cx="256" cy="256" r="120"/>
|
|
6
|
+
</g>
|
|
7
|
+
<circle cx="256" cy="256" r="46" fill="#2f6bd6" stroke="#6fa8ff" stroke-width="10"/>
|
|
8
|
+
<g fill="#6fa8ff">
|
|
9
|
+
<circle cx="256" cy="96" r="26"/>
|
|
10
|
+
<circle cx="256" cy="416" r="26"/>
|
|
11
|
+
<circle cx="96" cy="256" r="26"/>
|
|
12
|
+
<circle cx="416" cy="256" r="26"/>
|
|
13
|
+
</g>
|
|
14
|
+
</svg>
|
package/web/index.html
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Access Controller — XMB</title>
|
|
7
|
+
<link rel="manifest" href="./manifest.webmanifest" />
|
|
8
|
+
<meta name="theme-color" content="#05070c" />
|
|
9
|
+
<link rel="apple-touch-icon" href="./icon.svg" />
|
|
10
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Crect width=%2732%27 height=%2732%27 rx=%277%27 fill=%27%23121a26%27/%3E%3Ccircle cx=%2716%27 cy=%2716%27 r=%277%27 fill=%27%236fa8ff%27/%3E%3C/svg%3E" />
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--ink: #eaf1f8; --dim: #9fb3c6; --accent: #6fa8ff; --accent2: #2f6bd6;
|
|
14
|
+
--ok: #45d29a; --warn: #ffcd5e; --glass: rgba(18,26,38,.42);
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
html, body { height: 100%; margin: 0; }
|
|
18
|
+
body {
|
|
19
|
+
font: 300 16px/1.4 -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
20
|
+
color: var(--ink); background: #05070c; overflow: hidden; user-select: none;
|
|
21
|
+
}
|
|
22
|
+
/* animated wave background */
|
|
23
|
+
#wave { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; display: block; }
|
|
24
|
+
#vignette { position: fixed; inset: 0; z-index: 1; pointer-events: none;
|
|
25
|
+
background: radial-gradient(120% 90% at 50% 40%, transparent 40%, rgba(0,0,0,.55) 100%); }
|
|
26
|
+
|
|
27
|
+
/* top status bar */
|
|
28
|
+
.topbar { position: fixed; z-index: 5; top: 0; left: 0; right: 0; display: flex; align-items: center;
|
|
29
|
+
gap: 16px; padding: 16px 26px; font-size: 13px; color: var(--dim); }
|
|
30
|
+
.topbar .clock { margin-left: auto; letter-spacing: .04em; }
|
|
31
|
+
.topbar .device { display: inline-flex; align-items: flex-start; gap: 8px; }
|
|
32
|
+
.topbar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 8px var(--ok); margin-top: 5px; }
|
|
33
|
+
.topbar a { color: var(--dim); text-decoration: none; border: 1px solid rgba(255,255,255,.14);
|
|
34
|
+
padding: 4px 10px; border-radius: 999px; }
|
|
35
|
+
.topbar a:hover { color: var(--ink); border-color: var(--accent); }
|
|
36
|
+
|
|
37
|
+
/* the cross stage */
|
|
38
|
+
#stage { position: fixed; inset: 0; z-index: 3; }
|
|
39
|
+
/* horizontal blade ribbon */
|
|
40
|
+
#blades { position: absolute; left: 0; right: 0; top: 38%; height: 0;
|
|
41
|
+
display: flex; align-items: center; gap: 60px;
|
|
42
|
+
transition: transform .42s cubic-bezier(.22,.61,.36,1), opacity .42s ease, filter .42s ease; will-change: transform; }
|
|
43
|
+
#blades.drilled { opacity: .28; filter: blur(1.5px); }
|
|
44
|
+
.blade { position: relative; flex: 0 0 auto; display: flex; flex-direction: column; align-items: center;
|
|
45
|
+
gap: 10px; opacity: .5; filter: saturate(.7);
|
|
46
|
+
transform: scale(.62); transform-origin: center bottom;
|
|
47
|
+
transition: opacity .42s ease, transform .42s cubic-bezier(.22,.61,.36,1), filter .42s ease; }
|
|
48
|
+
.blade.focused { opacity: 1; filter: none; transform: scale(1); }
|
|
49
|
+
.blade .icon { width: 168px; height: 168px; display: grid; place-items: center; }
|
|
50
|
+
.blade.focused .icon { filter: drop-shadow(0 6px 26px rgba(111,168,255,.5)); }
|
|
51
|
+
.blade .icon svg, .blade .icon .glyph { width: 100%; height: 100%; }
|
|
52
|
+
.blade .glyph { display: grid; place-items: center; font-size: 64px; font-weight: 200;
|
|
53
|
+
border-radius: 24px; background: var(--glass); border: 1px solid rgba(255,255,255,.08);
|
|
54
|
+
backdrop-filter: blur(8px); }
|
|
55
|
+
.ctrl-icon { width: 82%; height: 82%; }
|
|
56
|
+
.save-icon { width: 64%; height: 64%; }
|
|
57
|
+
.blade .label { font-size: 14px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); }
|
|
58
|
+
.blade.focused .label { color: var(--ink); }
|
|
59
|
+
|
|
60
|
+
/* vertical item list, hung under the focused blade at the crossing column */
|
|
61
|
+
#items { position: absolute; z-index: 4; left: 0; top: 0; min-width: 280px;
|
|
62
|
+
transition: left .42s cubic-bezier(.22,.61,.36,1); }
|
|
63
|
+
.item { padding: 9px 4px; font-size: 18px; color: var(--dim); opacity: .55; letter-spacing: .01em;
|
|
64
|
+
display: flex; align-items: center; gap: 12px; transform: scale(.92); transform-origin: left center;
|
|
65
|
+
transition: opacity .2s, transform .2s, color .2s; white-space: nowrap; }
|
|
66
|
+
.item .chev { opacity: 0; transition: opacity .2s; }
|
|
67
|
+
.item.sel { opacity: 1; color: var(--ink); transform: scale(1.08); font-weight: 400;
|
|
68
|
+
text-shadow: 0 2px 18px rgba(111,168,255,.45); }
|
|
69
|
+
.item.sel .chev { opacity: .8; }
|
|
70
|
+
.item .val { margin-left: auto; color: var(--accent); display: inline-flex; align-items: center; gap: 8px; }
|
|
71
|
+
.item .val .sym { font-size: 20px; }
|
|
72
|
+
.item.sel .val .arrow { opacity: .9; }
|
|
73
|
+
.item .val .arrow { opacity: 0; transition: opacity .2s; color: var(--dim); }
|
|
74
|
+
|
|
75
|
+
/* hero render of the focused profile */
|
|
76
|
+
/* controller render (blade icons + hero) */
|
|
77
|
+
.icon svg, #hero svg { overflow: visible; }
|
|
78
|
+
.icon svg .seg, #hero svg .seg { fill: rgba(255,255,255,.045); stroke: rgba(255,255,255,.20); stroke-width: 2; stroke-linejoin: round; transition: fill .12s, stroke .12s; }
|
|
79
|
+
.icon svg .seg.on, #hero svg .seg.on { fill: var(--accent2); stroke: var(--accent); }
|
|
80
|
+
.icon svg .seg.foc, #hero svg .seg.foc { stroke: var(--accent); stroke-width: 4; fill: rgba(111,168,255,.16); }
|
|
81
|
+
.icon svg .lab, #hero svg .lab { fill: var(--ink); font: 300 22px sans-serif; text-anchor: middle; }
|
|
82
|
+
.icon svg .lab.big, #hero svg .lab.big { font-size: 34px; }
|
|
83
|
+
.icon svg .lab.sm, #hero svg .lab.sm { font-size: 15px; fill: var(--dim); }
|
|
84
|
+
.icon svg .stickwell, #hero svg .stickwell { fill: rgba(255,255,255,.035); stroke: rgba(255,255,255,.2); stroke-width: 2; }
|
|
85
|
+
.icon svg .stickwell.foc, #hero svg .stickwell.foc { stroke: var(--accent); stroke-width: 4; }
|
|
86
|
+
.icon svg .thumb, #hero svg .thumb { fill: rgba(255,255,255,.12); stroke: rgba(255,255,255,.22); stroke-width: 2; transition: fill .1s, cx .05s, cy .05s; }
|
|
87
|
+
.icon svg .thumb.on, #hero svg .thumb.on { fill: var(--accent2); stroke: var(--accent); }
|
|
88
|
+
.icon svg .thumb.foc, #hero svg .thumb.foc { stroke: var(--accent); stroke-width: 4; }
|
|
89
|
+
|
|
90
|
+
#hero { position: absolute; z-index: 4; right: 9%; top: 52%; transform: translateY(-50%);
|
|
91
|
+
width: 33vmin; max-width: 380px; opacity: 0;
|
|
92
|
+
filter: drop-shadow(0 10px 40px rgba(0,0,0,.5)); pointer-events: none;
|
|
93
|
+
transition: opacity .4s ease; }
|
|
94
|
+
#hero svg { width: 100%; height: auto; }
|
|
95
|
+
|
|
96
|
+
/* breadcrumb / hint footer */
|
|
97
|
+
.footer { position: fixed; z-index: 5; bottom: 0; left: 0; right: 0; display: flex; gap: 22px;
|
|
98
|
+
padding: 16px 26px; font-size: 12px; color: var(--dim); letter-spacing: .03em; }
|
|
99
|
+
.footer .key { display: inline-flex; align-items: center; gap: 7px; }
|
|
100
|
+
.footer .k { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 5px;
|
|
101
|
+
border-radius: 6px; border: 1px solid rgba(255,255,255,.16); font-size: 11px; color: var(--ink); }
|
|
102
|
+
.footer .gp { margin-left: auto; color: var(--dim); }
|
|
103
|
+
.footer .gp.on { color: var(--ok); }
|
|
104
|
+
|
|
105
|
+
#toast { position: fixed; z-index: 9; left: 50%; bottom: 64px; transform: translateX(-50%) translateY(20px);
|
|
106
|
+
background: var(--glass); border: 1px solid rgba(255,255,255,.12); backdrop-filter: blur(10px);
|
|
107
|
+
color: var(--ink); padding: 10px 18px; border-radius: 12px; opacity: 0; transition: opacity .25s, transform .25s; }
|
|
108
|
+
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
109
|
+
|
|
110
|
+
.rename-input { background: rgba(0,0,0,.4); border: 1px solid var(--accent); color: var(--ink);
|
|
111
|
+
font: inherit; font-size: 18px; padding: 4px 8px; border-radius: 8px; }
|
|
112
|
+
|
|
113
|
+
#unsupported { position: fixed; inset: 0; z-index: 20; display: none; place-items: center; text-align: center;
|
|
114
|
+
background: #05070c; padding: 40px; }
|
|
115
|
+
#unsupported.show { display: grid; }
|
|
116
|
+
|
|
117
|
+
/* monitor blade icon (live signal) */
|
|
118
|
+
.mon-icon { width: 80%; height: 80%; }
|
|
119
|
+
.mon-icon .wave { fill: none; stroke: rgba(255,255,255,.5); stroke-width: 4.5; stroke-linejoin: round; stroke-linecap: round; }
|
|
120
|
+
.blade.focused .mon-icon .wave { stroke: var(--ink); }
|
|
121
|
+
|
|
122
|
+
/* full live monitor overlay */
|
|
123
|
+
#monitor { position: fixed; z-index: 6; inset: 56px 36px 24px; display: none; }
|
|
124
|
+
#monitor.show { display: block; }
|
|
125
|
+
#monitor .mon-grid { display: grid; grid-template-columns: minmax(340px,1.1fr) minmax(330px,1fr);
|
|
126
|
+
gap: 36px; align-items: center; height: 100%; }
|
|
127
|
+
#mon-render { display: grid; place-items: center; }
|
|
128
|
+
#mon-render svg { width: 100%; max-width: 420px; height: auto; overflow: visible; filter: drop-shadow(0 10px 40px rgba(0,0,0,.5)); }
|
|
129
|
+
#mon-render .seg { fill: rgba(255,255,255,.045); stroke: rgba(255,255,255,.2); stroke-width: 2; stroke-linejoin: round; transition: fill .07s, stroke .07s; }
|
|
130
|
+
#mon-render .seg.on { fill: var(--accent2); stroke: var(--accent); }
|
|
131
|
+
#mon-render .lab { fill: var(--ink); font: 300 22px sans-serif; text-anchor: middle; }
|
|
132
|
+
#mon-render .lab.big { font-size: 34px; } #mon-render .lab.sm { font-size: 15px; fill: var(--dim); }
|
|
133
|
+
#mon-render .stickwell { fill: rgba(255,255,255,.035); stroke: rgba(255,255,255,.2); stroke-width: 2; }
|
|
134
|
+
#mon-render .thumb { fill: rgba(255,255,255,.12); stroke: rgba(255,255,255,.22); stroke-width: 2; transition: fill .07s, cx .04s linear, cy .04s linear; }
|
|
135
|
+
#mon-render .thumb.on { fill: var(--accent2); stroke: var(--accent); }
|
|
136
|
+
#monitor .panel { background: var(--glass); border: 1px solid rgba(255,255,255,.08); border-radius: 18px;
|
|
137
|
+
backdrop-filter: blur(10px); padding: 20px 22px; display: flex; flex-direction: column; gap: 18px; max-height: 100%; overflow: auto; }
|
|
138
|
+
#monitor .panel h2 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); margin: 0 0 10px; font-weight: 600; }
|
|
139
|
+
#monitor .chips { display: grid; grid-template-columns: repeat(5,1fr); gap: 8px; }
|
|
140
|
+
#monitor .chip { text-align: center; padding: 9px 4px; border-radius: 9px; font-size: 13px;
|
|
141
|
+
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); color: var(--dim); transition: all .07s; }
|
|
142
|
+
#monitor .chip.on { background: var(--accent2); border-color: var(--accent); color: #fff; box-shadow: 0 0 14px rgba(111,168,255,.5); }
|
|
143
|
+
#monitor .chip small { display: block; font-size: 10px; opacity: .7; }
|
|
144
|
+
#monitor .stickrow { display: flex; align-items: center; gap: 18px; }
|
|
145
|
+
#monitor .crosshair { position: relative; width: 96px; height: 96px; border-radius: 50%;
|
|
146
|
+
border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.02); flex: 0 0 auto; }
|
|
147
|
+
#monitor .crosshair::before, #monitor .crosshair::after { content: ""; position: absolute; background: rgba(255,255,255,.08); }
|
|
148
|
+
#monitor .crosshair::before { left: 50%; top: 8%; bottom: 8%; width: 1px; }
|
|
149
|
+
#monitor .crosshair::after { top: 50%; left: 8%; right: 8%; height: 1px; }
|
|
150
|
+
#monitor .crosshair .dot { position: absolute; width: 16px; height: 16px; border-radius: 50%; background: var(--accent);
|
|
151
|
+
left: 50%; top: 50%; transform: translate(-50%,-50%); transition: left .04s linear, top .04s linear; box-shadow: 0 0 10px var(--accent); }
|
|
152
|
+
#monitor .axisvals { font: 13px ui-monospace, monospace; color: var(--dim); }
|
|
153
|
+
#monitor .axisvals b { color: var(--ink); font-weight: 400; }
|
|
154
|
+
#mon-raw { display: grid; grid-template-columns: repeat(16,1fr); gap: 3px; font: 11px ui-monospace, monospace; }
|
|
155
|
+
#mon-raw .b { text-align: center; padding: 3px 0; border-radius: 4px; background: rgba(255,255,255,.03); color: #6c7a89; }
|
|
156
|
+
#mon-raw .b.nz { color: var(--ink); background: rgba(255,255,255,.07); }
|
|
157
|
+
#mon-raw .b.btn { outline: 1.5px solid var(--accent); color: var(--accent); }
|
|
158
|
+
#mon-done { position: absolute; top: -2px; right: 0; font: inherit; font-size: 13px; color: var(--dim);
|
|
159
|
+
background: var(--glass); border: 1px solid rgba(255,255,255,.14); padding: 5px 12px; border-radius: 999px; cursor: pointer; z-index: 7; }
|
|
160
|
+
#mon-done:hover { color: var(--ink); border-color: var(--accent); }
|
|
161
|
+
.devcol { display: inline-flex; flex-direction: column; line-height: 1.25; }
|
|
162
|
+
#mon-prof { font-size: 11px; color: var(--dim); letter-spacing: .02em; }
|
|
163
|
+
#mon-prof:empty { display: none; }
|
|
164
|
+
#mon-prof b { color: var(--accent); font-weight: 500; }
|
|
165
|
+
|
|
166
|
+
/* warning / confirm gate before entering live monitor */
|
|
167
|
+
/* Full-screen PS3-style system dialog (the wave ribbon shows through behind it). */
|
|
168
|
+
#mon-warn { position: fixed; inset: 0; z-index: 8; display: none;
|
|
169
|
+
background: radial-gradient(135% 105% at 50% 30%, rgba(3,6,11,.46) 0%, rgba(1,3,7,.84) 66%, rgba(0,0,0,.93) 100%); }
|
|
170
|
+
#mon-warn.show { display: block; }
|
|
171
|
+
#mon-warn .warn-body { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
|
|
172
|
+
justify-content: center; gap: 20px; text-align: center; padding: 0 24px; }
|
|
173
|
+
#mon-warn .warn-eyebrow { display: flex; align-items: center; gap: 11px; font-size: 12px; letter-spacing: .24em;
|
|
174
|
+
text-transform: uppercase; color: var(--accent); font-weight: 400; }
|
|
175
|
+
#mon-warn .warn-eyebrow svg { width: 28px; height: 21px; }
|
|
176
|
+
#mon-warn .warn-eyebrow .wv { fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
|
|
177
|
+
#mon-warn h2 { margin: 0; font-size: clamp(24px, 3.4vw, 34px); font-weight: 200; letter-spacing: .01em; color: #fff; }
|
|
178
|
+
#mon-warn .warn-sub { margin: 0; max-width: 560px; font-size: 14.5px; line-height: 1.6; color: var(--dim); font-weight: 300; }
|
|
179
|
+
#mon-warn .warn-opts { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; }
|
|
180
|
+
#mon-warn .warn-opt { font: inherit; font-size: 18px; font-weight: 300; color: var(--dim); background: transparent;
|
|
181
|
+
border: 0; padding: 12px 48px; border-radius: 8px; cursor: pointer; letter-spacing: .015em; position: relative;
|
|
182
|
+
transition: color .12s, background .12s; }
|
|
183
|
+
#mon-warn .warn-opt:hover { color: var(--ink); }
|
|
184
|
+
#mon-warn .warn-opt.sel { color: #fff;
|
|
185
|
+
background: linear-gradient(90deg, rgba(111,168,255,0) 0%, rgba(111,168,255,.2) 50%, rgba(111,168,255,0) 100%); }
|
|
186
|
+
#mon-warn .warn-opt.sel::before { content: "▸"; position: absolute; left: 22px; color: var(--accent); font-size: 14px; }
|
|
187
|
+
#mon-warn .warn-foot { position: absolute; left: 0; right: 0; bottom: 30px; text-align: center; padding: 0 24px;
|
|
188
|
+
font-size: 12.5px; line-height: 1.55; color: var(--dim); font-weight: 300; }
|
|
189
|
+
#mon-warn .warn-foot b { color: #ffdf9b; font-weight: 500; }
|
|
190
|
+
|
|
191
|
+
/* ============================ accessibility ============================ */
|
|
192
|
+
/* Screen-reader-only text (announcer + instructions): present to AT, invisible on screen. */
|
|
193
|
+
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
194
|
+
overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
195
|
+
|
|
196
|
+
/* High-visibility focus ring on the selected item — OPT-IN (off by default) so it doesn't
|
|
197
|
+
fight the XMB look. Toggle in Help (?). OS contrast prefs force it on (see media queries). */
|
|
198
|
+
body.hi-focus .item.sel { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
|
|
199
|
+
/* Real focusable controls (buttons) keep a standard keyboard-focus ring. */
|
|
200
|
+
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
|
201
|
+
|
|
202
|
+
/* Key Bridge: highlight the option whose physical button is currently pressed. */
|
|
203
|
+
.item.physdown { color: var(--ok); }
|
|
204
|
+
.item.physdown .lab { text-shadow: 0 0 14px rgba(69,210,154,.5); }
|
|
205
|
+
|
|
206
|
+
/* Respect users who ask for less motion: kill the animated wave + all transitions. */
|
|
207
|
+
@media (prefers-reduced-motion: reduce) {
|
|
208
|
+
*, *::before, *::after { transition: none !important; animation: none !important; }
|
|
209
|
+
#wave { display: none; }
|
|
210
|
+
body { background: radial-gradient(120% 90% at 50% 35%, #0a1422 0%, #05070c 70%); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Higher contrast on request — also forces the focus ring on regardless of the toggle. */
|
|
214
|
+
@media (prefers-contrast: more) {
|
|
215
|
+
:root { --ink: #ffffff; --dim: #cdd9e6; --accent: #9cc4ff; }
|
|
216
|
+
.item { opacity: .8; }
|
|
217
|
+
.item.sel { outline: 3px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
|
|
218
|
+
}
|
|
219
|
+
/* Windows High Contrast / forced-colors: use system colors, keep focus visible. */
|
|
220
|
+
@media (forced-colors: active) {
|
|
221
|
+
#wave, #vignette { display: none; }
|
|
222
|
+
.item.sel, :focus-visible { outline: 3px solid Highlight; }
|
|
223
|
+
.item, .blade .label { color: CanvasText; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Help dialog (controls reference) — keyboard accessible, focus-trapped. */
|
|
227
|
+
#help { position: fixed; inset: 0; z-index: 12; display: none; place-items: center;
|
|
228
|
+
background: rgba(2,5,10,.72); padding: 24px; }
|
|
229
|
+
#help.show { display: grid; }
|
|
230
|
+
#help .card { background: #0b1320; border: 1px solid rgba(255,255,255,.14); border-radius: 16px;
|
|
231
|
+
max-width: 560px; width: 100%; padding: 26px 28px; color: var(--ink);
|
|
232
|
+
max-height: 88vh; overflow: auto; box-shadow: 0 24px 70px rgba(0,0,0,.6); }
|
|
233
|
+
#help h2 { margin: 0 0 4px; font-size: 20px; font-weight: 400; }
|
|
234
|
+
#help p.sub { margin: 0 0 16px; color: var(--dim); font-size: 13px; }
|
|
235
|
+
#help dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; margin: 0; font-size: 14px; }
|
|
236
|
+
#help dt { color: var(--accent); font-weight: 400; white-space: nowrap; }
|
|
237
|
+
#help dd { margin: 0; color: var(--dim); }
|
|
238
|
+
#help .close { margin-top: 20px; font: inherit; font-size: 14px; color: var(--ink);
|
|
239
|
+
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.18); border-radius: 8px;
|
|
240
|
+
padding: 8px 16px; cursor: pointer; }
|
|
241
|
+
#help .close:hover { border-color: var(--accent); }
|
|
242
|
+
</style>
|
|
243
|
+
</head>
|
|
244
|
+
<body>
|
|
245
|
+
<canvas id="wave"></canvas>
|
|
246
|
+
<div id="vignette"></div>
|
|
247
|
+
|
|
248
|
+
<div class="topbar">
|
|
249
|
+
<span class="device"><span class="dot" id="dev-dot"></span><span class="devcol"><span id="dev-name">No controller</span><span id="mon-prof"></span></span></span>
|
|
250
|
+
<span id="crumb"></span>
|
|
251
|
+
<span class="clock" id="clock"></span>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<!-- Screen-reader live region: every navigation/value change is announced here. -->
|
|
255
|
+
<div id="sr" class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
256
|
+
<p id="app-instructions" class="visually-hidden">
|
|
257
|
+
Access Controller configurator. Use the Left and Right arrow keys to move between sections,
|
|
258
|
+
Up and Down to move between options, and Left or Right to change a value. Press Enter to
|
|
259
|
+
confirm and Escape or Backspace to go back. Press the question-mark key for the full list of
|
|
260
|
+
controls. You can also use the Access Controller itself: the stick navigates, the center or
|
|
261
|
+
stick-click confirms, and any ring button goes back.
|
|
262
|
+
</p>
|
|
263
|
+
|
|
264
|
+
<div id="stage" role="application" aria-label="Access Controller configurator" aria-describedby="app-instructions">
|
|
265
|
+
<div id="blades"></div>
|
|
266
|
+
<div id="items"></div>
|
|
267
|
+
<div id="hero" aria-hidden="true"></div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div id="monitor">
|
|
271
|
+
<button id="mon-done">Done · Esc</button>
|
|
272
|
+
<div class="mon-grid">
|
|
273
|
+
<div id="mon-render"></div>
|
|
274
|
+
<div class="panel">
|
|
275
|
+
<div>
|
|
276
|
+
<h2>Buttons (physical)</h2>
|
|
277
|
+
<div class="chips" id="mon-chips"></div>
|
|
278
|
+
</div>
|
|
279
|
+
<div>
|
|
280
|
+
<h2>Stick</h2>
|
|
281
|
+
<div class="stickrow">
|
|
282
|
+
<div class="crosshair"><div class="dot" id="mon-stickdot"></div></div>
|
|
283
|
+
<div class="axisvals">X <b id="mon-ax">0.00</b><br>Y <b id="mon-ay">0.00</b></div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
<div>
|
|
287
|
+
<h2>Raw input report (id 0x01)</h2>
|
|
288
|
+
<div id="mon-raw"></div>
|
|
289
|
+
<div class="legend" style="font-size:11px;color:var(--dim);margin-top:6px">outlined = <b style="color:var(--accent)">byte 15</b> (perimeter) & <b style="color:var(--accent)">byte 16</b> (center / stick-click)</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div id="mon-warn">
|
|
296
|
+
<div class="warn-body">
|
|
297
|
+
<div class="warn-eyebrow"><svg viewBox="0 0 120 92" aria-hidden="true"><polyline class="wv" points="12,52 30,52 40,30 52,68 64,22 76,52 86,44 108,44"/></svg> Live Input Monitor</div>
|
|
298
|
+
<h2>Start the live monitor?</h2>
|
|
299
|
+
<p class="warn-sub">The controller is only observed here — menu navigation pauses so you can press every button and fully move the stick without scrolling the bar.</p>
|
|
300
|
+
<div class="warn-opts">
|
|
301
|
+
<button class="warn-opt sel" id="warn-start" data-i="0">Start monitoring</button>
|
|
302
|
+
<button class="warn-opt" id="warn-cancel" data-i="1">Cancel</button>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="warn-foot">To leave the monitor you'll need the <b>keyboard</b> — press <b>Esc</b> — or click <b>Done</b>. The controller can't exit this view on its own.</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<div class="footer">
|
|
309
|
+
<span class="key"><span class="k">↑</span><span class="k">↓</span><span class="k">←</span><span class="k">→</span> navigate</span>
|
|
310
|
+
<span class="key"><span class="k">⏎</span> / center · stick-click — confirm</span>
|
|
311
|
+
<span class="key"><span class="k">⌫</span> / any ring button — back</span>
|
|
312
|
+
<span class="key"><span class="k">M</span> sound</span>
|
|
313
|
+
<span class="key"><span class="k">?</span> help</span>
|
|
314
|
+
<span class="gp" id="gp-status">controller: not detected</span>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<div id="toast" role="status" aria-live="polite"></div>
|
|
318
|
+
|
|
319
|
+
<div id="help" role="dialog" aria-modal="true" aria-labelledby="help-title" aria-hidden="true">
|
|
320
|
+
<div class="card">
|
|
321
|
+
<h2 id="help-title">Controls</h2>
|
|
322
|
+
<p class="sub">This configurator works with the keyboard, the mouse, or the Access Controller itself.</p>
|
|
323
|
+
<dl>
|
|
324
|
+
<dt>← →</dt><dd>Move between sections (Controllers, Profiles, Save, Library, Monitor)</dd>
|
|
325
|
+
<dt>↑ ↓</dt><dd>Move between options in a section</dd>
|
|
326
|
+
<dt>← → (in an option)</dt><dd>Change the highlighted value</dd>
|
|
327
|
+
<dt>Enter</dt><dd>Confirm / open the highlighted option</dd>
|
|
328
|
+
<dt>Esc / Backspace</dt><dd>Go back / close</dd>
|
|
329
|
+
<dt>Stick</dt><dd>Navigate (on the Access Controller)</dd>
|
|
330
|
+
<dt>Center / stick-click</dt><dd>Confirm</dd>
|
|
331
|
+
<dt>Any ring button</dt><dd>Go back</dd>
|
|
332
|
+
<dt>M</dt><dd>Toggle navigation sounds</dd>
|
|
333
|
+
<dt>?</dt><dd>Open this help</dd>
|
|
334
|
+
</dl>
|
|
335
|
+
<button class="close" id="help-focus" aria-pressed="false" style="margin-top:18px;margin-right:8px">High-visibility focus ring: Off</button>
|
|
336
|
+
<button class="close" id="help-close">Close · Esc</button>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div id="unsupported"><div>
|
|
340
|
+
<h2>WebHID isn't available</h2>
|
|
341
|
+
<p style="color:var(--dim)">Open this in Chrome or Edge (desktop) to use the XMB configurator.</p>
|
|
342
|
+
</div></div>
|
|
343
|
+
|
|
344
|
+
<script type="module" src="./xmb.js"></script>
|
|
345
|
+
</body>
|
|
346
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Access Controller Configurator",
|
|
3
|
+
"short_name": "ps-access",
|
|
4
|
+
"description": "Read, edit, share, and back up PlayStation Access Controller profiles from a PC over USB-C — no PS5 required.",
|
|
5
|
+
"start_url": "./",
|
|
6
|
+
"scope": "./",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"orientation": "landscape",
|
|
9
|
+
"background_color": "#05070c",
|
|
10
|
+
"theme_color": "#05070c",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "./icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
|
13
|
+
]
|
|
14
|
+
}
|