Launch games the way Flashpoint's Linux launcher does
fp-command applies the launcher's rules to any application path: execs.json mappings, .bat to .sh, and .exe under Wine, resolving paths case-insensitively and splitting launch commands as the launcher's shell command would. Windows programs run from a writable per-session mirror of the install, with Director Prefs and Flash saves linked into the synced saves. OldCPUSimulator, which hangs under Wine, becomes cpulimit; Flash projectors go full screen; and Flashpoint Navigator's toolbars are hidden. This replaces the LocalGames case.
This commit is contained in:
@@ -8,12 +8,15 @@ FROM ghcr.io/linuxserver/baseimage-selkies:debiantrixie
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
cpulimit \
|
||||
fontconfig \
|
||||
fonts-dejavu-core \
|
||||
libasound2-plugins \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libgl1 \
|
||||
libgtk2.0-0t64 \
|
||||
libnss3 \
|
||||
libpulse0 \
|
||||
libx11-6 \
|
||||
libxcomposite1 \
|
||||
@@ -26,7 +29,8 @@ RUN apt-get update && \
|
||||
libxrender1 \
|
||||
libxtst6 \
|
||||
libxxf86vm1 \
|
||||
sqlite3 && \
|
||||
sqlite3 \
|
||||
xdotool && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY root/ /
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
Runs a Flashpoint game natively on the server and streams it to the browser with
|
||||
[Selkies](https://github.com/selkies-project/selkies), for platforms that cannot
|
||||
run in a browser. One container plays one game, set by `GAME_ID`. It supports
|
||||
Java applets, and Windows games installed under `LocalGames/` (see
|
||||
[local-games](../local-games/README.md)).
|
||||
run in a browser. One container plays one game, set by `GAME_ID`. It launches
|
||||
games the way Flashpoint's Linux launcher does, so Java applets, Shockwave,
|
||||
Flash and Windows games installed under `LocalGames/` (see
|
||||
[local-games](../local-games/README.md)) all run.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -43,7 +44,13 @@ a `document.cookie` that persists to `/config/saves/cookies.txt`, and a policy
|
||||
allowing HTTP requests through the game server's proxy. The same jar (`applet-support/`) is loaded as a Java agent
|
||||
that removes appletviewer's menu bar and status line.
|
||||
|
||||
Windows games run under Flashpoint's bundled Wine from a writable copy of the
|
||||
game's directory. Its `Prefs` directory, where Director projectors save, is
|
||||
linked into `/config/saves`, so the session manager syncs it to the player's
|
||||
browser. A player without a save starts from the `Prefs` the game ships with.
|
||||
Other games go through `fp-command`, which applies the launcher's rules: it maps
|
||||
the application path through `Data/execs.json`, swaps `.bat` for `.sh`, and runs
|
||||
`.exe` files under Flashpoint's bundled Wine. Windows programs run from a
|
||||
writable mirror of the install, since they write beside themselves; a
|
||||
Director projector's `Prefs`, where it saves, is linked into `/config/saves`, as
|
||||
is Flash Player's save directory, so the session manager syncs both to the
|
||||
player's browser. OldCPUSimulator hangs under Wine, so its target runs under
|
||||
`cpulimit` at the same share of the CPU. Flash projectors switch to full screen,
|
||||
which hides their menu bar and scales the movie to the display, and Flashpoint
|
||||
Navigator's toolbars are hidden.
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prints, NUL-separated, the working directory and command line the Flashpoint
|
||||
Linux launcher runs for an application path and launch command.
|
||||
|
||||
Usage: fp-command APPLICATION_PATH LAUNCH_COMMAND (with FP_PATH set)
|
||||
|
||||
Follows the launcher's rules: map the path through Data/execs.json, swap .bat
|
||||
for .sh, and run .exe files under Wine. An .exe the Linux build replaces with a
|
||||
script of the same name, like FlashpointSecurePlayer, runs that script.
|
||||
OldCPUSimulator, which hangs under Wine, is replaced by running its target
|
||||
under cpulimit at the same share of the host CPU. Exits 1 if the program
|
||||
doesn't exist.
|
||||
|
||||
With FP_WRITABLE set, an .exe runs from a mirror of FP_PATH under it: symlinks
|
||||
to the read-only install, except the program's directory, which is copied
|
||||
since Windows programs write beside themselves. Its Prefs directory, where
|
||||
Director projectors save, is linked to ~/saves/Prefs, starting from the Prefs it
|
||||
ships with. Flashpoint Navigator's profile gets a stylesheet hiding its
|
||||
toolbars.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
fp_path = os.environ['FP_PATH']
|
||||
app, cmd = sys.argv[1:3]
|
||||
|
||||
|
||||
def mapped(app):
|
||||
try:
|
||||
with open(os.path.join(fp_path, 'Data', 'execs.json')) as f:
|
||||
execs = json.load(f)['execs']
|
||||
except OSError:
|
||||
return app
|
||||
for e in execs:
|
||||
if e['win32'].lower() == app.lower() and e.get('linux'):
|
||||
return e['linux']
|
||||
return app
|
||||
|
||||
|
||||
def resolve(relative):
|
||||
"""Finds a path under FP_PATH case-insensitively, as Windows and Wine would."""
|
||||
path = fp_path
|
||||
for part in relative.replace('\\', '/').split('/'):
|
||||
if part in ('', '.'):
|
||||
continue
|
||||
try:
|
||||
names = os.listdir(path)
|
||||
except OSError:
|
||||
return None
|
||||
match = next((n for n in names if n == part), None) or next((n for n in names if n.lower() == part.lower()), None)
|
||||
if match is None:
|
||||
return None
|
||||
path = os.path.join(path, match)
|
||||
return path
|
||||
|
||||
|
||||
def split(cmd):
|
||||
"""Splits a launch command the way the launcher's escaped shell command is parsed:
|
||||
double quotes group, paired single quotes group, and nothing else is special."""
|
||||
lexer = shlex.shlex(cmd, posix=True)
|
||||
lexer.whitespace_split = True
|
||||
lexer.escape = ''
|
||||
lexer.escapedquotes = ''
|
||||
lexer.quotes = '"\'' if cmd.count("'") % 2 == 0 else '"'
|
||||
return list(lexer)
|
||||
|
||||
|
||||
def host_mhz():
|
||||
"""The CPU clock OldCPUSimulator's target rate is measured against."""
|
||||
try:
|
||||
with open('/proc/cpuinfo') as f:
|
||||
info = f.read().splitlines()
|
||||
except OSError:
|
||||
info = []
|
||||
mhz = [float(line.split(':')[1]) for line in info if line.startswith('cpu MHz')]
|
||||
if mhz:
|
||||
return max(mhz)
|
||||
try:
|
||||
with open('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq') as f:
|
||||
return int(f.read()) / 1000
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
bogomips = [float(line.split(':')[1]) for line in info if line.lower().startswith('bogomips')]
|
||||
return bogomips[0] / 2 if bogomips else 3000
|
||||
|
||||
|
||||
def old_cpu_simulator(args):
|
||||
"""Translates OldCPUSimulator's '-t RATE [options] -sw SOFTWARE ARGS...'."""
|
||||
rate, i = None, 0
|
||||
while i < len(args) and args[i] not in ('-sw', '--software'):
|
||||
if args[i] in ('-t', '--target-rate', '-r', '--refresh-rate'):
|
||||
if args[i] in ('-t', '--target-rate'):
|
||||
rate = float(args[i + 1])
|
||||
i += 1
|
||||
i += 1
|
||||
if rate is None or i + 1 >= len(args):
|
||||
sys.exit(1)
|
||||
return rate, args[i + 1], args[i + 2:]
|
||||
|
||||
|
||||
relative = mapped(app).replace('\\', '/')
|
||||
args = split(cmd)
|
||||
limit = None
|
||||
if relative.lower().endswith('oldcpusimulator/oldcpusimulator.exe'):
|
||||
rate, software, args = old_cpu_simulator(args)
|
||||
relative = os.path.normpath(os.path.join(os.path.dirname(relative), software.replace('\\', '/')))
|
||||
limit = max(1, round(100 * rate / host_mhz()))
|
||||
if relative.lower().endswith('.bat'):
|
||||
relative = relative[:-4] + '.sh'
|
||||
program = resolve(relative)
|
||||
if program is None and relative.lower().endswith('.exe'):
|
||||
program = resolve(relative[:-4] + '.sh')
|
||||
if program is None or not os.path.isfile(program):
|
||||
sys.exit(1)
|
||||
|
||||
def writable_copy(program, root):
|
||||
"""Mirrors FP_PATH under root, copying only the program's directory."""
|
||||
parts = os.path.relpath(os.path.dirname(program), fp_path).split(os.sep)
|
||||
src, dst = fp_path, root
|
||||
for part in parts:
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
for name in os.listdir(src):
|
||||
if name != part and not os.path.lexists(os.path.join(dst, name)):
|
||||
os.symlink(os.path.join(src, name), os.path.join(dst, name))
|
||||
src, dst = os.path.join(src, part), os.path.join(dst, part)
|
||||
if not os.path.exists(dst):
|
||||
shutil.copytree(src, dst, symlinks=True)
|
||||
link_saves(os.path.join(dst, 'Prefs'), os.path.expanduser('~/saves/Prefs'))
|
||||
profile = os.path.join(dst, 'User', 'flashpointnavigator', 'Profiles', 'Default')
|
||||
if os.path.isdir(profile):
|
||||
os.makedirs(os.path.join(profile, 'chrome'), exist_ok=True)
|
||||
with open(os.path.join(profile, 'chrome', 'userChrome.css'), 'w') as f:
|
||||
f.write('#toolbar-menubar, #TabsToolbar, #nav-bar, #PersonalToolbar { visibility: collapse !important; }\n')
|
||||
return os.path.join(dst, os.path.basename(program))
|
||||
|
||||
|
||||
def link_saves(path, saves):
|
||||
"""Replaces path with a link to saves, which starts from path's contents."""
|
||||
if os.path.islink(path):
|
||||
return
|
||||
os.makedirs(saves, exist_ok=True)
|
||||
if os.path.isdir(path):
|
||||
if not os.listdir(saves):
|
||||
shutil.copytree(path, saves, dirs_exist_ok=True)
|
||||
shutil.rmtree(path)
|
||||
os.symlink(saves, path)
|
||||
|
||||
|
||||
if program.lower().endswith('.exe') and os.environ.get('FP_WRITABLE'):
|
||||
program = writable_copy(program, os.environ['FP_WRITABLE'])
|
||||
argv = [program, *args]
|
||||
if program.lower().endswith('.exe'):
|
||||
argv = ['wine', 'start', '/wait', '/unix', *argv]
|
||||
if limit:
|
||||
argv = ['cpulimit', '--include-children', '--limit', str(limit), '--', *argv]
|
||||
sys.stdout.write('\0'.join([os.path.dirname(program), *argv]) + '\0')
|
||||
@@ -38,24 +38,18 @@ case ${app,,} in
|
||||
# and a window holding only the applet
|
||||
export JAVA_TOOL_OPTIONS="-javaagent:/opt/flashpoint/applet-support.jar -Djava.security.policy=/opt/flashpoint/applet.policy"
|
||||
FP_PATH=$FP_PATH exec "$FP_PATH/FPSoftware/startJava.sh" "$cmd" ;;
|
||||
'localgames\'*.exe)
|
||||
# Windows games run under Flashpoint's bundled Wine from a writable copy.
|
||||
# Prefs, where Director projectors save, lives in the synced saves directory.
|
||||
exe=${app//\\//}
|
||||
work=$HOME/game
|
||||
[[ -d $work ]] || cp -r "$FP_PATH/$(dirname "$exe")" "$work"
|
||||
if [[ ! -L $work/Prefs ]]; then
|
||||
mkdir -p "$HOME/saves/Prefs"
|
||||
# Players without a save start from the one the game ships with
|
||||
if [[ -d $work/Prefs && -z $(ls -A "$HOME/saves/Prefs") ]]; then
|
||||
cp -r "$work/Prefs/." "$HOME/saves/Prefs"
|
||||
fi
|
||||
rm -rf "$work/Prefs"
|
||||
ln -s "$HOME/saves/Prefs" "$work/Prefs"
|
||||
fi
|
||||
export WINEPREFIX=$HOME/.wine WINEDEBUG=-all WINEDLLOVERRIDES='mscoree,mshtml='
|
||||
cd "$work"
|
||||
exec "$FP_PATH/FPSoftware/Wine/bin/wine" "$(basename "$exe")" ;;
|
||||
*)
|
||||
echo "Unsupported application: $app" >&2; exit 1 ;;
|
||||
mapfile -d '' argv < <(FP_PATH=$FP_PATH FP_WRITABLE=$HOME/flashpoint fp-command "$app" "$cmd") || true
|
||||
(( ${#argv[@]} > 1 )) || { echo "Unsupported application: $app" >&2; exit 1; }
|
||||
export FP_PATH HTTP_PROXY=http://127.0.0.1:22500/ http_proxy=http://127.0.0.1:22500/
|
||||
export PATH=$FP_PATH/FPSoftware/Wine/bin:$PATH WINEDEBUG=-all WINEDLLOVERRIDES='mscoree,mshtml='
|
||||
# Flash Player keeps saves (SharedObjects) here
|
||||
mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia"
|
||||
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
|
||||
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
|
||||
# Full screen hides the projector's menu bar and scales the movie to the display
|
||||
(xdotool search --sync --onlyvisible --name 'Flash Player' windowactivate --sync key ctrl+f) &
|
||||
fi
|
||||
cd "${argv[0]}"
|
||||
exec "${argv[@]:1}" ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user