desktop support

This commit is contained in:
Nicole O'Connor 2025-07-29 12:50:35 -07:00
parent b08eab62cc
commit 68ef7c1591
23 changed files with 385 additions and 61 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
app/.desktop_mode
__pycache__/**
app/requirements-*.txt
seagull
.git*

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.vscode/** .vscode/**
scratch.ipynb scratch.ipynb
.docker-login-credentials .docker-login-credentials
seagull

View File

@ -1,33 +1,32 @@
FROM python:3.11-alpine AS base FROM python:3.13-alpine AS builder
# install rust environment (for rant) # install rust environment (for rant)
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin RUST_VERSION=1.61.0 ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin RUST_VERSION=1.61.0
RUN apk add --no-cache rustup gcc musl-dev RUN --mount=type=cache,target=/var/cache/apk apk add --update-cache rustup gcc musl-dev
RUN rustup-init -y --profile minimal RUN rustup-init -y --profile minimal
# install rant # install rant
RUN cargo install --color=never rant --version 4.0.0-alpha.33 --root / --features cli RUN --mount=type=cache,target=/root/.cargo \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
cargo install --color=never rant --version 4.0.0-alpha.33 --root /opt/rant --features cli
# python prereqs FROM python:3.13-alpine AS app
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/opt/rant/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin RUST_VERSION=1.61.0
COPY --from=builder /opt/rant /opt/rant
# installing app
COPY app/requirements.txt /app/requirements.txt COPY app/requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt RUN --mount=type=cache,target=/root/.cache/pip pip install -r /app/requirements.txt
COPY app /app
WORKDIR /app
# cleanup # wordlist
RUN cargo install cargo-cache --root /
RUN cargo cache --remove-dir all
RUN pip cache purge
RUN apk del rustup gcc musl-dev
FROM base AS app
RUN mkdir -p /lib/wordlist RUN mkdir -p /lib/wordlist
COPY ext/imsky/wordlists /lib/wordlist COPY ext/imsky/wordlists /lib/wordlist
RUN mkdir -p /app/rant RUN mkdir -p /app/rant
RUN python /lib/wordlist/render.py -o rant /app/rant/wordlist.rant RUN python /lib/wordlist/render.py -o rant /app/rant/wordlist.rant
# installing app
COPY app /app
WORKDIR /app
CMD [ "python", "index.wsgi" ] CMD [ "python", "index.wsgi" ]
EXPOSE 80 EXPOSE 80

47
app/desktop.py Executable file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env python
import argparse
import os
import pathlib
import sys
import threading
import webview
import flask
from pylocal import core, desktop, dev, tick
core.desktop_mode = True
sig_exit = threading.Event()
argp = argparse.ArgumentParser("seagull")
argp.add_argument("-d", "--debug", action="store_true")
argo = argp.parse_args()
@core.app.route("/")
def index():
if not core.base_context_live:
core.render_base_context()
core.base_context["scripts"].insert(0, core.app.url_for("static", filename="js/desktop-structuredclone.js"))
core.base_context["scripts"].insert(1, core.app.url_for("static", filename="js/seagull-desktop.js"))
return flask.render_template("main_page.j2", **core.base_context)
if __name__ == "__main__":
try:
if sys.platform.startswith("win"):
storage_dir = pathlib.Path(os.environ["APPDATA"]) / "seagull"
elif sys.platform.startswith("darwin"): # macos
storage_dir = pathlib.Path(os.environ["HOME"]) / "Library/Application Support/seagull"
else:
storage_dir = pathlib.Path(os.environ["HOME"]) / ".local/share/seagull"
desktop.path_storagedir = pathlib.Path(storage_dir)
if argo.debug:
desktop.api.debug_mode = True
storage_dir.mkdir(exist_ok=True, parents=True)
webview.create_window("Seagull Game", core.app, js_api=desktop.api)
webview.start(private_mode=False, storage_path=storage_dir.as_posix(), debug=True if argo.debug else False)
except KeyboardInterrupt:
core.log.info("Goodnight, moon ...")
sig_exit.set()
sys.exit(0)

10
app/index.wsgi Normal file → Executable file
View File

@ -3,17 +3,22 @@
import gevent.monkey import gevent.monkey
gevent.monkey.patch_all() gevent.monkey.patch_all()
import pathlib
import sys import sys
import threading
import flask import flask
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
from pylocal import core, tick from pylocal import core, dev, tick
sig_exit = threading.Event()
@core.app.route("/") @core.app.route("/")
def index(): def index():
if not core.base_context_live: if not core.base_context_live:
core.render_base_context() core.render_base_context()
core.base_context["scripts"].append(core.app.url_for("static", filename="js/seagull-web.js"))
return flask.render_template("main_page.j2", **core.base_context) return flask.render_template("main_page.j2", **core.base_context)
if __name__ == "__main__": if __name__ == "__main__":
@ -22,5 +27,6 @@ if __name__ == "__main__":
http_server = WSGIServer(('', 80), core.app) http_server = WSGIServer(('', 80), core.app)
http_server.serve_forever() http_server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
print("Goodnight, moon ...") core.log.info("Goodnight, moon ...")
sig_exit.set()
sys.exit(0) sys.exit(0)

View File

@ -1,10 +1,24 @@
import logging
import os import os
import pathlib
import sys import sys
import flask import flask
import redis
app = flask.Flask("seagull-game", root_path="/app") log = logging.getLogger()
pipe_stderr = logging.StreamHandler(sys.stderr)
pipe_stderr.setLevel(logging.DEBUG)
log.addHandler(pipe_stderr)
if getattr(sys, "frozen", False):
path_appdir = pathlib.Path(sys._MEIPASS)
else:
path_appdir = pathlib.Path.cwd()
log.critical(path_appdir)
desktop_mode = False
app = flask.Flask("seagull-game", root_path=path_appdir)
orig_url_for = app.url_for orig_url_for = app.url_for
#REDIS_HOST="stub-implementation.example.net" #REDIS_HOST="stub-implementation.example.net"
@ -16,9 +30,8 @@ orig_url_for = app.url_for
def url_for_override(endpoint, *posargs, _anchor=None, _method=None, _scheme=None, _external=None, self=app, **values): def url_for_override(endpoint, *posargs, _anchor=None, _method=None, _scheme=None, _external=None, self=app, **values):
if endpoint == "static": if endpoint == "static":
# bandaid for #1 # bandaid for #1
if not os.path.exists("/app/static/" + values["filename"]): if not os.path.exists(path_appdir / "static" / values["filename"]):
sys.stderr.write("WARN:: requested {0} from local file, but it doesn't exist in this container. Redirecting to CDN...\n".format(values["filename"])) log.warning("requested {0} from local file, but it doesn't exist in this container. Redirecting to CDN...\n".format(values["filename"]))
sys.stderr.flush()
return "https://cdn.otl-hga.net/seagull/" + values["filename"] return "https://cdn.otl-hga.net/seagull/" + values["filename"]
return orig_url_for(endpoint, *posargs, _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external, **values) return orig_url_for(endpoint, *posargs, _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external, **values)
@ -51,5 +64,5 @@ def render_base_context():
base_context_live = True base_context_live = True
@app.route("/core/ping") @app.route("/core/ping")
def aws_healthcheck_ping(): def healthcheck_ping():
return flask.Response("OK", content_type="text/plain") return flask.Response("OK", content_type="text/plain")

32
app/pylocal/desktop.py Normal file
View File

@ -0,0 +1,32 @@
import pathlib
from . import core
path_storagedir = pathlib.Path()
class JS_API:
debug_mode = False
def load_data(self, key):
if not (path_storagedir / key).exists():
return None
with open(path_storagedir / key) as fd_datafile:
try:
return fd_datafile.read()
except Exception as exc:
core.log.error(f"problem loading {key} (from {path_storagedir}): {exc}")
return None
def save_data(self, key, data):
with open(path_storagedir / key, "w") as fd_datafile:
try:
fd_datafile.write(data)
except Exception as exc:
core.log.error(f"problem saving {key} (to {path_storagedir}): {exc}")
def delete_data(self, key):
if (path_storagedir / key).exists():
(path_storagedir / key).unlink()
api = JS_API()

7
app/pylocal/dev.py Normal file
View File

@ -0,0 +1,7 @@
import flask
from . import core
@core.app.route("/dev/get-toolbox")
def dev_toolbox():
return flask.render_template("dev_toolbox.j2", ipaddr=flask.request.remote_addr, desktop=core.desktop_mode)

View File

@ -7,7 +7,11 @@ import flask
from . import core from . import core
def generate_flavor_text(): def generate_flavor_text():
proc_rant = subprocess.run(["rant", "/app/rant/flavor.rant"], capture_output=True) if core.desktop_mode:
rant_path = core.path_appdir / "opt/rant/bin/rant"
else:
rant_path = "rant" # rely on OS PATH
proc_rant = subprocess.run([rant_path, (core.path_appdir / "rant/flavor.rant").as_posix()], capture_output=True)
return proc_rant.stdout.decode() return proc_rant.stdout.decode()
class TickEvent(object): class TickEvent(object):
@ -36,11 +40,13 @@ def tick():
result["event_type"] = random.choices(ticktypes, weights=tickweights)[0] result["event_type"] = random.choices(ticktypes, weights=tickweights)[0]
match result["event_type"]: match result["event_type"]:
case 0:
pass
case 1: # FLAVOR case 1: # FLAVOR
result["log"] = generate_flavor_text() result["log"] = generate_flavor_text()
case 10: # ENCHUMAN case 10: # ENCHUMAN
result["items"] = [] # TODO: implement items result["items"] = {} # TODO: implement items
case _: case _:
print("undefined tick: {0}".format(result["event_type"])) core.log.warning("undefined tick: {0}".format(result["event_type"]))
return flask.Response(json.dumps(result), status=200, content_type="application/json") return flask.Response(json.dumps(result), status=200, content_type="application/json")

View File

@ -18,9 +18,7 @@
You have a polite conversation about birdly affairs. | You have a polite conversation about birdly affairs. |
It scoffs and flies away. It scoffs and flies away.
} }
| } |
You encounter a human and attempt to steal their `{ {
[pick: <wordlist/nouns/food>] | A nearby {`[pick: <wordlist/nouns/birds>]|colony of `[pick: <wordlist/nouns/birds>]s} seems to be harassing a human.
[pick: <wordlist/nouns/fast_food>]
}.
} }

View File

@ -0,0 +1 @@
pyinstaller>=6.14.2

View File

@ -0,0 +1,3 @@
pyqt6>=6.9.1
pyqtwebengine>=5.15.7
pywebview[qt]>=5.4

View File

@ -0,0 +1 @@
pywebview>=5.4

View File

@ -1,4 +1,4 @@
Flask==2.2.2 Flask==3.1.1
gevent==22.10.2 gevent==25.5.1
hiredis>=1.0.0 hiredis>=1.0.0
redis==4.5.1 redis==6.2.0

View File

@ -0,0 +1,2 @@
{% if not desktop %}IP: {{ipaddr}}<br />{% endif %}
<button id="dev-reset" onClick="reset_game()">Reset Game</button>

View File

@ -12,8 +12,10 @@
</head> </head>
<body> <body>
<noscript> <noscript>
<div style="background:yellow;border:2px red;">
<h1>This doesn't work without JavaScript.</h1><br /> <h1>This doesn't work without JavaScript.</h1><br />
<h2>You're probably using a browser extension or privacy tool that disables it.</h2> <h2>You're probably using a browser extension or privacy tool that disables it.</h2>
</div>
</noscript> </noscript>
<div id="root"> <div id="root">
@ -22,12 +24,28 @@
<div id="side-seagull-name"><span id="lbl-seagull-name">Nameless</span> <a href="javascript:change_seagull_name()">✏️</a></div> <div id="side-seagull-name"><span id="lbl-seagull-name">Nameless</span> <a href="javascript:change_seagull_name()">✏️</a></div>
<div id="side-seagull-name-editor"><input type="text" id="edt-seagull-name"> <a href="javascript:confirm_seagull_name()">✅</a><a href="javascript:cancel_seagull_name()">❌</a></div> <div id="side-seagull-name-editor"><input type="text" id="edt-seagull-name"> <a href="javascript:confirm_seagull_name()">✅</a><a href="javascript:cancel_seagull_name()">❌</a></div>
<div id="side-seagull-stats"> <div id="side-seagull-stats">
<p id="side-seagull-lvl">Lv 1 LoadError</p> <p id="side-seagull-lvl">Lv <span id="lbl-seagull-lvl">1</span> <span id="lbl-seagull-class">LoadError</span></p>
<p id="side-seagull-xp">XP: <span id="lbl-seagull-xp-current">0</span>/<span id="lbl-seagull-xp-next">0</span></p>
<p id="side-seagull-misc"> <p id="side-seagull-misc">
Colony: <span id="lbl-seagull-colony">1337</span><br /> Colony: <span id="lbl-seagull-colony">1337</span><br />
Shinies: <span id="lbl-seagull-shinies">420</span><br /> Shinies: <span id="lbl-seagull-shinies">420</span><br />
Food: <span id="lbl-seagull-food">69</span>
</p> </p>
</div> </div>
<hr />
<div id="side-action-bar">
<p>Human encounters: <select id="menu-enc-human">
<option value="pause">Stop and ask</option>
<option value="steal-food">Steal food</option>
<option value="steal-shinies">Steal shiny things</option>
</select></p>
<p>Seagull encounters: <select id="menu-enc-seagull">
<option value="pause">Stop and ask</option>
<option value="recruit">Attempt recruiting</option>
<option value="steal-food">Steal food</option>
<option value="steal-shinies">Steal shiny things</option>
</select></p>
</div>
</div> </div>
<div id="main-content"> <div id="main-content">

43
build-desktop.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/bash
BUILD_DIR=${BUILD_DIR:-./build}
die () {
echo "$@" >&2
exit 1
}
findcmd () {
command -v $1 || die "missing command: $1"
}
findcmd cargo
findcmd python
findcmd rsync
srcdir=$(pwd)
mkdir -p $BUILD_DIR && cd $BUILD_DIR
rsync -rv $srcdir/ $BUILD_DIR/
# rant
mkdir -p opt/rant
cargo install rant --version 4.0.0-alpha.33 --root $BUILD_DIR/opt/rant --features cli
python $srcdir/ext/imsky/wordlists/render.py -o rant $BUILD_DIR/app/rant/wordlist.rant
# python venv
python -m venv pyvenv
source pyvenv/bin/activate
pip install -r app/requirements.txt
pip install -r app/requirements-build-desktop.txt
pip install -r app/requirements-desktop.txt
[[ $(uname -s) == "Linux" ]] && pip install -r app/requirements-desktop-linux.txt
#[[ $(uname -s) == "Darwin" ]] && pip install -r app/requirements-desktop-macos.txt
pyinstaller seagull.spec
deactivate
cd $srcdir
cp -fv $BUILD_DIR/dist/seagull $srcdir
rm -fr $BUILD_DIR
du -sh seagull
echo "You should be able to run ./seagull now"

@ -1 +1 @@
Subproject commit cdda0e81d62151349c3a17679b5a0433eec60327 Subproject commit 3aab59e6844fe899a3fcc3949859efbc74977510

43
seagull.spec Normal file
View File

@ -0,0 +1,43 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app/desktop.py'],
pathex=[],
binaries=[],
datas=[
('app/templates', './templates'),
('static', './static'),
('app/rant', './rant'),
('opt', './opt')
],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='seagull',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@ -0,0 +1,3 @@
function structuredClone(val) {
return JSON.parse(JSON.stringify(val));
}

View File

@ -0,0 +1,34 @@
var desktop_mode = true;
async function prepare_gamestate() {
var gamestate_loaded = null;
try {
gamestate_loaded = await window.pywebview.api.load_data("gamestate");
} catch (exc) {
console.error("no gamestate");
gamestate_loaded = null;
}
if (gamestate_loaded == null) {
record_log("Welcome to Seagull Game! We haven't found a save in your app data, so we're starting a new game!");
gamestate = structuredClone(gamestate_default);
}
else {
console.log(gamestate_loaded);
gamestate = JSON.parse(gamestate_loaded);
record_log("Welcome back! Game loaded.")
}
}
function save_game() {
window.pywebview.api.save_data("gamestate", JSON.stringify(gamestate));
record_log("Game saved.");
}
var tick_meter_running = true;
function reset_game() {
tick_meter_running = false;
window.pywebview.api.delete_data("gamestate");
window.location.reload();
}

27
static/js/seagull-web.js Normal file
View File

@ -0,0 +1,27 @@
var desktop_mode = false;
function prepare_gamestate() {
var gamestate_loaded = window.localStorage.getItem("gamestate");
if (gamestate_loaded == null) {
record_log("Welcome to Seagull Game! We haven't found a save in your browser storage, so we're starting a new game!");
gamestate = structuredClone(gamestate_default);
}
else {
gamestate = JSON.parse(gamestate_loaded);
record_log("Welcome back! Game loaded.")
}
}
function save_game() {
window.localStorage.setItem("gamestate", JSON.stringify(gamestate));
record_log("Game saved.");
}
var tick_meter_running = true;
function reset_game() {
tick_meter_running = false;
window.localStorage.removeItem("gamestate");
window.location.reload();
}

View File

@ -14,22 +14,14 @@ const gamestate_default = {
"name": "Nameless", "name": "Nameless",
"level": 1, "level": 1,
"shinies": 0, "shinies": 0,
"autosave": 35 "colony": 1,
"food": 0,
"autosave": 35,
"story_beat": 0,
"xp": 0,
"xp_next": 50
}; };
function prepare_gamestate() {
var gamestate_loaded = window.localStorage.getItem("gamestate");
if (gamestate_loaded == null) {
record_log("Welcome to Seagull Game! We haven't found a save in your browser storage, so we're starting a new game!");
gamestate = structuredClone(gamestate_default);
}
else {
gamestate = JSON.parse(gamestate_loaded);
record_log("Welcome back! Game loaded.")
}
}
var bool_log_alt = false var bool_log_alt = false
function record_log(text) { function record_log(text) {
const div_logrow = document.createElement("div"); const div_logrow = document.createElement("div");
@ -50,18 +42,40 @@ function record_log(text) {
page_elements["div_log"].append(div_logrow); page_elements["div_log"].append(div_logrow);
} }
function save_game() { function update_ui() {
window.localStorage.setItem("gamestate", JSON.stringify(gamestate)); page_elements["lbl_name"].innerHTML = gamestate["name"];
record_log("Game saved."); page_elements["lbl_tick"].innerHTML = gamestate["tick"];
page_elements["lbl_colony"].innerHTML = gamestate["colony"];
page_elements["lbl_shinies"].innerHTML = gamestate["shinies"];
page_elements["lbl_food"].innerHTML = gamestate["food"];
} }
var tick_meter_running = true; var dev_toolbox_open = false;
function dev_toolbox(open) {
if (open != dev_toolbox_open) {
if (open) {
var div_toolbox = document.createElement("div");
page_elements["div_toolbox"] = div_toolbox;
div_toolbox.setAttribute("id", "dev_toolbox");
fetch("/dev/get-toolbox")
.then((response) => response.text())
.then((resp) => {div_toolbox.innerHTML = resp})
page_elements["div_sidebar"].appendChild(div_toolbox);
}
else {
var div_toolbox = page_elements["div_toolbox"];
page_elements["div_sidebar"].removeChild(div_toolbox);
div_toolbox.remove()
delete page_elements["div_toolbox"];
}
}
dev_toolbox_open = open;
}
async function game_tick() { async function game_tick() {
gamestate["tick"] += 1; gamestate["tick"] += 1;
ticks_since_last_save += 1; ticks_since_last_save += 1;
// temp
page_elements["lbl_colony"].innerHTML = ticks_since_last_save;
page_elements["lbl_tick"].innerHTML = gamestate["tick"]; page_elements["lbl_tick"].innerHTML = gamestate["tick"];
var tickdata = await fetch("/tick") var tickdata = await fetch("/tick")
.then(res => { .then(res => {
@ -95,22 +109,42 @@ async function game_tick() {
save_game(); save_game();
ticks_since_last_save = 0; ticks_since_last_save = 0;
} }
update_ui();
} }
document.addEventListener("DOMContentLoaded", function (ev) { var start_event = "";
var target = null;
if (desktop_mode) {
// pywebview's native JS is nerfed in a few places and needs the additional python API
// which gets loaded after initial DOM via injections
start_event = "pywebviewready";
target = window;
}
else {
// in web mode, browsers are expected to have working local storage
start_event = "DOMContentLoaded";
target = document;
}
target.addEventListener(start_event, function (ev) {
page_elements["div_log"] = document.querySelector("#main-log"); page_elements["div_log"] = document.querySelector("#main-log");
page_elements["div_sidebar"] = document.querySelector("#main-sidebar");
page_elements["div_name"] = document.querySelector("#side-seagull-name"); page_elements["div_name"] = document.querySelector("#side-seagull-name");
page_elements["div_name_editor"] = document.querySelector("#side-seagull-name-editor"); page_elements["div_name_editor"] = document.querySelector("#side-seagull-name-editor");
page_elements["lbl_name"] = document.querySelector("#lbl-seagull-name"); page_elements["lbl_name"] = document.querySelector("#lbl-seagull-name");
page_elements["lbl_colony"] = document.querySelector("#lbl-seagull-colony"); page_elements["lbl_colony"] = document.querySelector("#lbl-seagull-colony");
page_elements["lbl_shinies"] = document.querySelector("#lbl-seagull-shinies");
page_elements["lbl_food"] = document.querySelector("#lbl-seagull-food");
page_elements["edt_name"] = document.querySelector("#edt-seagull-name"); page_elements["edt_name"] = document.querySelector("#edt-seagull-name");
page_elements["lbl_tick"] = document.querySelector("#main-day-counter"); page_elements["lbl_tick"] = document.querySelector("#main-day-counter");
page_elements["lbl_xp"] = document.querySelector("#lbl-seagull-xp-current");
page_elements["lbl_xp_next"] = document.querySelector("#lbl-seagull-xp-next");
prepare_gamestate(); prepare_gamestate().then(update_ui());
record_log("seagull game ver. " + ver_string); record_log("seagull game ver. " + ver_string);
page_elements["lbl_name"].innerHTML = gamestate["name"];
page_elements["lbl_tick"].innerHTML = gamestate["tick"];
const interval = setInterval(() => { const interval = setInterval(() => {
if (tick_meter_running) { game_tick(); } if (tick_meter_running) { game_tick(); }