Desktop app: shell and library screens

This commit is contained in:
2026-10-26 23:46:29 +03:00
parent 69048452b8
commit 01c1b304ef
46 changed files with 4700 additions and 5 deletions

11
tools/linux/desktop.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# SleepyOwl for the desktop without Gradle around the process: ./tools/linux/desktop.sh
# DESKTOP_NOBUILD=1 — do not build first. SLEEPYOWL_SCALE=1.25 — interface scale (until the settings have it, L9).
# Files go to the XDG places (~/.config/sleepyowl, ~/.cache/sleepyowl, ~/.local/state/sleepyowl/log); the test stand
# (tools/linux/stand.sh) points them at its own directory.
set -e
R=$(cd "$(dirname "$0")/../.." && pwd)
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath)
exec "$JAVA_HOME/bin/java" -Dsleepyowl.repo="$R" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \
-cp "$(cat "$R/desktop/build/desktop.classpath")" ru.stenfredd.sleepyowl.desktop.MainKt "$@"

70
tools/linux/stand.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Test stand of the desktop app (LINUX_PLAN.md §3 «Как проверяем»): a private virtual display :87 with the real
# window manager (Muffin, in its own D-Bus session), so window, mouse and keyboard tests do not touch the user's screen.
#
# stand.sh up start Xvfb :87 (1920×1080) and Muffin, if not running
# stand.sh down stop the app, Muffin and Xvfb
# stand.sh app [args] (re)start the app on :87 in the background; output in $STAND/app.out
# stand.sh stop stop the app
# stand.sh shot NAME screenshot of the display → $STAND/shots/NAME.png
# stand.sh in CMD… input on :87 (tools/linux/xinput.py): click X Y, move X Y, key NAME, type TEXT, …
# stand.sh log the app's log (sleepyowl.log)
#
# The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account,
# cache, log and lock — the user's own ~/.config/sleepyowl is never used. Gradle builds before `app` unless
# DESKTOP_NOBUILD=1.
set -e
R=$(cd "$(dirname "$0")/../.." && pwd)
STAND=${STAND:-$HOME/.cache/sleepyowl-test}
D=:87
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
mkdir -p "$STAND/shots" "$STAND/run"
running() { [ -f "$STAND/run/$1.pid" ] && kill -0 "$(cat "$STAND/run/$1.pid")" 2>/dev/null; }
up() {
if ! running xvfb; then
Xvfb $D -screen 0 1920x1080x24 -nolisten tcp >"$STAND/run/xvfb.out" 2>&1 &
echo $! >"$STAND/run/xvfb.pid"
for _ in $(seq 50); do xdpyinfo -display $D >/dev/null 2>&1 && break; sleep 0.1; done
fi
if ! running muffin; then
DISPLAY=$D setsid dbus-run-session muffin --replace >"$STAND/run/muffin.out" 2>&1 &
echo $! >"$STAND/run/muffin.pid"
sleep 2
fi
echo "stand up on $D"
}
stop_app() {
if running app; then
kill "$(cat "$STAND/run/app.pid")" 2>/dev/null || true
for _ in $(seq 50); do running app || break; sleep 0.1; done
running app && kill -9 "$(cat "$STAND/run/app.pid")" 2>/dev/null || true
fi
rm -f "$STAND/run/app.pid"
}
case "$1" in
up) up ;;
down)
stop_app
for p in muffin xvfb; do running $p && pkill -P "$(cat "$STAND/run/$p.pid")" 2>/dev/null; running $p && kill "$(cat "$STAND/run/$p.pid")"; rm -f "$STAND/run/$p.pid"; done
echo "stand down" ;;
app)
shift
up >/dev/null
stop_app
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath)
DISPLAY=$D XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \
SLEEPYOWL_RUNTIME_DIR="$STAND/run" DESKTOP_NOBUILD=1 "$R/tools/linux/desktop.sh" "$@" >"$STAND/app.out" 2>&1 &
echo $! >"$STAND/run/app.pid"
echo "app started, pid $(cat "$STAND/run/app.pid")" ;;
stop) stop_app ;;
shot)
import -display $D -window root "$STAND/shots/$2.png"
echo "$STAND/shots/$2.png" ;;
in) shift; DISPLAY=$D python3 "$R/tools/linux/xinput.py" "$@" ;;
log) cat "$STAND/state/sleepyowl/log/sleepyowl.log" ;;
*) sed -n '2,17p' "$0"; exit 1 ;;
esac

110
tools/linux/watchdata.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Watch data of the test account on the server, before and after a test run (LINUX_PLAN.md §3 «Как проверяем»).
watchdata.py snapshot [FILE] saves the user's watch data (positions, played, counts, dates, favourites)
watchdata.py diff [FILE] what the run changed, compared with the snapshot
watchdata.py restore [FILE] puts the changed items back as they were in the snapshot
FILE defaults to $STAND/watchdata.json. The user is the test account: its token is taken from the test stand's saved accounts
($STAND/config/sleepyowl/jellyfin_session.preferences_pb, STAND=~/.cache/sleepyowl-test) or from JF_TOKEN.
JF_USER picks another saved account by name. Server: JF_URL, default the LAN address.
Restore writes the saved UserData back (POST /UserItems/{id}/UserData). An item the run started from nothing (not in
the snapshot) is reset: played and its date removed (DELETE /UserPlayedItems), position and count back to 0.
The resume list follows LastPlayedDate, so its order comes back with the dates.
"""
import json, os, re, sys, urllib.request
STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test")
URL = os.environ.get("JF_URL", "http://192.168.1.10:8096")
USER = os.environ.get("JF_USER", "test")
FIELDS = ("PlaybackPositionTicks", "PlayCount", "Played", "LastPlayedDate", "IsFavorite")
def account():
token = os.environ.get("JF_TOKEN")
path = os.path.join(STAND, "config", "sleepyowl", "jellyfin_session.preferences_pb")
raw = open(path, "rb").read().decode("utf-8", "replace") if os.path.exists(path) else ""
m = re.search(r"\[\{.*\}\]", raw)
for a in json.loads(m.group(0)) if m else []:
if a["userName"] == USER:
return token or a["token"], a["userId"]
sys.exit(f"no saved account {USER!r} in {path}: log in on the stand first (or set JF_TOKEN and JF_USER_ID)")
def api(method, path, body=None):
req = urllib.request.Request(URL + path, method=method, data=None if body is None else json.dumps(body).encode())
req.add_header("Authorization", f'MediaBrowser Token="{TOKEN}"')
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=30) as r:
data = r.read()
return json.loads(data) if data else None
def current():
"""Every item with any watch data: resumable, played or favourite (episodes, movies, series)."""
items = {}
for flt in ("IsResumable", "IsPlayed", "IsFavorite"):
res = api("GET", f"/Items?userId={USER_ID}&Recursive=true&Filters={flt}&Fields=UserData&EnableImages=false")
for it in res["Items"]:
ud = it.get("UserData", {})
items[it["Id"]] = {"Name": it.get("SeriesName", "") + (" · " if it.get("SeriesName") else "") + it.get("Name", ""),
**{k: ud.get(k) for k in FIELDS}}
return items
def user_data(item_id):
ud = api("GET", f"/UserItems/{item_id}/UserData?userId={USER_ID}")
return {k: ud.get(k) for k in FIELDS}
def changes(before, after):
out = []
for item_id in sorted(set(before) | set(after)):
b, a = before.get(item_id), after.get(item_id)
if b is None:
a = {**a, **user_data(item_id)}
if a is None:
a = {"Name": b["Name"], **user_data(item_id)}
if b is None or any(b[k] != a[k] for k in FIELDS):
out.append((item_id, b, a))
return out
def show(item_id, b, a):
name = (b or a)["Name"]
diff = ", ".join(f"{k}: {None if b is None else b[k]} → {a[k]}" for k in FIELDS if b is None or b[k] != a[k])
print(f" {name} ({item_id}): {diff}")
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("snapshot", "diff", "restore"):
sys.exit(__doc__)
path = sys.argv[2] if len(sys.argv) > 2 else os.path.join(STAND, "watchdata.json")
if sys.argv[1] == "snapshot":
items = current()
json.dump(items, open(path, "w"), ensure_ascii=False, indent=1)
print(f"{len(items)} items with watch data saved to {path}")
return
before = json.load(open(path))
diff = changes(before, current())
if not diff:
print("no changes")
return
for c in diff: show(*c)
if sys.argv[1] == "diff":
return
for item_id, b, _ in diff:
if b is None:
api("DELETE", f"/UserPlayedItems/{item_id}?userId={USER_ID}")
api("POST", f"/UserItems/{item_id}/UserData?userId={USER_ID}", {"PlaybackPositionTicks": 0, "PlayCount": 0, "Played": False})
else:
api("POST", f"/UserItems/{item_id}/UserData?userId={USER_ID}", {k: b[k] for k in FIELDS})
left = changes(before, current())
print(f"restored {len(diff)} items" + ("" if not left else f"; still different: {len(left)}"))
for c in left: show(*c)
TOKEN, USER_ID = (os.environ["JF_TOKEN"], os.environ["JF_USER_ID"]) if os.environ.get("JF_USER_ID") else account()
if __name__ == "__main__":
main()

87
tools/linux/xinput.py Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Mouse and keyboard on an X display through XTest (python3-xlib), for the test stand (stand.sh in …).
xinput.py click X Y [BUTTON] move there and click (1 left, 3 right, 8 back)
xinput.py dclick X Y double click
xinput.py move X Y
xinput.py key NAME [NAME…] keys by keysym name: Escape Return Tab BackSpace Left …; "alt+Left" for a chord
xinput.py type TEXT types Latin text, digits and punctuation
xinput.py wait SECONDS
Several commands in one call are separated by ",": xinput.py click 10 10 , key Return
"""
import sys, time
from Xlib import X, XK, display
from Xlib.ext import xtest
d = display.Display()
MODS = {"alt": "Alt_L", "ctrl": "Control_L", "shift": "Shift_L"}
def code(name):
keysym = XK.string_to_keysym(name)
if keysym == 0 and len(name) == 1:
keysym = ord(name)
c = d.keysym_to_keycode(keysym)
if not c:
sys.exit(f"no key for {name!r}")
return c
def press(c, down):
xtest.fake_input(d, X.KeyPress if down else X.KeyRelease, c)
d.sync()
def key(chord):
*mods, name = chord.split("+")
for m in mods: press(code(MODS[m]), True)
c = code(name)
press(c, True); time.sleep(0.03); press(c, False)
for m in reversed(mods): press(code(MODS[m]), False)
time.sleep(0.08)
def move(x, y):
xtest.fake_input(d, X.MotionNotify, x=int(x), y=int(y)); d.sync(); time.sleep(0.05)
def click(x, y, button=1, times=1):
move(x, y)
for _ in range(times):
xtest.fake_input(d, X.ButtonPress, int(button)); d.sync(); time.sleep(0.03)
xtest.fake_input(d, X.ButtonRelease, int(button)); d.sync(); time.sleep(0.06)
SHIFTED = {c: True for c in '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'}
NAMES = {" ": "space", ".": "period", ":": "colon", "/": "slash", "-": "minus", "_": "underscore", "@": "at"}
def type_text(text):
for ch in text:
name = NAMES.get(ch, ch)
c = code(name)
shift = ch in SHIFTED
if shift: press(code("Shift_L"), True)
press(c, True); press(c, False)
if shift: press(code("Shift_L"), False)
time.sleep(0.02)
def run(cmd):
op, args = cmd[0], cmd[1:]
if op == "click": click(args[0], args[1], args[2] if len(args) > 2 else 1)
elif op == "dclick": click(args[0], args[1], times=2)
elif op == "move": move(*args)
elif op == "key": [key(a) for a in args]
elif op == "type": type_text(" ".join(args))
elif op == "wait": time.sleep(float(args[0]))
else: sys.exit(__doc__)
cmds, cur = [], []
for a in sys.argv[1:]:
if a == ",": cmds.append(cur); cur = []
else: cur.append(a)
if cur: cmds.append(cur)
if not cmds: sys.exit(__doc__)
for c in cmds: run(c)

View File

@@ -8,6 +8,8 @@ wordmark on a flat navy background, 1254×1254):
@color/ic_launcher_background
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
desktop/src/main/resources/icons/sleepyowl-<N>.png desktop icon N×N (16…512): owl on a navy rounded square —
window icon, .desktop file, the .deb's hicolor theme
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
classpath.
@@ -24,7 +26,7 @@ import argparse
import os
import numpy as np
from PIL import Image
from PIL import Image, ImageDraw
from scipy import ndimage
BG = np.array([5, 9, 39]) / 255 # the logo's background
@@ -34,6 +36,8 @@ WORD_ROWS = (870, 1080)
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
APP_RES = "app/src/main/res/"
SHARED_RES = "ui/shared/src/androidMain/res/"
DESKTOP_ICONS = "desktop/src/main/resources/icons/"
DESKTOP_SIZES = (16, 24, 32, 48, 64, 128, 256, 512)
DP = 2 # xhdpi
@@ -128,6 +132,17 @@ def build(src):
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
# Desktop icons: the owl's body fills ~80 % of a navy square with rounded corners (as the panel's other icons).
big = 1024
plate = Image.new("L", (big, big), 0)
ImageDraw.Draw(plate).rounded_rectangle((0, 0, big - 1, big - 1), radius=big * 0.22, fill=255)
owl_d = fit(owl, round(owl.height * big * 0.4 / body_radius))
icon = Image.new("RGBA", (big, big), tuple(int(c * 255 + 0.5) for c in BG) + (255,))
icon.alpha_composite(owl_d, ((big - owl_d.width) // 2, (big - owl_d.height) // 2))
icon.putalpha(plate)
for n in DESKTOP_SIZES:
out[DESKTOP_ICONS + f"sleepyowl-{n}.png"] = icon.convert("RGBa").resize((n, n), Image.LANCZOS).convert("RGBA")
# In-app marks, drawn at exactly these sizes (Brand.kt).
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)