mirror of
https://github.com/MoHadiShibli/Control4Free.git
synced 2026-10-06 10:00:28 +02:00
Show who's signed in on each controller, and an Invite QR code
When the PS4 reports a user signed in on a controller, the service asks for the user's name and adds it to the status as `user`. The PS4 may not have finished signing the user in by then, so a failed look-up is retried every half second for five seconds. The tile shows the name in place of "Controller N" (the ring keeps the number), and the controller screen reads "Controller 1 · Alex". Names are kept out of the log, since people post logs in bug reports, and they go out cleaned: quotes and backslashes escaped, and control characters or anything that isn't valid UTF-8 replaced, because a browser drops the whole WebSocket on a text frame that isn't valid UTF-8. An Invite panel, opened from a button next to the settings or from the menu, shows the page's address as a QR code for friends to scan. The console makes the code with the QR encoder the app already ships, from the address the page reached it on, and sends the module grid; the page draws it as SVG, so it still needs no libraries. The gamepad picker's tooltip had the same "Controller 1 · Controller 1" doubling the controller screen had; it now names the user too.
This commit is contained in:
1 parent
be950bf7b5
commit
200e2bfc0b
18 files changed
+308
-19
No files matched your search
+6
-2
@@ -12,9 +12,13 @@ Rumble and the light bar.
|
||||
gamepad that can't be rumbled says so on its row.
|
||||
- The page glows in the controller's light-bar colour, the player colour the PS4 gives each user or whatever
|
||||
the game sets, brightened from the quarter strength the PS4 uses.
|
||||
- Each controller shows the name of the PS4 user signed in on it.
|
||||
- An **Invite** button shows the page's address as a QR code, so friends can scan it and join. The console
|
||||
makes the code, so the page still needs no libraries.
|
||||
- The page says "Choose user on PS4" rather than "on TV": not everyone plays on a TV.
|
||||
- The status reports the light bar's colour in `color`, and the service sends rumble as
|
||||
`{"method": "v", "params": [pad, large, small]}`. See [docs/protocol.md](docs/protocol.md).
|
||||
- Protocol: the status reports the light bar's colour in `color` and the signed-in user in `user`; the
|
||||
service sends rumble as `{"method": "v", "params": [pad, large, small]}`; a new `invite` method returns the
|
||||
QR code. See [docs/protocol.md](docs/protocol.md).
|
||||
|
||||
## 1.0.0 — 2026-10-05
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ ifdef C4F_PROBE
|
||||
OBJDIR := build/probe
|
||||
endif
|
||||
|
||||
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o
|
||||
OBJECTS := $(SOURCES:src/%.c=$(OBJDIR)/%.o) $(OBJDIR)/client.o $(OBJDIR)/assets.o $(OBJDIR)/qrcodegen.o
|
||||
|
||||
CFLAGS += -std=gnu11 -Wall -Wextra -Wpointer-arith -g -O2
|
||||
CFLAGS += -MMD -MP
|
||||
CFLAGS += -Iinclude -Ivendor/jsmn -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
|
||||
CFLAGS += -Iinclude -Ivendor/jsmn -Ivendor/qrcodegen -DC4F_VERSION='"$(VERSION)"' $(EXTRA_CFLAGS)
|
||||
LDFLAGS += -lScePad -lSceUserService -ldl -lpthread
|
||||
|
||||
.PHONY: all clean
|
||||
@@ -54,6 +54,10 @@ $(OBJDIR)/%.o: src/%.c VERSION | $(OBJDIR)
|
||||
build/client.c: client/index.html tools/embed_client.py | build
|
||||
python3 tools/embed_client.py $< $@
|
||||
|
||||
# The page's Invite QR code (MIT, the same copy the app uses).
|
||||
$(OBJDIR)/qrcodegen.o: vendor/qrcodegen/qrcodegen.c | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
$(OBJDIR)/client.o: build/client.c | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ sees a real controller, so it works wherever a DualShock does:
|
||||
## Features
|
||||
|
||||
- **Up to four controllers**, from as many phones, tablets or computers as you like.
|
||||
- **See who's playing.** Each controller shows the name of the PS4 user signed in on it.
|
||||
- **Invite friends with a QR code.** One button on the page shows its address as a QR code: friends scan it
|
||||
and they're in.
|
||||
- **A full DualShock 4 on the screen**: analog sticks, a two-finger touchpad, L3/R3, PS, Share and Options.
|
||||
Move, resize or hide any button with the layout editor; each device keeps its own layout.
|
||||
- **The gamepads you already have.** Connect one to the phone or PC and give it a controller. One computer
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ can reach your PS4 over the network can:
|
||||
|
||||
- take a free controller and play, on the home screen, at sign-in and in games;
|
||||
- press PS and Share, which reach the system menus;
|
||||
- stop Control4Free, which disconnects every controller.
|
||||
- stop Control4Free, which disconnects every controller;
|
||||
- see the names of the users signed in on the controllers.
|
||||
|
||||
They cannot sign in as one of your users without someone at the PS4 choosing that
|
||||
user on the PS4's own screen, and they cannot see your screen.
|
||||
|
||||
+86
-6
@@ -924,6 +924,18 @@
|
||||
.options-menu .actions { display: grid; gap: 10px; margin-top: 16px; }
|
||||
.menu-close { position: absolute; top: max(14px, env(safe-area-inset-top)); right: 14px; }
|
||||
|
||||
/* Invite: the page's address as a QR code, in a panel opened on demand */
|
||||
.invite-qr {
|
||||
width: min(280px, 100%);
|
||||
aspect-ratio: 1;
|
||||
margin: 20px auto 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.invite-qr svg { display: block; width: 100%; height: 100%; }
|
||||
.invite-address { text-align: center; font-size: 18px; letter-spacing: .02em; word-break: break-all; user-select: all; }
|
||||
|
||||
/* ---------- Notification ---------- */
|
||||
|
||||
.toast {
|
||||
@@ -987,6 +999,9 @@
|
||||
<span class="conn" id="conn" data-conn="connecting" role="status" aria-live="polite"><span class="dot"></span><span id="connText">Connecting</span><span class="conn-detail" id="connDetail"></span></span>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<button class="icon-btn" type="button" id="openInvite" aria-label="Invite someone" title="Invite someone">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="14" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="3.5" y="14" width="6.5" height="6.5" rx="1.2"/><path d="M14 14h3v3M20.5 14v.01M14 20.5h3M20.5 17.5v3"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" type="button" id="openSettings" aria-label="Settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||||
</button>
|
||||
@@ -1146,12 +1161,23 @@
|
||||
<div class="rows" id="menuRows"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" id="menuLayout">Edit the button layout</button>
|
||||
<button class="btn" type="button" id="menuInvite">Invite someone</button>
|
||||
<button class="btn" type="button" id="menuSettings">All settings</button>
|
||||
<button class="btn danger" type="button" id="leaveBtn">Disconnect controller</button>
|
||||
<button class="btn danger" type="button" id="stopBtn">Stop Control4Free</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="options-menu" id="inviteSheet" aria-label="Invite someone">
|
||||
<button class="icon-btn menu-close" type="button" id="closeInvite" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
<h2>Invite someone</h2>
|
||||
<p class="hint">Scan this with a phone's camera to open the controller page. The phone has to be on the same network as the PS4.</p>
|
||||
<div class="invite-qr" id="inviteQr"></div>
|
||||
<p class="invite-address" id="inviteAddress"></p>
|
||||
</aside>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite">
|
||||
<svg viewBox="56 38 888 566" aria-hidden="true"><path fill="#fff" d="M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z"/></svg>
|
||||
<span id="toastText"></span>
|
||||
@@ -1239,7 +1265,7 @@
|
||||
|
||||
const status = Array.from({ length: PADS }, (_, i) => ({
|
||||
pad: i, open: false, enabled: true, real: false, logged: false, joined: false, connected: false,
|
||||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1),
|
||||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1), user: '',
|
||||
color: FALLBACK_COLORS[i], clients: 0, known: false,
|
||||
}));
|
||||
|
||||
@@ -2336,7 +2362,8 @@
|
||||
head.appendChild(el('span', 'activity'));
|
||||
tile.appendChild(head);
|
||||
const body = el('span');
|
||||
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Controller ' + (i + 1)));
|
||||
// Who's signed in on it, once the PS4 says; the ring keeps the number.
|
||||
body.appendChild(el('div', 'tile-name', s.known && s.user ? s.user : 'Controller ' + (i + 1)));
|
||||
body.appendChild(el('div', 'tile-meta', padMeta(s) || ''));
|
||||
tile.appendChild(body);
|
||||
const states = el('span', 'tile-state');
|
||||
@@ -2400,7 +2427,7 @@
|
||||
b.setAttribute('aria-checked', String(src.pad === pad));
|
||||
if (pad >= 0) {
|
||||
b.style.setProperty('--pc', css(status[pad].color));
|
||||
b.title = 'Controller ' + (pad + 1) + ' · ' + status[pad].name;
|
||||
b.title = 'Controller ' + (pad + 1) + (status[pad].user ? ' · ' + status[pad].user : '');
|
||||
}
|
||||
b.addEventListener('click', () => setGamepadPad(index, pad));
|
||||
seg.appendChild(b);
|
||||
@@ -2427,9 +2454,8 @@
|
||||
const s = status[primaryPad];
|
||||
document.documentElement.style.setProperty('--accent', css(s.color));
|
||||
const label = $('#padLabel');
|
||||
// The console's name for it, when it says more than the number does.
|
||||
const title = 'Controller ' + (primaryPad + 1);
|
||||
label.textContent = title + (s.known && s.name && s.name !== title ? ' · ' + s.name : '');
|
||||
// And who's signed in on it, once the PS4 says.
|
||||
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.user ? ' · ' + s.user : '');
|
||||
const state = padState(s);
|
||||
const extra = conn.state !== 'open' ? $('#connText').textContent + $('#connDetail').textContent
|
||||
: state === 'ready' ? 'Signed in on the PS4'
|
||||
@@ -2573,10 +2599,64 @@
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
$('#inviteSheet').classList.remove('open');
|
||||
document.body.classList.remove('menu-open');
|
||||
$('#optionsMenu').classList.remove('open');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- invite
|
||||
|
||||
// The console sends the QR code as a grid, one hex string per row, so the page
|
||||
// needs no QR library: each run of dark modules becomes one rectangle.
|
||||
function qrPath(size, rows, quiet) {
|
||||
const dark = (row, x) => x < size && ((parseInt(row[x >> 2], 16) >> (3 - (x & 3))) & 1) === 1;
|
||||
let d = '';
|
||||
for (let y = 0; y < size; y++) {
|
||||
const row = rows[y] || '';
|
||||
for (let x = 0; x < size;) {
|
||||
if (!dark(row, x)) { x++; continue; }
|
||||
let end = x;
|
||||
while (dark(row, end)) end++;
|
||||
d += 'M' + (x + quiet) + ' ' + (y + quiet) + 'h' + (end - x) + 'v1h-' + (end - x) + 'z';
|
||||
x = end;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// The address this page reached the console on, which is the one that works
|
||||
// from this network; the console falls back to its own when there's none.
|
||||
function inviteParams() {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?::(\d{1,5}))?$/.exec(conn.address || '');
|
||||
return m ? [+m[1], +m[2], +m[3], +m[4], +(m[5] || 4264)] : [];
|
||||
}
|
||||
|
||||
function openInvite() {
|
||||
closeMenu();
|
||||
document.body.classList.add('menu-open');
|
||||
$('#inviteSheet').classList.add('open');
|
||||
const box = $('#inviteQr'), address = $('#inviteAddress');
|
||||
box.textContent = '';
|
||||
address.textContent = '';
|
||||
call('invite', inviteParams()).then(r => {
|
||||
const quiet = 4, n = r.size + quiet * 2, ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 ' + n + ' ' + n);
|
||||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||
svg.setAttribute('role', 'img');
|
||||
svg.setAttribute('aria-label', 'QR code for ' + r.text);
|
||||
const path = document.createElementNS(ns, 'path');
|
||||
path.setAttribute('d', qrPath(r.size, r.rows, quiet));
|
||||
path.setAttribute('fill', '#000');
|
||||
svg.appendChild(path);
|
||||
box.appendChild(svg);
|
||||
address.textContent = r.text.replace(/\/$/, '');
|
||||
}).catch(e => { address.textContent = e.message || 'Couldn\'t make the QR code'; });
|
||||
}
|
||||
|
||||
$('#openInvite').addEventListener('click', openInvite);
|
||||
$('#menuInvite').addEventListener('click', openInvite);
|
||||
$('#closeInvite').addEventListener('click', closeMenu);
|
||||
$('#padMenuBtn').addEventListener('click', () => { if (!editor.active) openMenu(); });
|
||||
$('#menuLayout').addEventListener('click', () => { closeMenu(); editor.open(); });
|
||||
$('#closeOptions').addEventListener('click', closeMenu);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 537 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
+7
-1
@@ -18,10 +18,16 @@ The home screen shows all four controllers live:
|
||||
| **Free** | Nobody is using it. Pick it to play. |
|
||||
| **Connecting** | The PS4 is setting it up. Takes a moment. |
|
||||
| **Choose user on PS4** | Waiting for someone to pick a user on the PS4's screen. |
|
||||
| **Connected** | Signed in and ready. |
|
||||
| **Connected** | Signed in and ready. The tile shows the user's name. |
|
||||
| **Paused** | Its phone stopped sending input, for example because the screen locked. |
|
||||
| **This device** / **+1 device** | Who's driving it: this browser, or other ones. |
|
||||
|
||||
## Inviting someone
|
||||
|
||||
Press the **QR code button** at the top of the home screen, or **☰ → Invite someone** on the controller
|
||||
screen. The page shows its own address as a QR code: a friend scans it with their phone's camera and gets the
|
||||
controller page, as long as they're on the same network as the PS4. Tap outside the panel to close it.
|
||||
|
||||
## Touch controls
|
||||
|
||||
The controller screen is a DualShock 4 laid out for your screen, best held sideways:
|
||||
|
||||
+7
-1
@@ -47,6 +47,7 @@ Controllers are numbered 0 to 3 in the protocol and 1 to 4 on screen.
|
||||
| `leave` | `[pad]` | A status object. |
|
||||
| `stop` | `[]` | None; the service stops and the connection closes. |
|
||||
| `ping` | `[]` | `{}` |
|
||||
| `invite` | `[a, b, c, d, port]`, the address the page reached, or `[]` | `{"text": "http://192.168.1.20:4264/", "size": 29, "rows": […]}` |
|
||||
|
||||
**`claim`** is the whole set this connection wants: controllers missing from it that it owned are removed,
|
||||
and free ones in it are created. A claim that needs a new controller is answered when the controller exists,
|
||||
@@ -89,6 +90,10 @@ a connection claims them again; the page does so when it reconnects.
|
||||
|
||||
**`stop`** is refused with `409` while another connection owns a controller.
|
||||
|
||||
**`invite`** returns a QR code of the page's address: `[]` uses the console's own address. `rows` holds one
|
||||
hex string per row of modules, most significant bit first, `size` modules wide; add a light border of 4
|
||||
modules around it when you draw it.
|
||||
|
||||
### The status object
|
||||
|
||||
```json
|
||||
@@ -96,7 +101,7 @@ a connection claims them again; the page does so when it reconnects.
|
||||
"version": "1.1.0",
|
||||
"protocol": 2,
|
||||
"pads": [
|
||||
{"pad": 0, "name": "Controller 1", "enabled": true, "open": true, "connected": true, "clients": 1,
|
||||
{"pad": 0, "name": "Controller 1", "user": "Alex", "enabled": true, "open": true, "connected": true, "clients": 1,
|
||||
"mine": true, "state": "ready", "uid": "1a2b3c4d", "color": [32, 96, 255], "reports": 5120, "error": 0}
|
||||
]
|
||||
}
|
||||
@@ -104,6 +109,7 @@ a connection claims them again; the page does so when it reconnects.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `user` | The name of the PS4 user signed in on it, or `""` (not signed in, or not known yet). |
|
||||
| `open` | The virtual controller exists. |
|
||||
| `connected` | A connection owns it and is sending input. |
|
||||
| `clients` | 1 if a connection owns it, else 0. |
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
/* ---- libSceUserService ---- */
|
||||
int32_t sceUserServiceInitialize(void *params);
|
||||
/* The user's name as the PS4 shows it: an online ID, or a local account's name. */
|
||||
int32_t sceUserServiceGetUserName(int32_t userId, char *name, size_t size);
|
||||
|
||||
#define C4F_USER_ID_INVALID (-1)
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ void c4fPadFeedbackParse(const uint8_t *buf, size_t size, C4fPadFeedback *out);
|
||||
* Returns 0, the call's error code, or -1 if the call isn't exported. */
|
||||
int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out);
|
||||
|
||||
/* The PS4's name for a user, as it shows it. Returns 0, or the call's error.
|
||||
* Not written to the log: people post logs in bug reports. */
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size);
|
||||
|
||||
/* A neutral sample: sticks centred, identity quaternion, connected. */
|
||||
void c4fPadDataNeutral(ScePadData *data);
|
||||
|
||||
|
||||
@@ -159,6 +159,17 @@ int32_t c4fVirtualPadFeedback(const C4fVirtualPad *pad, C4fPadFeedback *out)
|
||||
return ret;
|
||||
}
|
||||
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
|
||||
{
|
||||
char name[64]; /* the system's own limit is 16 characters */
|
||||
|
||||
if (size) out[0] = 0;
|
||||
(void)memset(name, 0, sizeof(name));
|
||||
int32_t ret = sceUserServiceGetUserName((int32_t)userId, name, sizeof(name) - 1);
|
||||
if (ret == 0) snprintf(out, size, "%s", name);
|
||||
return ret;
|
||||
}
|
||||
|
||||
void c4fPadDataNeutral(ScePadData *data)
|
||||
{
|
||||
(void)memset(data, 0, sizeof(*data));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#define JSMN_STATIC
|
||||
#define JSMN_STRICT
|
||||
#include "jsmn.h"
|
||||
#include "qrcodegen.h"
|
||||
#include "c4f_log.h"
|
||||
#include "c4f_net.h"
|
||||
#include "c4f_vda.h"
|
||||
@@ -47,6 +48,10 @@ typedef struct {
|
||||
int feedbackKnown, rumbleSentValid, feedbackFailed;
|
||||
uint8_t rumbleSent[2];
|
||||
uint64_t feedbackAt;
|
||||
/* The signed-in user's name, once the PS4 will tell it. */
|
||||
char userName[72];
|
||||
int nameTries;
|
||||
uint64_t nameAt;
|
||||
} C4fWebPad;
|
||||
|
||||
/* Creating a controller means issuing AddDevice and then finding its DeviceId in
|
||||
@@ -155,9 +160,34 @@ static void c4fError(C4fNetClient *c, const C4fRequest *r, int code, const char
|
||||
c4fNetText(c, reply);
|
||||
}
|
||||
|
||||
/* `in` as the inside of a JSON string. Quotes and backslashes are escaped, and
|
||||
* control characters and anything that isn't valid UTF-8 become '?'. A user
|
||||
* name is the one string here we don't write ourselves, and a browser drops the
|
||||
* whole WebSocket on a text frame that isn't valid UTF-8. */
|
||||
static void c4fJsonText(char *out, size_t size, const char *in)
|
||||
{
|
||||
const unsigned char *p = (const unsigned char *)in;
|
||||
size_t o = 0;
|
||||
|
||||
if (!size) return;
|
||||
while (*p && o + 7 < size) {
|
||||
unsigned c = *p;
|
||||
unsigned n = c < 0x80 ? 1 : (c & 0xe0) == 0xc0 ? 2 : (c & 0xf0) == 0xe0 ? 3 : (c & 0xf8) == 0xf0 ? 4 : 0;
|
||||
int valid = n > 0 && !(n == 2 && c < 0xc2) && c <= 0xf4;
|
||||
for (unsigned k = 1; valid && k < n; k++) valid = (p[k] & 0xc0) == 0x80;
|
||||
/* No overlong forms, no UTF-16 surrogates, nothing past U+10FFFF. */
|
||||
if (valid && n == 3 && ((c == 0xe0 && p[1] < 0xa0) || (c == 0xed && p[1] >= 0xa0))) valid = 0;
|
||||
if (valid && n == 4 && ((c == 0xf0 && p[1] < 0x90) || (c == 0xf4 && p[1] >= 0x90))) valid = 0;
|
||||
if (!valid || (n == 1 && (c < 0x20 || c == 0x7f))) { out[o++] = '?'; p++; continue; }
|
||||
if (c == '"' || c == '\\') out[o++] = '\\';
|
||||
memcpy(out + o, p, n); o += n; p += n;
|
||||
}
|
||||
out[o] = 0;
|
||||
}
|
||||
|
||||
static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
|
||||
{
|
||||
char json[3072];
|
||||
char json[4096];
|
||||
size_t pos;
|
||||
if (request && request->hasId) pos = (size_t)snprintf(json, sizeof(json), "{\"id\":%lld,\"result\":", (long long)request->id);
|
||||
else pos = (size_t)snprintf(json, sizeof(json), "{\"method\":\"s\",\"params\":");
|
||||
@@ -177,9 +207,11 @@ static void c4fStatus(C4fWeb *app, C4fNetClient *c, const C4fRequest *request)
|
||||
if (l[2] > top) top = l[2];
|
||||
if (top) for (int k = 0; k < 3; k++) rgb[k] = l[k] * 255 / top;
|
||||
}
|
||||
char user[160];
|
||||
c4fJsonText(user, sizeof(user), p->active && p->assigned ? p->userName : "");
|
||||
pos += (size_t)snprintf(json+pos, sizeof(json)-pos,
|
||||
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
|
||||
i ? "," : "", i, i+1, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
|
||||
"%s{\"pad\":%d,\"name\":\"Controller %d\",\"user\":\"%s\",\"enabled\":true,\"open\":%s,\"connected\":%s,\"clients\":%d,\"mine\":%s,\"state\":\"%s\",\"uid\":\"%s%08x\",\"color\":[%u,%u,%u],\"reports\":%llu,\"error\":%d}",
|
||||
i ? "," : "", i, i+1, user, p->active ? "true" : "false", p->owner && !p->stale ? "true" : "false", p->owner ? 1 : 0,
|
||||
p->owner == c ? "true" : "false", state, p->assigned ? "" : "unassigned-", p->userId,
|
||||
rgb[0], rgb[1], rgb[2], (unsigned long long)p->reports, p->error);
|
||||
}
|
||||
@@ -251,6 +283,27 @@ static uint64_t c4fTimeUs(void)
|
||||
return (uint64_t)t.tv_sec * 1000000 + (uint64_t)t.tv_nsec / 1000;
|
||||
}
|
||||
|
||||
/* The names of signed-in users. The PS4 may not have finished signing a user in
|
||||
* when it reports the controller assigned, so a failed look-up is retried for a
|
||||
* few seconds. Names are kept out of the log. */
|
||||
static void c4fLookUpNames(C4fWeb *app, uint64_t now)
|
||||
{
|
||||
for (int i = 0; i < C4F_MAX_PADS; i++) {
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (!p->active || !p->assigned || p->userName[0] || p->nameTries >= 10 || now < p->nameAt) continue;
|
||||
p->nameTries++;
|
||||
p->nameAt = now + 500;
|
||||
int32_t ret = c4fUserName(p->userId, p->userName, sizeof(p->userName));
|
||||
if (ret == 0 && p->userName[0]) {
|
||||
c4fLog("web controller %d user name found (%u bytes)\n", i + 1, (unsigned)strlen(p->userName));
|
||||
app->changed = 1;
|
||||
} else if (p->nameTries == 10) {
|
||||
p->userName[0] = 0;
|
||||
c4fLog("web controller %d user name unavailable (0x%08x)\n", i + 1, (uint32_t)ret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reads back what the game wants from each controller. Rumble goes to the
|
||||
* controller's owner, whose page drives the phone's vibration and any gamepad
|
||||
* playing as it; the light bar goes into the status everyone sees. */
|
||||
@@ -392,6 +445,7 @@ static void c4fKlogLine(C4fWeb *app, const char *line)
|
||||
C4fWebPad *p = &app->pads[i];
|
||||
if (p->active && p->device.deviceId == device) {
|
||||
p->assigned = user != 0xffffffffu; p->userId = user; app->changed = 1;
|
||||
p->userName[0] = 0; p->nameTries = 0; p->nameAt = 0;
|
||||
c4fLog("web controller %d assignment confirmed=%d\n", i+1, p->assigned);
|
||||
}
|
||||
}
|
||||
@@ -611,6 +665,51 @@ static void c4fClaim(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
|
||||
app->changed = 1;
|
||||
}
|
||||
|
||||
/* A QR code of the page's address, for the page's Invite panel. The page names
|
||||
* the address it reached us on, as [a, b, c, d, port], because that's the one
|
||||
* that works from this network; without it, the console's own address is used.
|
||||
* The answer is the module grid, one hex string per row, most significant bit
|
||||
* first: the page draws it, so it needs no QR library of its own. */
|
||||
static void c4fInvite(C4fNetClient *c, const C4fRequest *r)
|
||||
{
|
||||
char text[64], address[64], reply[1024];
|
||||
uint8_t qr[qrcodegen_BUFFER_LEN_FOR_VERSION(5)], scratch[qrcodegen_BUFFER_LEN_FOR_VERSION(5)];
|
||||
size_t pos;
|
||||
|
||||
if (r->argc == 5) {
|
||||
for (int i = 0; i < 4; i++)
|
||||
if (r->args[i] < 0 || r->args[i] > 255) { c4fError(c, r, 400, "That isn't an IPv4 address"); return; }
|
||||
if (r->args[4] < 1 || r->args[4] > 65535) { c4fError(c, r, 400, "That isn't a port"); return; }
|
||||
snprintf(text, sizeof(text), "http://%d.%d.%d.%d:%d/", (int)r->args[0], (int)r->args[1],
|
||||
(int)r->args[2], (int)r->args[3], (int)r->args[4]);
|
||||
} else if (r->argc == 0) {
|
||||
c4fNetLocalAddress(address, sizeof(address));
|
||||
if (!address[0]) { c4fError(c, r, 503, "Couldn't work out the console's address"); return; }
|
||||
snprintf(text, sizeof(text), "http://%s:%d/", address, C4F_WEB_PORT);
|
||||
} else {
|
||||
c4fError(c, r, 400, "Invalid request"); return;
|
||||
}
|
||||
if (!qrcodegen_encodeText(text, scratch, qr, qrcodegen_Ecc_MEDIUM, 1, 5, qrcodegen_Mask_AUTO, true)) {
|
||||
c4fError(c, r, 500, "Couldn't make the QR code"); return;
|
||||
}
|
||||
int size = qrcodegen_getSize(qr);
|
||||
pos = (size_t)snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{\"text\":\"%s\",\"size\":%d,\"rows\":[",
|
||||
(long long)r->id, text, size);
|
||||
for (int y = 0; y < size && pos < sizeof(reply) - 16; y++) {
|
||||
reply[pos++] = y ? ',' : '"';
|
||||
if (y) reply[pos++] = '"';
|
||||
for (int x = 0; x < size; x += 4) {
|
||||
unsigned nibble = 0;
|
||||
for (int b = 0; b < 4; b++)
|
||||
nibble = nibble << 1 | (x + b < size && qrcodegen_getModule(qr, x + b, y));
|
||||
reply[pos++] = "0123456789abcdef"[nibble];
|
||||
}
|
||||
reply[pos++] = '"';
|
||||
}
|
||||
snprintf(reply + pos, sizeof(reply) - pos, "]}}");
|
||||
c4fNetText(c, reply);
|
||||
}
|
||||
|
||||
static void c4fUpdate(C4fWeb *app, C4fNetClient *c, C4fRequest *r)
|
||||
{
|
||||
if (r->argc < 9 || r->args[0] < 0 || r->args[0] >= C4F_MAX_PADS) { c4fError(c, r, 400, "That input was not in a form Control4Free understands"); return; }
|
||||
@@ -672,6 +771,8 @@ static void c4fWebEvent(C4fNetClient *c, int event, const char *text, size_t len
|
||||
} else if (!strcmp(r.method, "ping")) {
|
||||
char reply[64];
|
||||
if (r.hasId) { snprintf(reply, sizeof(reply), "{\"id\":%lld,\"result\":{}}", (long long)r.id); c4fNetText(c, reply); }
|
||||
} else if (!strcmp(r.method, "invite")) {
|
||||
if (r.hasId) c4fInvite(c, &r);
|
||||
} else if (!strcmp(r.method, "status")) c4fStatus(app, c, &r);
|
||||
else if (!strcmp(r.method, "claim")) c4fClaim(app, c, &r);
|
||||
else if (!strcmp(r.method, "u")) c4fUpdate(app, c, &r);
|
||||
@@ -738,6 +839,7 @@ int c4fWebRun(int klogFd)
|
||||
long klogBytes = c4fReadKlog(app);
|
||||
c4fReportPads(app);
|
||||
c4fPollFeedback(app, now);
|
||||
c4fLookUpNames(app, now);
|
||||
#ifdef C4F_PROBE_SETTING
|
||||
c4fProbeSetting(app, now);
|
||||
#endif
|
||||
|
||||
@@ -123,4 +123,14 @@ function padStatus(fields) {
|
||||
assert.equal(can({}), false, 'Firefox');
|
||||
assert.equal(can({ vibrationActuator: { playEffect: play, effects: [] } }), false);
|
||||
console.log('PASS gamepads that cannot rumble are named as such');
|
||||
|
||||
// The Invite panel draws the console's grid as runs of dark modules.
|
||||
const qrCtx = {};
|
||||
vm.createContext(qrCtx);
|
||||
vm.runInContext(extract(' function qrPath(size, rows, quiet)', ' // The address this page reached'), qrCtx);
|
||||
// 5 wide: row 0 = 11011, row 1 = 00100 -> hex d8, 20.
|
||||
assert.equal(vm.runInContext("qrPath(5, ['d8', '20'], 4)", qrCtx),
|
||||
'M4 4h2v1h-2zM7 4h2v1h-2zM6 5h1v1h-1z');
|
||||
assert.equal(vm.runInContext("qrPath(3, ['0', '0', '0'], 4)", qrCtx), '');
|
||||
console.log('PASS the invite QR is drawn from the console grid');
|
||||
})();
|
||||
@@ -21,7 +21,7 @@ def connect_again():
|
||||
|
||||
def main():
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', 'src/net.c', 'src/web.c', 'src/klog_line.c',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', 'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c',
|
||||
'tests/web_stub.c', 'tests/recovery_faults.c', 'build/client.c', 'build/assets.c', '-pthread',
|
||||
'-Wl,--wrap=select,--wrap=accept,--wrap=getsockopt', '-o', str(web.BINARY)], check=True)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
+43
-2
@@ -21,8 +21,8 @@ def build():
|
||||
subprocess.run(['python3', 'tools/embed_file.py', 'build/assets.c',
|
||||
'c4fManifest=client/manifest.webmanifest', 'c4fIcon=client/icon-192.png'], check=True)
|
||||
subprocess.run(['clang-18', '-std=gnu11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
|
||||
'src/net.c', 'src/web.c', 'src/klog_line.c', 'tests/web_stub.c', 'build/client.c',
|
||||
'-Iinclude', '-Ivendor/jsmn', '-Ivendor/qrcodegen', '-DC4F_STALE_MS=400', '-DC4F_RELEASE_MS=1800',
|
||||
'src/net.c', 'src/web.c', 'src/klog_line.c', 'vendor/qrcodegen/qrcodegen.c', 'tests/web_stub.c', 'build/client.c',
|
||||
'build/assets.c', '-pthread', '-o', str(BINARY)], check=True)
|
||||
|
||||
|
||||
@@ -420,6 +420,47 @@ def main():
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Who's signed in on each controller, made safe for JSON and UTF-8, kept out of the log.
|
||||
server = Server()
|
||||
try:
|
||||
c = server.c
|
||||
assert c.request('claim', [0, 1])['result']['pads'][1]['mine']
|
||||
assert c.request('status')['result']['pads'][0]['user'] == ''
|
||||
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x11030d][UserId:0x1a2b3c4d]\n')
|
||||
os.write(server.log, b'<118>#LOGIN MGR# Receive Event : SCE_MBUS_EVENT_DEVICE_OWNER_CHANGED [DeviceId:0x12030d][UserId:0x1a2b3c4e]\n')
|
||||
time.sleep(1.2) # Sam's name only comes on the retry, half a second later
|
||||
pads = c.request('status')['result']['pads']
|
||||
assert pads[0]['user'] == 'Alex', pads[0]
|
||||
assert pads[1]['user'] == 'Sam "S" \\ ? ? \u00e9', repr(pads[1]['user'])
|
||||
assert pads[2]['user'] == '' and pads[3]['user'] == ''
|
||||
assert not any('Alex' in r or 'Sam' in r for r in server.rows()), 'a name reached the log'
|
||||
assert any('user name found (4 bytes)' in r for r in server.rows())
|
||||
print('PASS signed-in names shown, made safe for JSON and UTF-8, kept out of the log', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# The Invite panel's QR code: a real QR code of the address the page names.
|
||||
server = Server()
|
||||
try:
|
||||
r = server.c.request('invite', [192, 168, 1, 20, 4264])['result']
|
||||
assert r['text'] == 'http://192.168.1.20:4264/', r
|
||||
size = r['size']
|
||||
assert size in (21, 25, 29, 33, 37) and len(r['rows']) == size
|
||||
bits = [[int(row[x >> 2], 16) >> (3 - (x & 3)) & 1 for x in range(size)] for row in r['rows']]
|
||||
assert all(len(row) == (size + 3) // 4 for row in r['rows'])
|
||||
# The three finder squares: a dark ring, a light ring, a dark 3x3 core.
|
||||
finder = [[1,1,1,1,1,1,1], [1,0,0,0,0,0,1], [1,0,1,1,1,0,1], [1,0,1,1,1,0,1],
|
||||
[1,0,1,1,1,0,1], [1,0,0,0,0,0,1], [1,1,1,1,1,1,1]]
|
||||
for ox, oy in ((0, 0), (size - 7, 0), (0, size - 7)):
|
||||
assert [bits[oy + y][ox:ox + 7] for y in range(7)] == finder, (ox, oy)
|
||||
assert server.c.request('invite', [300, 1, 1, 1, 4264])['error']['code'] == 400
|
||||
assert server.c.request('invite', [192, 168, 1, 20, 0])['error']['code'] == 400
|
||||
reply = server.c.request('invite', []) # the console's own address, when it has a route
|
||||
assert reply.get('result', {}).get('text', 'http://').startswith('http://') or reply['error']['code'] == 503
|
||||
print('PASS invite: a real QR code of the page address, bad addresses refused', flush=True)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# Two claims cannot create at once: the second is refused, not queued.
|
||||
server = Server(C4F_TEST_ADD_DELAY='600')
|
||||
try:
|
||||
|
||||
@@ -189,6 +189,21 @@ void c4fVirtualPadAdopt(C4fVirtualPad *p, int32_t user, int32_t vdaUser, uint64_
|
||||
printf("ADOPT %x %d\n", (unsigned)p->handle, vdaUser);
|
||||
}
|
||||
|
||||
/* Alex, and a name built to break JSON and UTF-8 that only resolves on a retry,
|
||||
* as a user still signing in does. */
|
||||
int32_t c4fUserName(uint32_t userId, char *out, size_t size)
|
||||
{
|
||||
static int samTries;
|
||||
if (size) out[0] = 0;
|
||||
if (userId == 0x1a2b3c4d) { snprintf(out, size, "Alex"); return 0; }
|
||||
if (userId == 0x1a2b3c4e) {
|
||||
if (++samTries < 2) return (int32_t)0x80960009;
|
||||
snprintf(out, size, "Sam \"S\" \\ \x01 \xff \xc3\xa9");
|
||||
return 0;
|
||||
}
|
||||
return (int32_t)0x80960009;
|
||||
}
|
||||
|
||||
void c4fVirtualPadRemove(C4fVirtualPad *p) { printf("REMOVE %x\n", p->handle); p->owned = 0; }
|
||||
void c4fPadDataNeutral(ScePadData *p)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user