fix(cursor): correct Xcursor binary format (was missing version field)

Previous generator packed 5 fields in the image chunk header but Xcursor
format needs 9 (header_size, type, nominal, version, w, h, xhot, yhot,
delay). Missing version field → malformed → wlroots ignored it → fell
back to default visible cursor. Now writes correct 68-byte Xcursor with
all 9 header fields. Added more cursor names (x_cursor, pirate, sides).

Also: terminal UI shows bash-style cwd$ prompt, separates command from
output visually, auto-detects pwd after each command for prompt update.
This commit is contained in:
Mitchell R 2026-05-23 00:22:28 +02:00
parent ee980509c7
commit 70bdc3bb8b
No known key found for this signature in database
2 changed files with 101 additions and 58 deletions

View file

@ -106,47 +106,29 @@ plymouth-set-default-theme betterframe || true
CURSOR_DIR=/usr/share/icons/betterframe-empty/cursors CURSOR_DIR=/usr/share/icons/betterframe-empty/cursors
install -d -m 755 "$CURSOR_DIR" install -d -m 755 "$CURSOR_DIR"
install -m 644 /tmp/bf-files/cursor.theme /usr/share/icons/betterframe-empty/cursor.theme install -m 644 /tmp/bf-files/cursor.theme /usr/share/icons/betterframe-empty/cursor.theme
# Generate a 1x1 transparent X cursor for every standard cursor name. # Generate valid 1x1 transparent Xcursor files. Previous generator had a
# xcursorgen reads a config file and produces the binary Xcursor format. # missing version field → malformed → wlroots fell back to default cursor.
# If xcursorgen is available (from x11-apps), use it; otherwise create # Xcursor format: file header (16) + TOC entry (12) + image chunk (36+4=40) = 68 bytes.
# a minimal raw Xcursor binary (header + 1px transparent image). python3 -c "
printf '1 0 0 /tmp/bf-transparent.png\n' > /tmp/bf-cursor.cfg import struct, os
convert -size 1x1 xc:transparent /tmp/bf-transparent.png 2>/dev/null \ # File header: magic + header_size + version(1.0) + ntoc
|| python3 -c " hdr = b'Xcur' + struct.pack('<III', 16, 0x00010000, 1)
import struct,sys # TOC: type(image=0xfffd0002) + subtype(nominal=1) + position(28)
# Minimal 1x1 RGBA PNG toc = struct.pack('<III', 0xfffd0002, 1, 28)
sys.stdout.buffer.write(b'\\x89PNG\\r\\n\\x1a\\n' + bytes([ # Image chunk header (36 bytes): header_size + type + nominal + version + w + h + xhot + yhot + delay
0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137, img = struct.pack('<IIIIIIIII', 36, 0xfffd0002, 1, 1, 1, 1, 0, 0, 0)
0,0,0,10,73,68,65,84,120,156,98,0,0,0,2,0,1,226,33,188,51, # 1 ARGB pixel, fully transparent
0,0,0,0,73,69,78,68,174,66,96,130])) px = struct.pack('<I', 0)
" > /tmp/bf-transparent.png data = hdr + toc + img + px
if command -v xcursorgen >/dev/null 2>&1; then
for name in default left_ptr arrow watch hand2 text xterm top_left_corner \
top_right_corner bottom_left_corner bottom_right_corner sb_h_double_arrow \
sb_v_double_arrow fleur crosshair question_arrow; do
xcursorgen /tmp/bf-cursor.cfg "$CURSOR_DIR/$name" 2>/dev/null || true
done
else
# Minimal Xcursor binary: magic + header + 1x1 ARGB image (4 bytes, all 0)
python3 -c "
import struct, sys, os
magic = b'Xcur'
header = struct.pack('<III', 16, 1, 1) # header_size, version, ntoc
toc = struct.pack('<III', 0xfffd0002, 36, 28) # type=image, subtype=1, position
chunk_header = struct.pack('<IIIII', 36, 0xfffd0002, 1, 1, 1) # size,type,subtype(nominal),width,height
xhot_yhot = struct.pack('<II', 0, 0)
delay = struct.pack('<I', 0)
pixel = struct.pack('<I', 0) # 1 ARGB pixel, fully transparent
data = magic + header + toc + chunk_header + xhot_yhot + delay + pixel
for name in ['default','left_ptr','arrow','watch','hand2','text','xterm', for name in ['default','left_ptr','arrow','watch','hand2','text','xterm',
'top_left_corner','top_right_corner','bottom_left_corner', 'top_left_corner','top_right_corner','bottom_left_corner',
'bottom_right_corner','sb_h_double_arrow','sb_v_double_arrow', 'bottom_right_corner','sb_h_double_arrow','sb_v_double_arrow',
'fleur','crosshair','question_arrow']: 'fleur','crosshair','question_arrow','x_cursor','pirate',
path = os.path.join('$CURSOR_DIR', name) 'sb_left_arrow','sb_right_arrow','sb_up_arrow','sb_down_arrow',
with open(path, 'wb') as f: f.write(data) 'top_side','bottom_side','left_side','right_side']:
" 2>/dev/null || true with open(os.path.join('$CURSOR_DIR', name), 'wb') as f:
fi f.write(data)
rm -f /tmp/bf-cursor.cfg /tmp/bf-transparent.png "
# Set as system default cursor theme # Set as system default cursor theme
update-alternatives --install /usr/share/icons/default/index.theme x-cursor-theme \ update-alternatives --install /usr/share/icons/default/index.theme x-cursor-theme \
/usr/share/icons/betterframe-empty/cursor.theme 100 2>/dev/null || true /usr/share/icons/betterframe-empty/cursor.theme 100 2>/dev/null || true

View file

@ -1770,45 +1770,98 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// WS auth: browser sends session cookie automatically on WS upgrade. // WS auth: browser sends session cookie automatically on WS upgrade.
// Coordinator WS endpoint validates via resolveSession. // Coordinator WS endpoint validates via resolveSession.
return htmlPage(`<html><head><title>Terminal: ${kiosk.name}</title> return htmlPage(`<html><head><title>Terminal: ${kiosk.name}</title>
<style>body{margin:0;background:#000;color:#fff;font-family:monospace;font-size:14px;padding:1rem} <style>
#term{white-space:pre-wrap;word-break:break-all;height:calc(100vh - 120px);overflow-y:auto;background:#111;padding:8px;border:1px solid #333} body{margin:0;background:#1a1a1a;color:#e0e0e0;font-family:'Cascadia Code','Fira Code',monospace;font-size:13px;padding:1rem}
.controls{margin-bottom:1rem;display:flex;gap:8px;align-items:center} #term{height:calc(100vh - 100px);overflow-y:auto;background:#0d0d0d;padding:12px;border:1px solid #333;border-radius:4px}
button{background:#333;color:#fff;border:1px solid #555;padding:4px 12px;cursor:pointer} .line{margin:0;white-space:pre-wrap;word-break:break-all;line-height:1.5}
input{background:#222;color:#fff;border:1px solid #555;padding:4px 8px;font-family:monospace;width:200px} .prompt{color:#5faf5f}
.status{color:#888;font-size:12px;margin-left:12px} .cmd{color:#fff}
.output{color:#b0b0b0}
.controls{margin-bottom:0.75rem;display:flex;gap:8px;align-items:center}
button{background:#333;color:#fff;border:1px solid #555;padding:4px 12px;cursor:pointer;border-radius:3px}
button:hover{background:#444}
input{background:#222;color:#fff;border:1px solid #555;padding:4px 8px;font-family:inherit;border-radius:3px}
#code-input{width:180px}
.status{color:#666;font-size:11px;margin-left:12px}
.cmd-line{display:flex;align-items:center;margin-top:4px;background:#0d0d0d;border:1px solid #333;border-radius:4px;padding:4px 8px}
.cmd-line .prompt-label{color:#5faf5f;margin-right:8px;white-space:nowrap}
.cmd-line input{flex:1;background:transparent;border:none;color:#fff;outline:none;font-family:inherit;font-size:13px;padding:2px 0}
</style></head><body> </style></head><body>
<div class="controls"> <div class="controls">
<a href="/admin/kiosks/${id}" style="color:#0af"> ${kiosk.name}</a> <a href="/admin/kiosks/${id}" style="color:#5fafff;text-decoration:none"> ${kiosk.name}</a>
<button id="btn-request">Request Terminal</button> <button id="btn-request">Request Terminal</button>
<input id="code-input" placeholder="Enter code from kiosk screen" style="display:none" /> <input id="code-input" placeholder="Enter code from screen" style="display:none" />
<button id="btn-auth" style="display:none">Authenticate</button> <button id="btn-auth" style="display:none">Auth</button>
<span class="status" id="status">Disconnected</span> <span class="status" id="status">Disconnected</span>
</div> </div>
<div id="term"></div> <div id="term"></div>
<input id="cmd-input" placeholder="Type here..." style="width:100%;background:#222;color:#fff;border:1px solid #333;padding:6px;font-family:monospace;margin-top:4px;display:none" /> <div class="cmd-line" id="cmd-row" style="display:none">
<span class="prompt-label" id="prompt-label">$</span>
<input id="cmd-input" placeholder="" autofocus />
</div>
<script> <script>
(function(){ (function(){
var term=document.getElementById('term'),status=document.getElementById('status'); var term=document.getElementById('term'),status=document.getElementById('status');
var codeInput=document.getElementById('code-input'),authBtn=document.getElementById('btn-auth'); var codeInput=document.getElementById('code-input'),authBtn=document.getElementById('btn-auth');
var cmdInput=document.getElementById('cmd-input'); var cmdInput=document.getElementById('cmd-input'),cmdRow=document.getElementById('cmd-row');
var ws; var promptLabel=document.getElementById('prompt-label');
var ws,cwd='~',outputBuf='';
function appendOutput(){
if(!outputBuf)return;
var div=document.createElement('div');
div.className='line output';
div.textContent=outputBuf;
term.appendChild(div);
outputBuf='';
term.scrollTop=term.scrollHeight;
}
function appendCmd(cmd){
appendOutput();
var div=document.createElement('div');
div.className='line';
var p=document.createElement('span');
p.className='prompt';
p.textContent=cwd+'$ ';
var c=document.createElement('span');
c.className='cmd';
c.textContent=cmd;
div.appendChild(p);
div.appendChild(c);
term.appendChild(div);
term.scrollTop=term.scrollHeight;
}
function connect(){ function connect(){
var proto=location.protocol==='https:'?'wss:':'ws:'; var proto=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(proto+'//'+location.host+'/admin/ws/debug/${id}'); ws=new WebSocket(proto+'//'+location.host+'/admin/ws/debug/${id}');
ws.onopen=function(){status.textContent='Connected (not authed)';}; ws.onopen=function(){status.textContent='Connected';};
ws.onmessage=function(e){ ws.onmessage=function(e){
try{var m=JSON.parse(e.data); try{var m=JSON.parse(e.data);
if(m.type==='terminal-challenge'){ if(m.type==='terminal-challenge'){
status.textContent='Code displayed on kiosk screen'; status.textContent='Enter code from kiosk screen';
codeInput.style.display='';authBtn.style.display=''; codeInput.style.display='';authBtn.style.display='';codeInput.focus();
}else if(m.type==='terminal-granted'){ }else if(m.type==='terminal-granted'){
status.textContent='Terminal active'; status.textContent='Terminal active';
codeInput.style.display='none';authBtn.style.display='none'; codeInput.style.display='none';authBtn.style.display='none';
cmdInput.style.display='';cmdInput.focus(); cmdRow.style.display='flex';cmdInput.focus();
// Get initial pwd
ws.send(JSON.stringify({type:'terminal-data',data:btoa('pwd\\n')}));
}else if(m.type==='terminal-denied'){ }else if(m.type==='terminal-denied'){
status.textContent='Denied: '+(m.reason||'unknown'); status.textContent='Denied: '+(m.reason||'unknown');
}else if(m.type==='terminal-data'){ }else if(m.type==='terminal-data'){
var bytes=atob(m.data);term.textContent+=bytes;term.scrollTop=term.scrollHeight; var bytes=atob(m.data);
outputBuf+=bytes;
// Try to detect pwd from output (last non-empty line after command)
var lines=outputBuf.split('\\n');
for(var i=lines.length-1;i>=0;i--){
var l=lines[i].trim();
if(l&&l.startsWith('/')){cwd=l.replace(/^\\/home\\/bfkiosk/,'~');break;}
}
// Flush to display
appendOutput();
promptLabel.textContent=cwd+'$ ';
} }
}catch{} }catch{}
}; };
@ -1821,10 +1874,18 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
authBtn.onclick=function(){ authBtn.onclick=function(){
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'terminal-auth',code:codeInput.value.toUpperCase()})); if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'terminal-auth',code:codeInput.value.toUpperCase()}));
}; };
codeInput.onkeydown=function(e){
if(e.key==='Enter')authBtn.click();
};
cmdInput.onkeydown=function(e){ cmdInput.onkeydown=function(e){
if(e.key==='Enter'){ if(e.key==='Enter'&&cmdInput.value){
var text=cmdInput.value+'\\n'; var cmd=cmdInput.value;
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'terminal-data',data:btoa(text)})); appendCmd(cmd);
ws.send(JSON.stringify({type:'terminal-data',data:btoa(cmd+'\\n')}));
// After each command, get pwd for prompt update
setTimeout(function(){
ws.send(JSON.stringify({type:'terminal-data',data:btoa('pwd\\n')}));
},200);
cmdInput.value=''; cmdInput.value='';
} }
}; };