From 68ef7c1591000debe6fe27f6f4fea8d3b84ce36b Mon Sep 17 00:00:00 2001 From: Nicole O'Connor Date: Tue, 29 Jul 2025 12:50:35 -0700 Subject: [PATCH] desktop support --- .dockerignore | 6 ++ .gitignore | 1 + Dockerfile | 31 +++++------ app/desktop.py | 47 ++++++++++++++++ app/index.wsgi | 10 +++- app/pylocal/core.py | 25 +++++++-- app/pylocal/desktop.py | 32 +++++++++++ app/pylocal/dev.py | 7 +++ app/pylocal/tick.py | 12 +++- app/rant/flavor.rant | 8 +-- app/requirements-build-desktop.txt | 1 + app/requirements-desktop-linux.txt | 3 + app/requirements-desktop.txt | 1 + app/requirements.txt | 6 +- app/templates/dev_toolbox.j2 | 2 + app/templates/main_page.j2 | 20 ++++++- build-desktop.sh | 43 +++++++++++++++ ext/imsky/wordlists | 2 +- seagull.spec | 43 +++++++++++++++ static/js/desktop-structuredclone.js | 3 + static/js/seagull-desktop.js | 34 ++++++++++++ static/js/seagull-web.js | 27 +++++++++ static/js/seagull.js | 82 ++++++++++++++++++++-------- 23 files changed, 385 insertions(+), 61 deletions(-) create mode 100644 .dockerignore create mode 100755 app/desktop.py mode change 100644 => 100755 app/index.wsgi create mode 100644 app/pylocal/desktop.py create mode 100644 app/pylocal/dev.py create mode 100644 app/requirements-build-desktop.txt create mode 100644 app/requirements-desktop-linux.txt create mode 100644 app/requirements-desktop.txt create mode 100644 app/templates/dev_toolbox.j2 create mode 100755 build-desktop.sh create mode 100644 seagull.spec create mode 100644 static/js/desktop-structuredclone.js create mode 100644 static/js/seagull-desktop.js create mode 100644 static/js/seagull-web.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..466b675 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +app/.desktop_mode +__pycache__/** +app/requirements-*.txt + +seagull +.git* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 73cf9d4..2cac875 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode/** scratch.ipynb .docker-login-credentials +seagull \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fab4764..bb18b93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/desktop.py b/app/desktop.py new file mode 100755 index 0000000..0a41628 --- /dev/null +++ b/app/desktop.py @@ -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) \ No newline at end of file diff --git a/app/index.wsgi b/app/index.wsgi old mode 100644 new mode 100755 index b4f6f62..0349ebb --- a/app/index.wsgi +++ b/app/index.wsgi @@ -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) \ No newline at end of file diff --git a/app/pylocal/core.py b/app/pylocal/core.py index 7a16c14..233f203 100644 --- a/app/pylocal/core.py +++ b/app/pylocal/core.py @@ -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") \ No newline at end of file diff --git a/app/pylocal/desktop.py b/app/pylocal/desktop.py new file mode 100644 index 0000000..02988da --- /dev/null +++ b/app/pylocal/desktop.py @@ -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() \ No newline at end of file diff --git a/app/pylocal/dev.py b/app/pylocal/dev.py new file mode 100644 index 0000000..bf8f34a --- /dev/null +++ b/app/pylocal/dev.py @@ -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) \ No newline at end of file diff --git a/app/pylocal/tick.py b/app/pylocal/tick.py index 41a032f..c283265 100644 --- a/app/pylocal/tick.py +++ b/app/pylocal/tick.py @@ -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") \ No newline at end of file diff --git a/app/rant/flavor.rant b/app/rant/flavor.rant index 2b12ad4..6ca4407 100644 --- a/app/rant/flavor.rant +++ b/app/rant/flavor.rant @@ -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: ] | - [pick: ] - }. +} | +{ + A nearby {`[pick: ]|colony of `[pick: ]s} seems to be harassing a human. } \ No newline at end of file diff --git a/app/requirements-build-desktop.txt b/app/requirements-build-desktop.txt new file mode 100644 index 0000000..5545112 --- /dev/null +++ b/app/requirements-build-desktop.txt @@ -0,0 +1 @@ +pyinstaller>=6.14.2 \ No newline at end of file diff --git a/app/requirements-desktop-linux.txt b/app/requirements-desktop-linux.txt new file mode 100644 index 0000000..bee69a7 --- /dev/null +++ b/app/requirements-desktop-linux.txt @@ -0,0 +1,3 @@ +pyqt6>=6.9.1 +pyqtwebengine>=5.15.7 +pywebview[qt]>=5.4 \ No newline at end of file diff --git a/app/requirements-desktop.txt b/app/requirements-desktop.txt new file mode 100644 index 0000000..07034ee --- /dev/null +++ b/app/requirements-desktop.txt @@ -0,0 +1 @@ +pywebview>=5.4 \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index 807d8c7..6a1db7a 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -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 \ No newline at end of file +redis==6.2.0 \ No newline at end of file diff --git a/app/templates/dev_toolbox.j2 b/app/templates/dev_toolbox.j2 new file mode 100644 index 0000000..49bc863 --- /dev/null +++ b/app/templates/dev_toolbox.j2 @@ -0,0 +1,2 @@ +{% if not desktop %}IP: {{ipaddr}}
{% endif %} + \ No newline at end of file diff --git a/app/templates/main_page.j2 b/app/templates/main_page.j2 index 4bcc2b2..d1b46f2 100644 --- a/app/templates/main_page.j2 +++ b/app/templates/main_page.j2 @@ -12,8 +12,10 @@
@@ -22,12 +24,28 @@
Nameless ✏️
-

Lv 1 LoadError

+

Lv 1 LoadError

+

XP: 0/0

Colony: 1337
Shinies: 420
+ Food: 69

+
+
+

Human encounters:

+

Seagull encounters:

+
diff --git a/build-desktop.sh b/build-desktop.sh new file mode 100755 index 0000000..265dfa7 --- /dev/null +++ b/build-desktop.sh @@ -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" diff --git a/ext/imsky/wordlists b/ext/imsky/wordlists index cdda0e8..3aab59e 160000 --- a/ext/imsky/wordlists +++ b/ext/imsky/wordlists @@ -1 +1 @@ -Subproject commit cdda0e81d62151349c3a17679b5a0433eec60327 +Subproject commit 3aab59e6844fe899a3fcc3949859efbc74977510 diff --git a/seagull.spec b/seagull.spec new file mode 100644 index 0000000..97617b9 --- /dev/null +++ b/seagull.spec @@ -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, +) diff --git a/static/js/desktop-structuredclone.js b/static/js/desktop-structuredclone.js new file mode 100644 index 0000000..217f914 --- /dev/null +++ b/static/js/desktop-structuredclone.js @@ -0,0 +1,3 @@ +function structuredClone(val) { + return JSON.parse(JSON.stringify(val)); +} \ No newline at end of file diff --git a/static/js/seagull-desktop.js b/static/js/seagull-desktop.js new file mode 100644 index 0000000..54a998b --- /dev/null +++ b/static/js/seagull-desktop.js @@ -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(); +} \ No newline at end of file diff --git a/static/js/seagull-web.js b/static/js/seagull-web.js new file mode 100644 index 0000000..23cc718 --- /dev/null +++ b/static/js/seagull-web.js @@ -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(); +} \ No newline at end of file diff --git a/static/js/seagull.js b/static/js/seagull.js index 7690822..4e60698 100644 --- a/static/js/seagull.js +++ b/static/js/seagull.js @@ -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(); }