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/**
scratch.ipynb
.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)
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
# 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
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
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
# wordlist
RUN mkdir -p /lib/wordlist
COPY ext/imsky/wordlists /lib/wordlist
RUN mkdir -p /app/rant
RUN python /lib/wordlist/render.py -o rant /app/rant/wordlist.rant
# installing app
COPY app /app
WORKDIR /app
CMD [ "python", "index.wsgi" ]
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
gevent.monkey.patch_all()
import pathlib
import sys
import threading
import flask
from gevent.pywsgi import WSGIServer
from pylocal import core, tick
from pylocal import core, dev, tick
sig_exit = threading.Event()
@core.app.route("/")
def index():
if not core.base_context_live:
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)
if __name__ == "__main__":
@ -22,5 +27,6 @@ if __name__ == "__main__":
http_server = WSGIServer(('', 80), core.app)
http_server.serve_forever()
except KeyboardInterrupt:
print("Goodnight, moon ...")
core.log.info("Goodnight, moon ...")
sig_exit.set()
sys.exit(0)

View File

@ -1,10 +1,24 @@
import logging
import os
import pathlib
import sys
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
#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):
if endpoint == "static":
# bandaid for #1
if not os.path.exists("/app/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"]))
sys.stderr.flush()
if not os.path.exists(path_appdir / "static" / values["filename"]):
log.warning("requested {0} from local file, but it doesn't exist in this container. Redirecting to CDN...\n".format(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)
@ -51,5 +64,5 @@ def render_base_context():
base_context_live = True
@app.route("/core/ping")
def aws_healthcheck_ping():
def healthcheck_ping():
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
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()
class TickEvent(object):
@ -36,11 +40,13 @@ def tick():
result["event_type"] = random.choices(ticktypes, weights=tickweights)[0]
match result["event_type"]:
case 0:
pass
case 1: # FLAVOR
result["log"] = generate_flavor_text()
case 10: # ENCHUMAN
result["items"] = [] # TODO: implement items
result["items"] = {} # TODO: implement items
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")

View File

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

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
gevent==22.10.2
Flask==3.1.1
gevent==25.5.1
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>
<body>
<noscript>
<div style="background:yellow;border:2px red;">
<h1>This doesn't work without JavaScript.</h1><br />
<h2>You're probably using a browser extension or privacy tool that disables it.</h2>
</div>
</noscript>
<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-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">
<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">
Colony: <span id="lbl-seagull-colony">1337</span><br />
Shinies: <span id="lbl-seagull-shinies">420</span><br />
Food: <span id="lbl-seagull-food">69</span>
</p>
</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 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",
"level": 1,
"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
function record_log(text) {
const div_logrow = document.createElement("div");
@ -50,18 +42,40 @@ function record_log(text) {
page_elements["div_log"].append(div_logrow);
}
function save_game() {
window.localStorage.setItem("gamestate", JSON.stringify(gamestate));
record_log("Game saved.");
function update_ui() {
page_elements["lbl_name"].innerHTML = gamestate["name"];
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() {
gamestate["tick"] += 1;
ticks_since_last_save += 1;
// temp
page_elements["lbl_colony"].innerHTML = ticks_since_last_save;
page_elements["lbl_tick"].innerHTML = gamestate["tick"];
var tickdata = await fetch("/tick")
.then(res => {
@ -95,22 +109,42 @@ async function game_tick() {
save_game();
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_sidebar"] = document.querySelector("#main-sidebar");
page_elements["div_name"] = document.querySelector("#side-seagull-name");
page_elements["div_name_editor"] = document.querySelector("#side-seagull-name-editor");
page_elements["lbl_name"] = document.querySelector("#lbl-seagull-name");
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["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);
page_elements["lbl_name"].innerHTML = gamestate["name"];
page_elements["lbl_tick"].innerHTML = gamestate["tick"];
const interval = setInterval(() => {
if (tick_meter_running) { game_tick(); }