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
package/web/monitor.html
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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 — Input Monitor</title>
|
|
7
|
+
<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" />
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--ink: #eaf1f8; --dim: #9fb3c6; --accent: #6fa8ff; --accent2: #2f6bd6;
|
|
11
|
+
--ok: #45d29a; --warn: #ffcd5e; --glass: rgba(18,26,38,.42);
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
html, body { height: 100%; margin: 0; }
|
|
15
|
+
body { font: 300 16px/1.4 -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
16
|
+
color: var(--ink); background: #05070c; overflow: hidden; user-select: none; }
|
|
17
|
+
#wave { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; display: block; }
|
|
18
|
+
#vignette { position: fixed; inset: 0; z-index: 1; pointer-events: none;
|
|
19
|
+
background: radial-gradient(120% 90% at 50% 40%, transparent 40%, rgba(0,0,0,.55) 100%); }
|
|
20
|
+
|
|
21
|
+
.topbar { position: fixed; z-index: 5; top: 0; left: 0; right: 0; display: flex; align-items: center;
|
|
22
|
+
gap: 16px; padding: 16px 26px; font-size: 13px; color: var(--dim); }
|
|
23
|
+
.topbar .title { color: var(--ink); letter-spacing: .04em; font-size: 14px; }
|
|
24
|
+
.topbar .device { display: inline-flex; align-items: center; gap: 8px; }
|
|
25
|
+
.topbar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); }
|
|
26
|
+
.topbar .device.on .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
|
27
|
+
.topbar .clock { margin-left: auto; letter-spacing: .04em; }
|
|
28
|
+
.topbar a { color: var(--dim); text-decoration: none; border: 1px solid rgba(255,255,255,.14); padding: 4px 10px; border-radius: 999px; }
|
|
29
|
+
.topbar a:hover { color: var(--ink); border-color: var(--accent); }
|
|
30
|
+
|
|
31
|
+
.stage { position: fixed; z-index: 3; inset: 64px 32px 28px; display: grid;
|
|
32
|
+
grid-template-columns: minmax(360px, 1.1fr) minmax(340px, 1fr); gap: 36px; align-items: center; }
|
|
33
|
+
|
|
34
|
+
/* live controller render */
|
|
35
|
+
#render { display: grid; place-items: center; }
|
|
36
|
+
#render svg { width: 100%; max-width: 460px; height: auto; filter: drop-shadow(0 10px 40px rgba(0,0,0,.5)); overflow: visible; }
|
|
37
|
+
#render .seg { fill: rgba(255,255,255,.045); stroke: rgba(255,255,255,.20); stroke-width: 2; stroke-linejoin: round; transition: fill .07s, stroke .07s; }
|
|
38
|
+
#render .seg.on { fill: var(--accent2); stroke: var(--accent); }
|
|
39
|
+
#render .lab { fill: var(--ink); font: 300 22px sans-serif; text-anchor: middle; }
|
|
40
|
+
#render .lab.big { font-size: 34px; }
|
|
41
|
+
#render .lab.sm { font-size: 15px; fill: var(--dim); }
|
|
42
|
+
#render .stickwell { fill: rgba(255,255,255,.035); stroke: rgba(255,255,255,.2); stroke-width: 2; }
|
|
43
|
+
#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; }
|
|
44
|
+
#render .thumb.on { fill: var(--accent2); stroke: var(--accent); }
|
|
45
|
+
|
|
46
|
+
/* diagnostics panel */
|
|
47
|
+
.panel { background: var(--glass); border: 1px solid rgba(255,255,255,.08); border-radius: 18px;
|
|
48
|
+
backdrop-filter: blur(10px); padding: 20px 22px; display: flex; flex-direction: column; gap: 18px; max-height: 100%; overflow: auto; }
|
|
49
|
+
.panel h2 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); margin: 0 0 10px; font-weight: 600; }
|
|
50
|
+
.chips { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
|
|
51
|
+
.chip { text-align: center; padding: 9px 4px; border-radius: 9px; font-size: 13px;
|
|
52
|
+
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); color: var(--dim); transition: all .07s; }
|
|
53
|
+
.chip.on { background: var(--accent2); border-color: var(--accent); color: #fff; box-shadow: 0 0 14px rgba(111,168,255,.5); }
|
|
54
|
+
.chip small { display: block; font-size: 10px; opacity: .7; }
|
|
55
|
+
|
|
56
|
+
.stickrow { display: flex; align-items: center; gap: 18px; }
|
|
57
|
+
.crosshair { position: relative; width: 96px; height: 96px; border-radius: 50%;
|
|
58
|
+
border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.02); flex: 0 0 auto; }
|
|
59
|
+
.crosshair::before, .crosshair::after { content: ""; position: absolute; background: rgba(255,255,255,.08); }
|
|
60
|
+
.crosshair::before { left: 50%; top: 8%; bottom: 8%; width: 1px; }
|
|
61
|
+
.crosshair::after { top: 50%; left: 8%; right: 8%; height: 1px; }
|
|
62
|
+
.crosshair .dot { position: absolute; width: 16px; height: 16px; border-radius: 50%; background: var(--accent);
|
|
63
|
+
left: 50%; top: 50%; transform: translate(-50%,-50%); transition: left .04s linear, top .04s linear; box-shadow: 0 0 10px var(--accent); }
|
|
64
|
+
.axisvals { font: 13px ui-monospace, monospace; color: var(--dim); }
|
|
65
|
+
.axisvals b { color: var(--ink); font-weight: 400; }
|
|
66
|
+
|
|
67
|
+
/* raw input report */
|
|
68
|
+
#raw { display: grid; grid-template-columns: repeat(16, 1fr); gap: 3px; font: 11px ui-monospace, monospace; }
|
|
69
|
+
#raw .b { text-align: center; padding: 3px 0; border-radius: 4px; background: rgba(255,255,255,.03); color: #6c7a89; }
|
|
70
|
+
#raw .b.nz { color: var(--ink); background: rgba(255,255,255,.07); }
|
|
71
|
+
#raw .b.btn { outline: 1.5px solid var(--accent); color: var(--accent); }
|
|
72
|
+
.legend { font-size: 11px; color: var(--dim); margin-top: 6px; }
|
|
73
|
+
.legend b { color: var(--accent); }
|
|
74
|
+
|
|
75
|
+
.center-msg { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; text-align: center; }
|
|
76
|
+
.center-msg button { font: inherit; background: var(--accent2); color: #fff; border: 0; padding: 12px 22px; border-radius: 12px; cursor: pointer; }
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<canvas id="wave"></canvas>
|
|
81
|
+
<div id="vignette"></div>
|
|
82
|
+
|
|
83
|
+
<div class="topbar">
|
|
84
|
+
<span class="title">Input Monitor</span>
|
|
85
|
+
<span class="device" id="dev"><span class="dot"></span><span id="dev-name">Not connected</span></span>
|
|
86
|
+
<span class="clock" id="clock"></span>
|
|
87
|
+
<a href="./index.html">✦ Configure</a>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="stage" id="stage" style="display:none">
|
|
91
|
+
<div id="render"></div>
|
|
92
|
+
<div class="panel">
|
|
93
|
+
<div>
|
|
94
|
+
<h2>Buttons (physical)</h2>
|
|
95
|
+
<div class="chips" id="chips"></div>
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<h2>Stick</h2>
|
|
99
|
+
<div class="stickrow">
|
|
100
|
+
<div class="crosshair"><div class="dot" id="stickdot"></div></div>
|
|
101
|
+
<div class="axisvals">X <b id="ax">0.00</b><br>Y <b id="ay">0.00</b></div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div>
|
|
105
|
+
<h2>Raw input report (id 0x01)</h2>
|
|
106
|
+
<div id="raw"></div>
|
|
107
|
+
<div class="legend">outlined = <b>byte 15</b> (perimeter) & <b>byte 16</b> (center / stick-click)</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="center-msg" id="connect-msg">
|
|
113
|
+
<div>
|
|
114
|
+
<p id="msg" style="color:var(--dim)">Connect your Access Controller (USB-C) to monitor input.</p>
|
|
115
|
+
<button id="connect">Connect controller</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<script type="module" src="./monitor.js"></script>
|
|
120
|
+
</body>
|
|
121
|
+
</html>
|
package/web/monitor.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// XMB-styled live input monitor — reimagined hid-capture. Shows the controller render reacting
|
|
2
|
+
// to physical input, physical-button chips, the stick, and the raw input report.
|
|
3
|
+
import { profileSVG, decodePhysical, PHYS_NAMES, M } from "./controller-render.mjs";
|
|
4
|
+
import { hidSupported, grantedControllers, requestControllers, ensureOpen, readProfileRaw } from "./hid-web.mjs";
|
|
5
|
+
import { parseProfile } from "./access-protocol.mjs";
|
|
6
|
+
|
|
7
|
+
const $ = (s) => document.querySelector(s);
|
|
8
|
+
let device = null, profile = null;
|
|
9
|
+
|
|
10
|
+
// ---- build static UI ----
|
|
11
|
+
function buildChips() {
|
|
12
|
+
$("#chips").innerHTML = PHYS_NAMES.map((n, i) =>
|
|
13
|
+
`<div class="chip" data-i="${i}">${i < 8 ? n : n.split("-")[0]}<small>${i < 8 ? "button" : (i === 8 ? "center" : "L3")}</small></div>`
|
|
14
|
+
).join("");
|
|
15
|
+
}
|
|
16
|
+
function buildRaw() {
|
|
17
|
+
let h = "";
|
|
18
|
+
for (let i = 0; i < 63; i++) h += `<div class="b${i === 15 || i === 16 ? " btn" : ""}" data-i="${i}">00</div>`;
|
|
19
|
+
$("#raw").innerHTML = h;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---- live update ----
|
|
23
|
+
function onReport(e) {
|
|
24
|
+
if (device && e.device !== device) return;
|
|
25
|
+
const d = new Uint8Array(e.data.buffer.slice(e.data.byteOffset, e.data.byteOffset + e.data.byteLength));
|
|
26
|
+
const { buttons, axes } = decodePhysical(d);
|
|
27
|
+
$("#dev").classList.add("on");
|
|
28
|
+
$("#dev-name").textContent = device?.productName || "Access Controller";
|
|
29
|
+
|
|
30
|
+
// controller render: light physical buttons + move thumb
|
|
31
|
+
for (const el of document.querySelectorAll("#render svg [data-btn]")) {
|
|
32
|
+
el.classList.toggle("on", buttons.has(+el.getAttribute("data-btn")));
|
|
33
|
+
}
|
|
34
|
+
const thumb = $("#render svg .thumb");
|
|
35
|
+
if (thumb) {
|
|
36
|
+
thumb.setAttribute("cx", (+thumb.dataset.bx + axes[0] * M.THUMB_R).toFixed(1));
|
|
37
|
+
thumb.setAttribute("cy", (+thumb.dataset.by + axes[1] * M.THUMB_R).toFixed(1));
|
|
38
|
+
}
|
|
39
|
+
// chips
|
|
40
|
+
for (const c of document.querySelectorAll("#chips .chip")) c.classList.toggle("on", buttons.has(+c.dataset.i));
|
|
41
|
+
// stick crosshair + values
|
|
42
|
+
$("#stickdot").style.left = (50 + axes[0] * 38) + "%";
|
|
43
|
+
$("#stickdot").style.top = (50 + axes[1] * 38) + "%";
|
|
44
|
+
$("#ax").textContent = axes[0].toFixed(2);
|
|
45
|
+
$("#ay").textContent = axes[1].toFixed(2);
|
|
46
|
+
// raw bytes
|
|
47
|
+
const cells = $("#raw").children;
|
|
48
|
+
for (let i = 0; i < d.length && i < cells.length; i++) {
|
|
49
|
+
cells[i].textContent = d[i].toString(16).padStart(2, "0");
|
|
50
|
+
cells[i].classList.toggle("nz", d[i] !== 0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- connect ----
|
|
55
|
+
async function connect(viaGesture) {
|
|
56
|
+
try {
|
|
57
|
+
let ds = await grantedControllers();
|
|
58
|
+
if (!ds.length && viaGesture) ds = await requestControllers();
|
|
59
|
+
device = ds[0];
|
|
60
|
+
if (!device) { $("#msg").textContent = "No controller selected."; return; }
|
|
61
|
+
await ensureOpen(device);
|
|
62
|
+
profile = parseProfile(await readProfileRaw(device, 1));
|
|
63
|
+
$("#render").innerHTML = profileSVG(profile);
|
|
64
|
+
device.addEventListener("inputreport", onReport);
|
|
65
|
+
$("#connect-msg").style.display = "none";
|
|
66
|
+
$("#stage").style.display = "grid";
|
|
67
|
+
} catch (e) { $("#msg").textContent = "Error: " + (e.message || e); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- clock ----
|
|
71
|
+
function tickClock() { $("#clock").textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
|
|
72
|
+
|
|
73
|
+
// ---- wave background (shared visual language with the XMB view) ----
|
|
74
|
+
function startWave() {
|
|
75
|
+
const cv = $("#wave"), ctx = cv.getContext("2d");
|
|
76
|
+
let w, h;
|
|
77
|
+
const resize = () => { w = cv.width = innerWidth * devicePixelRatio; h = cv.height = innerHeight * devicePixelRatio; };
|
|
78
|
+
resize(); addEventListener("resize", resize);
|
|
79
|
+
let t = 0;
|
|
80
|
+
const bands = [
|
|
81
|
+
{ amp: 0.10, len: 0.9, sp: 0.6, y: 0.42, hue: 215, a: 0.20 },
|
|
82
|
+
{ amp: 0.07, len: 1.4, sp: -0.4, y: 0.55, hue: 200, a: 0.16 },
|
|
83
|
+
{ amp: 0.13, len: 0.7, sp: 0.9, y: 0.66, hue: 230, a: 0.13 },
|
|
84
|
+
];
|
|
85
|
+
const draw = () => {
|
|
86
|
+
t += 0.005; ctx.clearRect(0, 0, w, h);
|
|
87
|
+
const hueShift = 18 * Math.sin(t * 0.05);
|
|
88
|
+
for (const b of bands) {
|
|
89
|
+
ctx.beginPath(); ctx.moveTo(0, h);
|
|
90
|
+
for (let x = 0; x <= w; x += 16 * devicePixelRatio) {
|
|
91
|
+
const y = h * b.y + Math.sin(x / w * Math.PI * 2 * b.len + t * b.sp) * h * b.amp
|
|
92
|
+
+ Math.sin(x / w * Math.PI * 5 * b.len - t * b.sp * 1.7) * h * b.amp * 0.3;
|
|
93
|
+
ctx.lineTo(x, y);
|
|
94
|
+
}
|
|
95
|
+
ctx.lineTo(w, h); ctx.closePath();
|
|
96
|
+
const g = ctx.createLinearGradient(0, h * b.y - h * 0.2, 0, h);
|
|
97
|
+
g.addColorStop(0, `hsla(${b.hue + hueShift},70%,55%,${b.a})`);
|
|
98
|
+
g.addColorStop(1, `hsla(${b.hue + hueShift},70%,30%,0)`);
|
|
99
|
+
ctx.fillStyle = g; ctx.fill();
|
|
100
|
+
}
|
|
101
|
+
requestAnimationFrame(draw);
|
|
102
|
+
};
|
|
103
|
+
draw();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- init ----
|
|
107
|
+
function init() {
|
|
108
|
+
startWave();
|
|
109
|
+
tickClock(); setInterval(tickClock, 15000);
|
|
110
|
+
if (!hidSupported()) { $("#msg").textContent = "WebHID not supported — use Chrome/Edge (desktop)."; $("#connect").style.display = "none"; return; }
|
|
111
|
+
buildChips(); buildRaw();
|
|
112
|
+
$("#connect").onclick = () => connect(true);
|
|
113
|
+
// mark stale if input stops
|
|
114
|
+
setInterval(() => { /* status freshness handled per-report */ }, 1000);
|
|
115
|
+
grantedControllers().then((g) => { if (g.length) connect(false); });
|
|
116
|
+
}
|
|
117
|
+
init();
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Profile sharing + preset library for the Access Controller — pure, I/O-free.
|
|
2
|
+
//
|
|
3
|
+
// A "portable" profile is a small, JSON-serializable subset of a decoded profile
|
|
4
|
+
// (name + button/port mappings) that can be exported to a file, encoded into a URL,
|
|
5
|
+
// or applied on top of an existing on-device profile. Device-specific fields (uuid,
|
|
6
|
+
// timestamp, raw bytes) are deliberately NOT carried — applyPortable() merges a
|
|
7
|
+
// portable onto a live profile so those fields survive.
|
|
8
|
+
|
|
9
|
+
export const PORTABLE_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
// ---- conversion -------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// Decoded profile -> portable spec. Only the user-meaningful mapping is kept.
|
|
14
|
+
export function toPortable(profile) {
|
|
15
|
+
return {
|
|
16
|
+
v: PORTABLE_VERSION,
|
|
17
|
+
name: profile.name || "",
|
|
18
|
+
buttons: (profile.buttons || []).map((b) => ({ map1: b.map1 | 0, map2: b.map2 | 0, toggle: !!b.toggle })),
|
|
19
|
+
ports: (profile.ports || []).map(clonePort),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Apply a portable spec onto a live decoded profile, in place. Returns the profile.
|
|
24
|
+
// Keeps profile._raw / uuid / timestamp so a subsequent buildProfile() round-trips
|
|
25
|
+
// cleanly and only the mappings change.
|
|
26
|
+
export function applyPortable(profile, portable) {
|
|
27
|
+
if (!portable || typeof portable !== "object") throw new Error("not a profile");
|
|
28
|
+
if (typeof portable.name === "string") profile.name = portable.name.slice(0, 40);
|
|
29
|
+
if (Array.isArray(portable.buttons)) {
|
|
30
|
+
profile.buttons = profile.buttons.map((b, i) => {
|
|
31
|
+
const src = portable.buttons[i];
|
|
32
|
+
return src ? { map1: src.map1 | 0, map2: src.map2 | 0, toggle: !!src.toggle } : b;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(portable.ports)) {
|
|
36
|
+
profile.ports = profile.ports.map((p, i) => (portable.ports[i] ? clonePort(portable.ports[i]) : p));
|
|
37
|
+
}
|
|
38
|
+
return profile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clonePort(p) {
|
|
42
|
+
if (!p || p.kind === "none") return { kind: "none" };
|
|
43
|
+
if (p.kind === "stick") {
|
|
44
|
+
return {
|
|
45
|
+
kind: "stick", stick: p.stick | 0, orientation: p.orientation | 0,
|
|
46
|
+
...(p.sensitivity != null ? { sensitivity: p.sensitivity | 0 } : {}),
|
|
47
|
+
...(Array.isArray(p.deadzone) ? { deadzone: p.deadzone.slice(0, 6).map((x) => x | 0) } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (p.kind === "button") return { kind: "button", analog: !!p.analog, map1: p.map1 | 0, map2: p.map2 | 0, toggle: !!p.toggle };
|
|
51
|
+
return { kind: "none" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- share encoding (URL-safe, no server) -----------------------------------
|
|
55
|
+
|
|
56
|
+
export function encodeShare(portable) {
|
|
57
|
+
const json = JSON.stringify(portable);
|
|
58
|
+
return base64urlEncode(json);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function decodeShare(str) {
|
|
62
|
+
const portable = JSON.parse(base64urlDecode(String(str).trim()));
|
|
63
|
+
if (!portable || portable.v !== PORTABLE_VERSION) throw new Error("unsupported or corrupt share code");
|
|
64
|
+
return portable;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build a shareable URL whose hash carries the profile (e.g. .../#p=<code>).
|
|
68
|
+
export function shareURL(portable, base) {
|
|
69
|
+
const origin = base || (typeof location !== "undefined" ? location.origin + location.pathname : "");
|
|
70
|
+
return `${origin}#p=${encodeShare(portable)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pull a portable out of a URL hash, or null if none. Accepts "#p=..." or "p=...".
|
|
74
|
+
export function parseShareHash(hash) {
|
|
75
|
+
if (!hash) return null;
|
|
76
|
+
const m = String(hash).replace(/^#/, "").match(/(?:^|&)p=([^&]+)/);
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
try { return decodeShare(decodeURIComponent(m[1])); } catch { return null; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- file import/export -----------------------------------------------------
|
|
82
|
+
|
|
83
|
+
// Serialize a portable to file text (pretty JSON, with a type tag for sniffing).
|
|
84
|
+
export function toFileText(portable) {
|
|
85
|
+
return JSON.stringify({ type: "ps-access-profile", ...portable }, null, 2);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Parse uploaded text. Accepts: a portable file (this app), a raw portable object,
|
|
89
|
+
// a share code, or a CLI backup ({ profiles:[{ decoded }] }) — picking a slot.
|
|
90
|
+
export function fromFileText(text, slot = 0) {
|
|
91
|
+
const s = String(text).trim();
|
|
92
|
+
if (!s.startsWith("{") && !s.startsWith("[")) return decodeShare(s); // bare share code
|
|
93
|
+
const obj = JSON.parse(s);
|
|
94
|
+
if (Array.isArray(obj.profiles)) {
|
|
95
|
+
const entry = obj.profiles.find((p) => p.slot === slot + 1) || obj.profiles[0];
|
|
96
|
+
if (!entry?.decoded) throw new Error("backup file has no decoded profile");
|
|
97
|
+
return toPortable(entry.decoded);
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(obj.buttons) || Array.isArray(obj.ports)) {
|
|
100
|
+
return { v: PORTABLE_VERSION, name: obj.name || "", buttons: obj.buttons || [], ports: obj.ports || [] };
|
|
101
|
+
}
|
|
102
|
+
throw new Error("unrecognized profile file");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- preset library ---------------------------------------------------------
|
|
106
|
+
//
|
|
107
|
+
// Curated STARTING POINTS, not prescriptions — every body and game is different, so
|
|
108
|
+
// these are meant to be applied and then customized. Codes match access-protocol ACTIONS:
|
|
109
|
+
// 0 nothing 1 circle 2 cross 3 triangle 4 square 5 up 6 down 7 left 8 right
|
|
110
|
+
// 9 L1 10 R1 11 L2 12 R2 13 L3 14 R3 15 options 16 create 17 PS 18 touchpad
|
|
111
|
+
// Ports: index 0 = built-in stick; 1..4 = expansion jacks (external switches).
|
|
112
|
+
|
|
113
|
+
const btn = (map1, opts = {}) => ({ map1, map2: opts.map2 || 0, toggle: !!opts.toggle });
|
|
114
|
+
const portBtn = (map1, opts = {}) => ({ kind: "button", analog: false, map1, map2: 0, toggle: !!opts.toggle });
|
|
115
|
+
const stickPort = (stick, orientation) => ({ kind: "stick", stick, orientation });
|
|
116
|
+
|
|
117
|
+
export const PRESETS = [
|
|
118
|
+
{
|
|
119
|
+
id: "reset-neutral",
|
|
120
|
+
name: "Neutral reset",
|
|
121
|
+
description: "Clears expansion ports and sets a left stick. A clean baseline to build on.",
|
|
122
|
+
tags: ["baseline"],
|
|
123
|
+
portable: {
|
|
124
|
+
v: PORTABLE_VERSION, name: "Neutral",
|
|
125
|
+
buttons: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((c) => btn(c)),
|
|
126
|
+
ports: [stickPort(1, 3), { kind: "none" }, { kind: "none" }, { kind: "none" }, { kind: "none" }],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "toggle-triggers",
|
|
131
|
+
name: "Toggle triggers & sprint",
|
|
132
|
+
description: "Makes L2/R2 and L3 (sprint/aim) toggle instead of hold — press once on, once off. Reduces sustained-hold fatigue.",
|
|
133
|
+
tags: ["fatigue", "low-force"],
|
|
134
|
+
portable: {
|
|
135
|
+
v: PORTABLE_VERSION, name: "Toggles",
|
|
136
|
+
buttons: [btn(1), btn(2), btn(3), btn(4), btn(5), btn(6), btn(11, { toggle: true }), btn(12, { toggle: true }), btn(13, { toggle: true }), btn(10)],
|
|
137
|
+
ports: [stickPort(1, 3), { kind: "none" }, { kind: "none" }, { kind: "none" }, { kind: "none" }],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "one-handed-right",
|
|
142
|
+
name: "One-handed (stick on right)",
|
|
143
|
+
description: "Built-in stick on the right side; the four expansion ports become Cross, Circle, Square, Triangle for external switches placed within reach.",
|
|
144
|
+
tags: ["one-handed", "switch-access"],
|
|
145
|
+
portable: {
|
|
146
|
+
v: PORTABLE_VERSION, name: "One-hand R",
|
|
147
|
+
buttons: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((c) => btn(c)),
|
|
148
|
+
ports: [stickPort(1, 1), portBtn(2), portBtn(1), portBtn(4), portBtn(3)],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "external-dpad",
|
|
153
|
+
name: "External switches → D-pad",
|
|
154
|
+
description: "Maps the four expansion ports to Up, Down, Left, Right for menu/navigation control with separate adaptive switches.",
|
|
155
|
+
tags: ["switch-access", "navigation"],
|
|
156
|
+
portable: {
|
|
157
|
+
v: PORTABLE_VERSION, name: "Switch D-pad",
|
|
158
|
+
buttons: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((c) => btn(c)),
|
|
159
|
+
ports: [stickPort(1, 3), portBtn(5), portBtn(6), portBtn(7), portBtn(8)],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
export function presetById(id) {
|
|
165
|
+
return PRESETS.find((p) => p.id === id) || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- base64url helpers (work in browser and Node) ---------------------------
|
|
169
|
+
|
|
170
|
+
function base64urlEncode(str) {
|
|
171
|
+
const b64 = typeof btoa === "function"
|
|
172
|
+
? btoa(unescape(encodeURIComponent(str)))
|
|
173
|
+
: Buffer.from(str, "utf8").toString("base64");
|
|
174
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function base64urlDecode(s) {
|
|
178
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
|
|
179
|
+
if (typeof atob === "function") return decodeURIComponent(escape(atob(b64)));
|
|
180
|
+
return Buffer.from(b64, "base64").toString("utf8");
|
|
181
|
+
}
|
package/web/serve.json
ADDED
package/web/sw.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ps-access service worker — NETWORK-FIRST by design.
|
|
2
|
+
//
|
|
3
|
+
// Caching this app caused real grief before, so the rule here is simple and safe:
|
|
4
|
+
// when online, ALWAYS serve fresh from the network (no stale class of bugs). The cache
|
|
5
|
+
// is only a fallback for offline use. New versions take over immediately (skipWaiting +
|
|
6
|
+
// clients.claim), so a deploy is never masked by the service worker.
|
|
7
|
+
|
|
8
|
+
const CACHE = "ps-access-v1";
|
|
9
|
+
|
|
10
|
+
self.addEventListener("install", (e) => {
|
|
11
|
+
self.skipWaiting(); // activate the new SW without waiting for tabs to close
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
self.addEventListener("activate", (e) => {
|
|
15
|
+
e.waitUntil((async () => {
|
|
16
|
+
for (const key of await caches.keys()) if (key !== CACHE) await caches.delete(key);
|
|
17
|
+
await self.clients.claim();
|
|
18
|
+
})());
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
self.addEventListener("fetch", (e) => {
|
|
22
|
+
const req = e.request;
|
|
23
|
+
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) return;
|
|
24
|
+
e.respondWith((async () => {
|
|
25
|
+
try {
|
|
26
|
+
const fresh = await fetch(req); // network first — always current when online
|
|
27
|
+
if (fresh && fresh.ok) {
|
|
28
|
+
const copy = fresh.clone();
|
|
29
|
+
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
return fresh;
|
|
32
|
+
} catch {
|
|
33
|
+
const cached = await caches.match(req); // offline fallback
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
if (req.mode === "navigate") return caches.match("./");
|
|
36
|
+
throw new Error("offline and not cached");
|
|
37
|
+
}
|
|
38
|
+
})());
|
|
39
|
+
});
|