Start Gyre from an icon: launch / quit / install-desktop

- python3 -m sandtable install-desktop puts a Gyre icon in the app grid and on
  the desktop (freedesktop .desktop entry with a "Quit Gyre" action).
- The icon runs `launch`: starts the web page in the background (no terminal,
  own session, log in .cache/server.log) if it isn't running, then opens it.
  A dialog shows the reason if it can't start.
- Quit link on the page (local computer only) and `quit`: stop any design,
  motors off, close the page.
- Gyre icon (sandtable/static/gyre.svg), also the page's tab icon.
- Simulator takes several lines per tick, so streaming tests aren't slowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 21:25:10 -05:00
1 parent 8a377af116
commit db78b1f2e6
9 files changed
+195 -13

No files matched your search

+28 -2
View File
@@ -344,6 +344,7 @@ class Player:
PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Gyre - Sand Art Table</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
:root{--bg:#f4f1ea;--card:#fffdf8;--ink:#2a2622;--mute:#6f675d;--line:#e2dbcf;--acc:#2f6f5e;--acc-ink:#fff;--stop:#c0392b;--bar:#e9e2d4}
@media (prefers-color-scheme:dark){:root{--bg:#18191b;--card:#232528;--ink:#ece8e1;--mute:#a59e94;--line:#36393d;--acc:#5fb49c;--acc-ink:#0d1f1a;--bar:#2e3135}}
@@ -431,6 +432,7 @@ a{color:var(--acc)}
<footer>Click a design to draw it. Each one starts with an erase spiral (turn off "erase first" to draw over the sand as it is).
Make your own at <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball.
· <a href="/jog">Jog &amp; calibrate</a>
<span id="quit" style="display:none"> · <a href="#" onclick="return quitApp()">Quit</a></span>
<span id="power" style="display:none"> · <a href="#" onclick="return powerAct('reboot')">Restart</a> · <a href="#" onclick="return powerAct('poweroff')">Power off</a></span></footer>
</main>
<script>
@@ -506,6 +508,7 @@ async function tick(){
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
lastConnErr=m.connected?'':m.error;
$('power').style.display = ST.power_buttons ? 'inline' : 'none';
$('quit').style.display = ST.can_quit ? 'inline' : 'none';
if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;}
if(p.message) showErr(p.message);
const now=p.now;
@@ -521,12 +524,17 @@ async function tick(){
markPlaying();
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
}
function quitApp(){
if(!confirm('Close Gyre? Any design stops and the motors switch off.')) return false;
post('quit').then(j=>{ if(j.ok){ clearInterval(TICK); showErr(j.msg); $('mach').textContent='closed'; } });
return false;
}
function powerAct(what){
const q = what==='poweroff' ? 'Power off the table\'s computer? Unplug it only after the green light on the Pi stops blinking.' : 'Restart the table\'s computer? The page comes back in about a minute.';
if(confirm(q)) post(what).then(j=>{ if(j.ok) showErr(j.msg); });
return false;
}
loadLib(); tick(); setInterval(tick,1000);
loadLib(); tick(); const TICK=setInterval(tick,1000);
</script></body></html>
"""
@@ -582,6 +590,9 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
q = parse_qs(u.query)
if u.path in ('/', '/index.html'):
self._send(200, PAGE, 'text/html; charset=utf-8')
elif u.path == '/favicon.svg':
with open(os.path.join(os.path.dirname(__file__), 'static', 'gyre.svg'), 'rb') as f:
self._send(200, f.read(), 'image/svg+xml')
elif u.path == '/jog':
self._send(200, jog_page, 'text/html; charset=utf-8')
elif u.path == '/api/status':
@@ -590,7 +601,8 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
self._send(200, json.dumps({'machine': st, 'player': player.status(),
'state': st['state'], 'theta': st['theta'], 'rho': st['rho'],
'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'],
'error': st['error'], 'power_buttons': POWER_BUTTONS}))
'error': st['error'], 'power_buttons': POWER_BUTTONS,
'can_quit': not POWER_BUTTONS}))
elif u.path == '/api/library':
self._send(200, json.dumps(lib.items()))
elif u.path == '/thumb':
@@ -642,6 +654,20 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
lib.remove(body.get('id', '')); ok, msg = True, 'removed'
elif act in ('poweroff', 'reboot'):
ok, msg = power(act)
elif act == 'quit':
if POWER_BUTTONS or self.client_address[0] not in ('127.0.0.1', '::1'):
ok, msg = False, 'Quit only works on the computer Gyre runs on'
else:
log('quit from the page or the Gyre icon')
player.stop()
try:
if panel.connected:
with panel.lock:
ctrl.motors_off()
except Exception:
pass
threading.Thread(target=srv.shutdown, daemon=True).start()
ok, msg = True, 'Gyre is closed. Start it again from the Gyre icon.'
elif act in ('jog', 'home', 'unlock', 'calibrate'):
ok, msg = busy() or {
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),