mirror of
https://github.com/s0liton/ps5-romm.git
synced 2026-10-28 22:01:45 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
659005ce58 | ||
|
|
a1446f4706 | ||
|
|
b5d3e954f8 | ||
|
|
9066fb38ac | ||
|
|
46aa1710d2 | ||
|
|
bf479d045b | ||
|
|
7a5a07a13f | ||
|
|
7b426b8273 |
No files matched your search
@@ -14,3 +14,4 @@ jobs:
|
||||
- run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev zlib1g-dev
|
||||
- run: make host tools
|
||||
- run: tests/e2e.sh
|
||||
- run: tests/autostart.sh
|
||||
@@ -23,7 +23,7 @@ SRCS := src/main.c src/util.c src/http.c src/romm.c src/config.c src/state.c \
|
||||
third_party/cJSON.c third_party/md5.c third_party/monocypher.c third_party/monocypher-ed25519.c \
|
||||
$(CHDR_SRCS)
|
||||
HDRS := $(wildcard src/*.h) third_party/cJSON.h third_party/md5.h third_party/monocypher.h third_party/monocypher-ed25519.h
|
||||
UI_HEADER := build/gen/ui_index.h build/gen/icon_png.h
|
||||
UI_HEADER := build/gen/ui_index.h
|
||||
COMMON_CFLAGS := -std=gnu11 -O2 -Wall -Wextra -Wno-unused-parameter -Wno-unknown-warning-option -Wno-unreachable-code-generic-assoc -Isrc -Ithird_party -Ibuild/gen \
|
||||
-Ithird_party/libchdr/include -DCHDR_SYSTEM_ZLIB
|
||||
ifdef VERSION
|
||||
@@ -50,10 +50,6 @@ build/gen/inter-font.css: third_party/inter-latin.woff2 tools/font_css.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/font_css.py $< $@ Inter
|
||||
|
||||
build/gen/icon_png.h: assets/icon0.png tools/embed.py
|
||||
@mkdir -p $(dir $@)
|
||||
python3 tools/embed.py $< $@ ICON_PNG
|
||||
|
||||
# host build
|
||||
HOST_CC ?= cc
|
||||
HOST_BIN := build/host/romm-sync
|
||||
|
||||
@@ -4,42 +4,47 @@
|
||||
|
||||
A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN or onionHEN, with kstuff) and PS4 consoles (GoldHEN). Through my insatiable need to mod everything electronic I encounter, I had my 12.70 PS5 jailbroken the minute that P2JB was out. I maintain a RomM server to centralize all my _legal_ ROMs, saves, and savestates, which all my devices sync with, and the PS5 needs similar treatment.
|
||||
|
||||
- **Background save sync**
|
||||
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS5 always has the latest saves and savestates.
|
||||
- **Save and sate sync**
|
||||
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS4/5 always has the latest saves and save states.
|
||||
- **Interactive library**
|
||||
- Browse your RomM library from RommPS, the native app (or some other device), download games and BIOS files into the right emulator folders, and resolve save conflicts.
|
||||
- Browse your RomM library from the RommPS app (or some other device via the Web UI), download games and BIOS files into the right emulator folders, and resolve save conflicts.
|
||||
- **Pluggable emulator profiles and multiple emulators per platform**
|
||||
- Fully customizable sync destinations, but includes frequently updated preset profiles for existing homebrew emulators.
|
||||
- Can sync games and saves and states for multiple emulators per platform (PS2 on RetroArch and PS5SX2 as an example) using hardlinks.
|
||||
- **Beautiful and Responsive UI**
|
||||
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better.
|
||||
- **Easter Egg**
|
||||
- Check in the Settings, scroll the settings list, see if you find the X.
|
||||
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better. 15+ different themes to pick from.
|
||||
- **Background Service**
|
||||
- All operations are handled by a backend payload, which means syncing, downloading, and updating keep working in the background, so you can keep **gaming**.
|
||||
- **RomM API Compliant**
|
||||
- RommPS is meant to strictly stick to RomM's API and method of syncing saves and states, ensuring we don't break anything and support RomM's latest features.
|
||||
- **Secure and Human-Coded**
|
||||
- Each release is signed. The payload checks the signature before it updates itself, and ProsperoStore checks every download against the release's checksum. This app was primarily coded by a **human**, and will be frequently updated and maintained. AI was primarily used for explaining concepts, creating documentation, and converting data. Ya know, the tedious stuff.
|
||||
- **Easter Eggs**
|
||||
- Check in the Settings, scroll the settings list, see if you find the X. This **hint** won't be here forever.
|
||||
|
||||
## Supported Emulators and Games
|
||||
|
||||
Technically, this RomM client will work with all emulators, but I have included profiles for the emulators that exist for the console as of Oct 2026.
|
||||
Technically, RomM Sync on both PS4 and PS5 will support **any** emulator as long as you map the game, save, and save state folders correctly during emulator setup. Most should just work, but some emulators/consoles may have weird formats, or Romm may not fully support all features.
|
||||
|
||||
### Supported out of the box
|
||||
As a general rule, Game library syncing will pretty much work with anything. Saves/States are the more complex side of things.
|
||||
|
||||
Setup finds these on its own. Anything else can still sync by picking its folders during setup.
|
||||
### PS5 ###
|
||||
|
||||
**PS5**
|
||||
| Emulator | Systems | Game Downloads | Saves | Comments |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [RetroArch](https://github.com/mihawk-99/PS5_RetroArch) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Full Support | Full Support | - |
|
||||
| [PSXS5](https://github.com/SynoPiia/PSXS5) | PS1 | Full Support | One memory card per game, and states | - |
|
||||
| [SwanStationPS5](https://github.com/darkxex/SwanStationPS5) | PS1 | Full Support | One memory card per game, and states | - |
|
||||
| [PS5SX2](https://github.com/Swordpdf/PS5SX2) | PS2 | Full Support | Its two shared memory cards, backed up to RomM (5.3 or newer) | - |
|
||||
| [PS5N64](https://github.com/Globaleliteee/PS5N64) | N64 | Full Support | Full Support | - |
|
||||
| [PS5NES](https://github.com/Globaleliteee/PS5NES) | NES | Broken | Broken | Broken, tried to report issue, but GH page gone |
|
||||
| [Porpoise](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5) | Wii/GameCube | N/A | N/A | Couldn't exit sandbox w/o crash. [Issue](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5/issues/18) reported |
|
||||
| [ProsperoEden](https://github.com/blackbearreloaded/ProsperoEden) | Switch | Manual Support | Researching | - |
|
||||
| [PS5X360](https://github.com/BrinooTk/PS5X360) | Xbox 360 | Manual Support | Researching | - |
|
||||
|
||||
| Emulator | Systems | Saves |
|
||||
| --- | --- | --- |
|
||||
| RetroArch (mihawk-99's port) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Saves and states |
|
||||
| PSXS5 / SwanStationPS5 | PS1 | One memory card per game, and states |
|
||||
| PS5SX2 | PS2 | Its two shared memory cards, backed up to RomM (5.3 or newer) |
|
||||
| PS5N64 | N64 | Saves and states |
|
||||
To give some meaning to the table above. **Full support** means RommPS can detect and properly set game/save directories for that emulator. **Manual Support** means you can use it to sync games and/or saves, but you'll need to point it to the directories yourself. This typically means that full support is coming shortly. Broken or N/A means there was a problem keeping us from sync games and saves, or the emulator just fall out doesn't work or crashes. **Researching** means I am seeing what is possible when it comes to that sync type for the given emulator or game system.
|
||||
|
||||
PS5NES and Porpoise don't have presets yet, because neither works properly on my console right now:
|
||||
|
||||
- **PS5NES** v7 looks for its games in PS5SX2's folder instead of its own.
|
||||
- **Porpoise** crashes the moment it leaves the app sandbox, with both OnionHEN and Lapy freeing it, so it fails to launch. Issue reported to the dev. In the meantime, you can sync files into the sandbox, but you won't have access to /data.
|
||||
|
||||
|
||||
**PS4** (experimental)
|
||||
### PS4 (experimental) ###
|
||||
|
||||
| Emulator | Systems |
|
||||
| --- | --- |
|
||||
@@ -61,7 +66,7 @@ If you are interested in downloading PS4 and PS5 games from your RomM server, Ro
|
||||
|
||||
## Install
|
||||
|
||||
Install **RommPS** from ProsperoStore and open it. That's it: RommPS carries the RomM Sync payload and starts it for you, then walks you through setup. Its last step makes RomM Sync start with every jailbreak, so you don't have to open the app for syncing to work.
|
||||
Install **RommPS** from [ProsperoStore](https://homebrew.page) and open it. It'll set up the background daemon, and take you through a setup wizard. That's it: RommPS carries the RomM Sync payload and starts it for you. From then on it starts with your HEN, so after a jailbreak or waking the console it's ready to go.
|
||||
|
||||
Not using the store? Download `PPSA76677.zip` from the releases page, unzip it and copy the `PPSA76677` folder to `/data/homebrew/` on the console.
|
||||
|
||||
@@ -74,11 +79,13 @@ If you'd rather run just the payload, without the app:
|
||||
```
|
||||
3. Set it up at `http://<ps5-ip>:8780` from another device.
|
||||
|
||||
To start RomM Sync with the console, let your HEN start it: the last setup step does this for you. Both HENs start `payloads/romm-sync.elf` when `romm-sync.elf.auto_start` sits next to it, in `/data/etaHEN/payloads` or `/data/OnionHEN/payloads` (onionHEN's Toolbox has a toggle for it under Payloads). RomM Sync works out which HEN is running. If you build it yourself, `make install PS5_HOST=<ps5-ip>` copies it there over FTP (add `PS5_FTP=ftp://<ps5-ip>:1337 PS5_HEN_DIR=/data/OnionHEN` for onionHEN's FTP plugin).
|
||||
On the PS5 the payload doesn't update itself: new versions come with RommPS. Without the app, download the new `romm-sync.elf` yourself.
|
||||
|
||||
RomM Sync starts with your HEN, and setup turns this on for you. Both HENs start `payloads/romm-sync.elf` when `romm-sync.elf.auto_start` sits next to it, and RomM Sync puts both in every HEN's folder on the console (`/data/etaHEN/payloads` and `/data/OnionHEN/payloads`), so switching HENs keeps working. If a payload manager like pldmgr has its own copy, that copy is kept up to date too. If you build it yourself, `make install PS5_HOST=<ps5-ip>` copies it over FTP (add `PS5_FTP=ftp://<ps5-ip>:1337 PS5_HEN_DIR=/data/OnionHEN` for onionHEN's FTP plugin).
|
||||
|
||||
### PS4
|
||||
|
||||
> **Experimental.** The PS4 build has only been tested with RetroArch so far. Standalone emulators may work, but haven't been confirmed. I spent all my focus on the PS5 build, so I didn't get to test as much, so consider it experimental and there may be gremlins for now.
|
||||
> **Experimental.** The PS4 build has only been tested with RetroArch so far. Standalone emulators may work, but haven't been confirmed. I spent all my focus on the PS5 build, so I didn't get to test as much, so consider it unstable and there may be gremlins for now.
|
||||
|
||||
Needs GoldHEN 2.4b18.5 or newer, whose payload menu runs ELF payloads.
|
||||
|
||||
@@ -93,12 +100,9 @@ Don't send the payload to GoldHEN's network loader on port 9090: GoldHEN 2.4 cra
|
||||
|
||||
## Updates
|
||||
|
||||
There are two pieces to keep updated, and they update separately:
|
||||
On the PS5, just update RommPS: from ProsperoStore (or other homebrew storefronts eventually), or by copying the new zip's folder over the old one. The background service comes with it, and RommPS swaps it in the next time you open it, once nothing is syncing or downloading. An older version never replaces a newer one.
|
||||
|
||||
- **RomM Sync (the payload)** checks GitHub once a day (you can turn that off) and tells you when there's a new version. Settings can install it for you, but nothing installs on its own. Each payload release is signed, and RomM Sync only installs a file whose signature matches the key built into it, so nobody can slip a modified payload in through the updater.
|
||||
- **RommPS (the app)** updates through ProsperoStore, like any other homebrew in its catalog. If the payload ever falls behind what the app needs, RommPS tells you to update it.
|
||||
|
||||
You can always update either one by hand from the releases page.
|
||||
On the PS4, the payload updates itself from Settings.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -117,7 +121,7 @@ The setup walks you through five steps:
|
||||
LRPS2 keeps every game on one shared memory card by default. RomM stores saves per game, so pick one of these during setup:
|
||||
|
||||
- **One card per game.** Works with any RomM version. In RetroArch, open the LRPS2 core options and turn off _Shared Memory Cards_. After that, each game's card syncs like any other save. Saves already on the shared card stay there.
|
||||
- **Back up the shared card.** Needs RomM 5.3.0 or newer. The whole card is uploaded when it changes, and you can restore it from Settings. It isn't merged with other devices.
|
||||
- **Back up the shared card.** Needs RomM 5.3.0 or newer. The whole card is uploaded when it changes, and you can restore it from Settings in the web UI. It isn't merged with other devices.
|
||||
|
||||
PSP, GameCube and Wii saves are synced as one zip per game, the same format other RomM clients use. Depending on the client, they may or may not support unzipping the saves (I don't truly know), but this client does!
|
||||
|
||||
@@ -127,7 +131,7 @@ My opinion? One card per game is better since 8MB memory cards fill up quick.
|
||||
|
||||
I've included direct support for most of the emulators that are out right now, except for the ones that won't even launch... you can sync games, states, and saves anywhere you want, but the presets should do it for most people.
|
||||
|
||||
Settings has a JSON editor for emulator profiles, and a table showing which folders are used for each platform. A profile lists an emulator's folders and the RomM platforms it plays:
|
||||
Settings in the web UI has a JSON editor for emulator profiles, and a table showing which folders are used for each platform. A profile lists an emulator's folders and the RomM platforms it plays:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -153,6 +157,7 @@ tools/ps5-build.sh # PS5 build in Docker, output in build/ps5/
|
||||
tools/ps4-build.sh # PS4 payload in Docker, output in build/ps4/
|
||||
tools/ps4-build.sh ps4-pkg # plus the PS4 home screen app (platform/ps4/app)
|
||||
tests/e2e.sh # end-to-end tests against a mock RomM server
|
||||
tests/autostart.sh # autostart, version checks and HEN folders
|
||||
```
|
||||
|
||||
Both consoles build from the same code in `src/`. What differs lives in `platform/<console>/`: `platform.c` (the `src/platform.h` interface), the make rules and the Docker SDK image. `platform/console/` has the parts the PS4 and PS5 share, and `platform/host/` is the development build.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Release checklist
|
||||
|
||||
Run all of this on a real console before tagging. Tests in CI (`tests/e2e.sh`, `tests/autostart.sh`) cover the logic, not the console.
|
||||
|
||||
## Before
|
||||
|
||||
- `kMinPayload` in `rommps_app.hpp`, `contentVersion` in RommPS's `param.json` and `APP_VERSION` in `src/util.h` all match the tag.
|
||||
- `docs/releases/<tag>.md` is written. ProsperoStore shows it as the release notes.
|
||||
- If this release changes who owns something on the console (the home screen icon, starting the payload, the autostart files, updates, the data folder), the old one gets removed or migrated in this same release.
|
||||
|
||||
## Upgrades
|
||||
|
||||
For each one, start from the old version actually installed and set up, then update. Check afterwards:
|
||||
|
||||
- one icon on the home screen (RommPS), no "RomM Sync" tile
|
||||
- one RomM Sync running, the new version (`/api/status`), lock file says the same
|
||||
- every HEN folder that exists has the new `romm-sync.elf` and `.auto_start` (`/api/autostart` lists the copies and their versions)
|
||||
- a pldmgr copy, if there was one, is the new version
|
||||
- no `.bak` files left in the payload folders
|
||||
- a sync runs and finishes without errors
|
||||
- no RomM Sync notifications while RommPS is installed
|
||||
|
||||
| From | How | Also check |
|
||||
| --- | --- | --- |
|
||||
| Nothing | ProsperoStore install, open RommPS | setup turns autostart on by default |
|
||||
| Nothing | zip copied to `/data/homebrew` | same as above |
|
||||
| 1.0.2 payload only, tile, etaHEN folder | in-app update from the web UI | tile stays (no RommPS), "Install RommPS" notice |
|
||||
| 1.0.2 as above | install RommPS, open it | tile goes away |
|
||||
| previous RommPS + payload | store update of RommPS, open it | payload swapped once idle, copies updated |
|
||||
| previous RommPS + payload | payload's own update first, RommPS later | "RommPS is out of date" notice |
|
||||
| any, autostart off | update | autostart stays off, nothing added to HEN folders |
|
||||
| any, old copy in pldmgr's autoload | update | the pldmgr copy is updated, its autoload list untouched |
|
||||
|
||||
## Boot and rest mode
|
||||
|
||||
- Reboot, jailbreak with onionHEN only, don't open RommPS: RomM Sync starts by itself, one copy.
|
||||
- Same with etaHEN.
|
||||
- Rest mode, then wake: same process keeps running (uptime carries on), web UI answers.
|
||||
- Open RommPS right after a jailbreak: it waits for RomM Sync instead of starting a second copy.
|
||||
|
||||
## Never downgrade
|
||||
|
||||
- Send an older `romm-sync.elf` to port 9021 while a newer one runs: it exits, the newer one keeps running.
|
||||
- Upload an older payload in the web UI's autostart: the newer copies on disk stay.
|
||||
|
||||
## After
|
||||
|
||||
- The release workflow published the payloads, then `tools/release-app.sh <tag> --upload` attached `PPSA76677.zip`.
|
||||
- The catalog bot's pull request for the new version shows up the next day.
|
||||
@@ -0,0 +1,20 @@
|
||||
Update RommPS and open it once. It takes care of the rest.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The extra "RomM Sync" icon is gone.
|
||||
- RomM Sync now starts with onionHEN too.
|
||||
- Old copies of RomM Sync no longer start or replace newer ones.
|
||||
- RommPS shows the right time for the last sync.
|
||||
- Saving settings in the web UI no longer undoes changes made in RommPS.
|
||||
- The library picks up emulator changes right away, without restarting RommPS.
|
||||
|
||||
### Added
|
||||
|
||||
- RomM Sync starts with the console by default.
|
||||
- New home screen art.
|
||||
|
||||
### Changed
|
||||
|
||||
- RomM Sync updates come with RommPS.
|
||||
- No more RomM Sync notifications while RommPS is installed, except sync results.
|
||||
+120
-5
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <netinet/in.h>
|
||||
#include <signal.h>
|
||||
@@ -15,8 +16,10 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysctl.h>
|
||||
@@ -94,13 +97,10 @@ static pid_t find_pid(const char *name) {
|
||||
return c.found;
|
||||
}
|
||||
|
||||
void console_single_instance(void) {
|
||||
/* Kills every other process called CONSOLE_PROC_NAME, giving up after 10 s. */
|
||||
static void kill_by_name(void) {
|
||||
pid_t pid;
|
||||
int tries = 0;
|
||||
|
||||
/* Name the main thread so find_pid() of the next instance sees us. */
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
|
||||
while ((pid = find_pid(CONSOLE_PROC_NAME)) > 0) {
|
||||
if (kill(pid, SIGKILL) && errno != ESRCH) break;
|
||||
sleep(1);
|
||||
@@ -108,6 +108,121 @@ void console_single_instance(void) {
|
||||
}
|
||||
}
|
||||
|
||||
struct pid_ctx { pid_t pid; };
|
||||
|
||||
static int has_pid_cb(pid_t pid, const char *tdname, void *vctx) {
|
||||
struct pid_ctx *c = vctx;
|
||||
return pid == c->pid && !strcmp(tdname, CONSOLE_PROC_NAME);
|
||||
}
|
||||
|
||||
/* "1.2.3" into numbers; anything after the patch is ignored. */
|
||||
static int parse_version(const char *s, int v[3]) {
|
||||
v[0] = v[1] = v[2] = 0;
|
||||
if (*s == 'v') s++;
|
||||
return sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]) >= 2 ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Whether this copy should take over from one running `have`. A version that
|
||||
* doesn't parse (a hand-made build) is sent on purpose, so it takes over. */
|
||||
static int takes_over(const char *mine, const char *have) {
|
||||
int x[3], y[3];
|
||||
if (parse_version(mine, x) || parse_version(have, y)) return 1;
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (x[i] != y[i]) return x[i] > y[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Held for as long as this copy runs; the kernel lets go when the process
|
||||
* ends, however it ends. The file says which version holds it and its pid. */
|
||||
static int g_lock_fd = -1;
|
||||
|
||||
/* One line in the log, written before the log is set up: a copy that quits
|
||||
* here would otherwise leave no trace. */
|
||||
static void note(const char *fmt, ...) {
|
||||
char line[256], when[32];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", &tm);
|
||||
int n = snprintf(line, sizeof line, "%s [INFO] ", when);
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
n += vsnprintf(line + n, sizeof line - (size_t)n - 1, fmt, ap);
|
||||
va_end(ap);
|
||||
if (n > (int)sizeof line - 2) n = (int)sizeof line - 2;
|
||||
line[n++] = '\n';
|
||||
int fd = open(CONSOLE_DATA_DIR "/romm-sync.log", O_WRONLY | O_APPEND | O_CREAT, 0666);
|
||||
if (fd < 0) return;
|
||||
write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
static int lock_wait(int fd, int seconds) {
|
||||
for (int i = 0; i <= seconds * 4; i++) {
|
||||
if (!flock(fd, LOCK_EX | LOCK_NB)) return 0;
|
||||
usleep(250 * 1000);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int console_single_instance(const char *version) {
|
||||
console_mkdir_p(CONSOLE_DATA_DIR);
|
||||
int fd = open(CONSOLE_LOCK_FILE, O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0) {
|
||||
/* No lock to be had: run, as versions before the lock did. */
|
||||
kill_by_name();
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
return 0;
|
||||
}
|
||||
if (flock(fd, LOCK_EX | LOCK_NB)) {
|
||||
char have[32] = "";
|
||||
pid_t holder = 0;
|
||||
int alive = 0;
|
||||
/* A copy that took the lock a moment ago may not have said who it is
|
||||
* yet; until it does, the file still names the copy before it. */
|
||||
for (int tries = 0; tries < 8 && !alive; tries++) {
|
||||
char buf[64] = "";
|
||||
ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
|
||||
if (n > 0) buf[n] = 0;
|
||||
have[0] = 0;
|
||||
holder = 0;
|
||||
sscanf(buf, "%31s %d", have, &holder);
|
||||
struct pid_ctx c = {holder};
|
||||
alive = holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1;
|
||||
if (!alive) usleep(250 * 1000);
|
||||
}
|
||||
/* Held, but by no copy of ours: RommPS checking whether one runs.
|
||||
* It lets go at once. */
|
||||
if (!alive && !lock_wait(fd, 3)) goto locked;
|
||||
if (!takes_over(version, have)) {
|
||||
note("romm-sync %s not started: %s is already running (pid %d)", version, have, (int)holder);
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
/* Older: stop it, but only if that pid is still RomM Sync. */
|
||||
note("romm-sync %s replacing %s (pid %d)", version, have, (int)holder);
|
||||
struct pid_ctx c = {holder};
|
||||
if (holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1) kill(holder, SIGKILL);
|
||||
if (lock_wait(fd, 10)) {
|
||||
note("romm-sync %s not started: %s didn't stop", version, have);
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
locked:;
|
||||
/* Say who holds the lock first, for any copy starting right now. */
|
||||
char line[64];
|
||||
int len = snprintf(line, sizeof line, "%s %d\n", version, (int)getpid());
|
||||
if (!ftruncate(fd, 0) && len > 0) pwrite(fd, line, (size_t)len, 0);
|
||||
fchmod(fd, 0644);
|
||||
g_lock_fd = fd;
|
||||
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
|
||||
/* Copies from before the lock don't hold it: find them by name. A copy
|
||||
* still deciding hasn't named itself yet, so it's safe from this. */
|
||||
kill_by_name();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int console_send_elf(int port, const void *elf, size_t len) {
|
||||
struct sockaddr_in sa;
|
||||
memset(&sa, 0, sizeof sa);
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#define CONSOLE_PROC_NAME "romm-sync.elf"
|
||||
#define CONSOLE_DATA_DIR "/data/romm-sync"
|
||||
#define CONSOLE_CA_BUNDLE CONSOLE_DATA_DIR "/cacert.pem"
|
||||
#define CONSOLE_ICON CONSOLE_DATA_DIR "/icon0.png"
|
||||
/* Held by the running copy; holds "<version> <pid>". RommPS reads it too. */
|
||||
#define CONSOLE_LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
|
||||
|
||||
int console_mkdir_p(const char *path);
|
||||
|
||||
@@ -20,8 +21,11 @@ int console_mkdir_p(const char *path);
|
||||
typedef int (*console_proc_cb)(pid_t pid, const char *tdname, void *ctx);
|
||||
int console_for_each_proc(console_proc_cb cb, void *ctx);
|
||||
|
||||
/* Names the main thread CONSOLE_PROC_NAME and kills any older copy. */
|
||||
void console_single_instance(void);
|
||||
/* Makes this the only copy running. A copy holding the lock keeps running if
|
||||
* it's the same version or newer, and 1 is returned: this copy should quit.
|
||||
* Otherwise it's stopped, as is any copy from before the lock, and this one
|
||||
* takes the lock and the name CONSOLE_PROC_NAME. */
|
||||
int console_single_instance(const char *version);
|
||||
|
||||
/* Sends an ELF to a loader listening on 127.0.0.1:port. -1 with errno set. */
|
||||
int console_send_elf(int port, const void *elf, size_t len);
|
||||
|
||||
@@ -20,7 +20,7 @@ static char g_data[PATH_MAX_LEN];
|
||||
static const char *const HOMEBREW_DIRS[] = {NULL}; /* ROMM_SYNC_HOMEBREW */
|
||||
static const char *const EMULATOR_DIRS[] = {NULL};
|
||||
|
||||
static const plat_info_t INFO = {
|
||||
static plat_info_t INFO = {
|
||||
.name = "host",
|
||||
.console = "PlayStation 5",
|
||||
.client = "romm-sync-ps5",
|
||||
@@ -35,12 +35,15 @@ static const plat_info_t INFO = {
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.can_relaunch = 1,
|
||||
.self_update = 1, /* ROMM_SYNC_SELF_UPDATE=0 tests the PS5's way */
|
||||
};
|
||||
|
||||
const plat_info_t *plat_info(void) { return &INFO; }
|
||||
|
||||
int plat_init(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
const char *su = getenv("ROMM_SYNC_SELF_UPDATE");
|
||||
if (su && *su) INFO.self_update = atoi(su);
|
||||
const char *d = getenv("ROMM_SYNC_DATA");
|
||||
str_copy(g_data, sizeof g_data, d && *d ? d : "./host-data");
|
||||
mkdir_p(g_data);
|
||||
@@ -74,9 +77,16 @@ const char *plat_ca_bundle(void) {
|
||||
return c && *c ? c : NULL;
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
(void)port;
|
||||
return -1;
|
||||
void plat_remove_old_tile(void) {}
|
||||
|
||||
int plat_app_installed(void) {
|
||||
const char *e = getenv("ROMM_SYNC_APP_INSTALLED");
|
||||
return e && *e == '1';
|
||||
}
|
||||
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
const char *e = getenv("ROMM_SYNC_APP_VERSION");
|
||||
str_copy(out, n, e ? e : "");
|
||||
}
|
||||
|
||||
int plat_launch_elf(const void *elf, size_t len) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
#include "console.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps4/emulators.json */
|
||||
|
||||
#define LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
|
||||
#define LOCK_FILE CONSOLE_LOCK_FILE
|
||||
|
||||
|
||||
/* libkernel: layout from sdk/samples/ps/main.c (title_id is wider than the PS5's) */
|
||||
@@ -67,10 +67,9 @@ static const plat_info_t INFO = {
|
||||
.retroarch_root = "/data/retroarch",
|
||||
.mednafen_root = "/data/mednafen",
|
||||
.emulator_catalog = EMULATORS_JSON,
|
||||
/* PS4 tiles need an installed package; there's no URL shortcut. */
|
||||
.has_tile = 0,
|
||||
/* GoldHEN runs the payload on a thread of its own process. */
|
||||
.detach = 1,
|
||||
.self_update = 1,
|
||||
};
|
||||
|
||||
const plat_info_t *plat_info(void) { return &INFO; }
|
||||
@@ -148,9 +147,12 @@ void plat_describe(char *buf, size_t n) {
|
||||
console_describe(buf, n, host);
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
(void)port;
|
||||
return -1;
|
||||
void plat_remove_old_tile(void) {}
|
||||
|
||||
int plat_app_installed(void) { return 0; }
|
||||
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
if (n) out[0] = 0;
|
||||
}
|
||||
|
||||
/* Not used (can_relaunch is 0): GoldHEN's network loader (9090) crashes on
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "avatar_png.h"
|
||||
#include "core/tween.hpp"
|
||||
#include "qrcodegen.h"
|
||||
#include "rommps_api.hpp"
|
||||
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
|
||||
|
||||
#include <algorithm>
|
||||
@@ -238,7 +239,7 @@ void CodecCall::go(int step, hui::ui::Feedback &feedback)
|
||||
{
|
||||
// The payload's page hosting YouTube's player, filling the browser
|
||||
// (an embed opened on its own fails with YouTube's error 153).
|
||||
const std::string page = std::string("http://127.0.0.1:8780/video?v=") + s.video;
|
||||
const std::string page = "http://127.0.0.1:" + std::to_string(payload_port()) + "/video?v=" + s.video;
|
||||
opened_ = open_in_browser(page.c_str());
|
||||
feedback.play(Cue::launch);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -39,6 +41,19 @@ constexpr std::size_t kListsKept = 24; // game lists kept at once: every tile on
|
||||
|
||||
void *g_renderer = nullptr;
|
||||
|
||||
// -1, 0 or 1 as a is older, the same as or newer than b ("1.2.3"); one that
|
||||
// isn't a version ("dev", "") counts as 0.0.0.
|
||||
int version_cmp(const std::string &a, const std::string &b)
|
||||
{
|
||||
int x[3] = {}, y[3] = {};
|
||||
std::sscanf(a.c_str(), "%d.%d.%d", &x[0], &x[1], &x[2]);
|
||||
std::sscanf(b.c_str(), "%d.%d.%d", &y[0], &y[1], &y[2]);
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if (x[i] != y[i])
|
||||
return x[i] < y[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string url_escape(const std::string &s)
|
||||
{
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
@@ -161,7 +176,7 @@ App &app()
|
||||
return instance;
|
||||
}
|
||||
|
||||
App::App() : api_("127.0.0.1", 8780)
|
||||
App::App() : api_("127.0.0.1", payload_port())
|
||||
{
|
||||
refresh_status();
|
||||
}
|
||||
@@ -331,6 +346,37 @@ void App::start_payload()
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void App::repair_autostart()
|
||||
{
|
||||
api_.get("/api/autostart", [this](const Response &r) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.json(), cJSON_Delete);
|
||||
if (!r.ok() || !j || !flag(j.get(), "supported"))
|
||||
return;
|
||||
// Setup used to leave autostart off unless it was turned on, so most
|
||||
// people never did. It's switched on once for anyone who never chose
|
||||
// either way; after that their choice stands.
|
||||
if (!flag(j.get(), "enabled") && cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(j.get(), "chosen")) &&
|
||||
status_.setup_complete)
|
||||
{
|
||||
install_autostart([this](const Response &a) {
|
||||
if (a.ok())
|
||||
say("RomM Sync now starts with the console. You can turn this off in Set up again.");
|
||||
});
|
||||
return;
|
||||
}
|
||||
bool old = false;
|
||||
const cJSON *c;
|
||||
cJSON_ArrayForEach(c, cJSON_GetObjectItemCaseSensitive(j.get(), "copies"))
|
||||
old |= version_cmp(str(c, "version"), kMinPayload) < 0;
|
||||
if (!old)
|
||||
return;
|
||||
api_.post("/api/autostart/install", bundled_payload(), [this](const Response &a) {
|
||||
if (!a.ok())
|
||||
say("Couldn't update RomM Sync's autostart: " + a.message());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void App::install_autostart(Api::Callback done)
|
||||
{
|
||||
api_.post("/api/autostart/upload", bundled_payload(), [this, done](const Response &r) {
|
||||
@@ -357,6 +403,20 @@ bool App::payload_too_old() const
|
||||
return false;
|
||||
}
|
||||
|
||||
// RomM Sync holds a lock on this file for as long as it runs (1.1.1 on); a
|
||||
// copy can be running and not answering yet, after a start or a wake.
|
||||
int App::payload_lock()
|
||||
{
|
||||
const int fd = ::open("/data/romm-sync/romm-sync.lock", O_RDONLY);
|
||||
if (fd < 0)
|
||||
return errno == ENOENT ? 0 : -1;
|
||||
int held = 0;
|
||||
if (::flock(fd, LOCK_SH | LOCK_NB) != 0)
|
||||
held = errno == EWOULDBLOCK ? 1 : -1;
|
||||
::close(fd);
|
||||
return held;
|
||||
}
|
||||
|
||||
void App::refresh_status()
|
||||
{
|
||||
status_timer_ = 0;
|
||||
@@ -365,12 +425,22 @@ void App::refresh_status()
|
||||
status_busy_ = false;
|
||||
status_.known = true;
|
||||
status_.reachable = r.status != 0;
|
||||
// Not running: RommPS starts the payload it carries, once by itself
|
||||
// (Cross on the screen tries again).
|
||||
// Not answering: RommPS starts the payload it carries, once by itself
|
||||
// (Cross on the screen tries again), but only when none is running.
|
||||
// One that's running is left to come up; with no way to tell, it
|
||||
// waits a few tries first.
|
||||
unanswered_ = status_.reachable ? 0 : unanswered_ + 1;
|
||||
payload_waiting_ = false;
|
||||
if (!status_.reachable && !tried_start_)
|
||||
{
|
||||
tried_start_ = true;
|
||||
start_payload();
|
||||
const int lock = payload_lock();
|
||||
if (lock == 1)
|
||||
payload_waiting_ = true;
|
||||
else if (lock == 0 || unanswered_ >= 3)
|
||||
{
|
||||
tried_start_ = true;
|
||||
start_payload();
|
||||
}
|
||||
}
|
||||
if (!r.ok())
|
||||
{
|
||||
@@ -382,20 +452,21 @@ void App::refresh_status()
|
||||
return;
|
||||
status_.error.clear();
|
||||
status_.version = str(j.get(), "version");
|
||||
// An older payload is replaced by the one RommPS carries, once it's
|
||||
// idle: its autostart copy first, if it has one, then the running one.
|
||||
if (payload_too_old() && !updated_payload_ && !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") &&
|
||||
active_downloads_ == 0)
|
||||
// An older payload is replaced by the one RommPS carries once it's
|
||||
// idle: the new one takes over from it and then, being this
|
||||
// RommPS's own, updates the copies on disk (below). A newer one is
|
||||
// left alone.
|
||||
const bool idle = !flag(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "running") && active_downloads_ == 0;
|
||||
if (payload_too_old() && !updated_payload_ && idle)
|
||||
{
|
||||
updated_payload_ = true;
|
||||
say("Updating RomM Sync to " + std::string(kMinPayload));
|
||||
api_.get("/api/autostart", [this](const Response &a) {
|
||||
std::unique_ptr<cJSON, void (*)(cJSON *)> as(a.json(), cJSON_Delete);
|
||||
if (a.ok() && flag(as.get(), "installed"))
|
||||
install_autostart([this](const Response &) { start_payload(); });
|
||||
else
|
||||
start_payload();
|
||||
});
|
||||
start_payload();
|
||||
}
|
||||
else if (!repaired_ && version_cmp(status_.version, kMinPayload) == 0)
|
||||
{
|
||||
repaired_ = true;
|
||||
repair_autostart();
|
||||
}
|
||||
status_.ip = str(j.get(), "ip");
|
||||
status_.port = static_cast<int>(num(j.get(), "web_port", 8780));
|
||||
@@ -831,6 +902,9 @@ void App::refresh_update()
|
||||
u.latest = str(j.get(), "latest");
|
||||
u.available = flag(j.get(), "available");
|
||||
u.autostart = flag(j.get(), "autostart");
|
||||
// Payloads before 1.1.1 don't say: they update themselves.
|
||||
const cJSON *su = cJSON_GetObjectItemCaseSensitive(j.get(), "self_update");
|
||||
u.self_update = !cJSON_IsBool(su) || cJSON_IsTrue(su);
|
||||
u.done = num(j.get(), "done");
|
||||
u.total = num(j.get(), "total");
|
||||
update_ = u;
|
||||
@@ -839,7 +913,8 @@ void App::refresh_update()
|
||||
if (u.state == "error")
|
||||
say("Update: " + (u.error.empty() ? u.message : u.error));
|
||||
else if (u.available)
|
||||
say("RomM Sync " + u.latest + " is available");
|
||||
say(u.self_update ? "RomM Sync " + u.latest + " is available"
|
||||
: "RommPS " + u.latest + " is available in ProsperoStore");
|
||||
else if (u.state == "idle")
|
||||
say("RomM Sync is up to date");
|
||||
else if (!u.message.empty())
|
||||
|
||||
@@ -188,6 +188,8 @@ struct Update
|
||||
bool known = false;
|
||||
std::string state, message, error, current, latest;
|
||||
bool available = false, autostart = false;
|
||||
// false: RomM Sync doesn't update itself; a new RommPS brings it (PS5).
|
||||
bool self_update = true;
|
||||
double done = 0, total = 0;
|
||||
bool busy() const { return state == "checking" || state == "downloading" || state == "installing" || state == "restarting"; }
|
||||
};
|
||||
@@ -204,11 +206,14 @@ class App
|
||||
const Status &status() const { return status_; }
|
||||
// RommPS carries the RomM Sync payload of its own release
|
||||
// (assets/rommps/romm-sync.elf, version kMinPayload): it starts it when
|
||||
// it isn't running, and puts it in place of an older one.
|
||||
static constexpr const char *kMinPayload = "1.1.0";
|
||||
// none is running, puts it in place of an older one (never of a newer
|
||||
// one), and keeps the copies the console starts it from up to date.
|
||||
static constexpr const char *kMinPayload = "1.1.1";
|
||||
bool payload_too_old() const;
|
||||
void start_payload(); // sends the payload to the HEN's loader
|
||||
bool payload_starting() const { return start_state_ == 1; }
|
||||
// Sending it, or RomM Sync is running and not answering yet (starting up,
|
||||
// or waking from rest mode): either way it'll answer in a moment.
|
||||
bool payload_starting() const { return start_state_ == 1 || payload_waiting_; }
|
||||
const std::string &payload_error() const { return start_error_; }
|
||||
// The payload into the HEN's autostart folder, so it starts with the console.
|
||||
void install_autostart(Api::Callback done);
|
||||
@@ -228,6 +233,13 @@ class App
|
||||
// A game's details; asks for them the first time.
|
||||
const Details &details(int rom_id);
|
||||
void refresh_installed();
|
||||
// Asks for the platforms again: which emulator plays each, and its
|
||||
// folder, change when emulators are set up here or in the web UI.
|
||||
void reload_platforms()
|
||||
{
|
||||
if (!platforms_busy_)
|
||||
refresh_platforms();
|
||||
}
|
||||
// A cover's texture and palette; asks for it the first time.
|
||||
const Cover &cover(const std::string &path);
|
||||
|
||||
@@ -269,6 +281,13 @@ class App
|
||||
|
||||
private:
|
||||
void refresh_status();
|
||||
// Whether a RomM Sync is running, from the lock it holds while it runs:
|
||||
// 1 yes, 0 no, -1 can't tell.
|
||||
static int payload_lock();
|
||||
// Brings the copies the console starts RomM Sync from up to the one
|
||||
// RommPS carries, leaving autostart on or off as it is; turns it on once
|
||||
// for anyone who never chose either way (1.1.0's setup left it off).
|
||||
void repair_autostart();
|
||||
void evict_covers(hui::gfx::Renderer *renderer);
|
||||
void send_page(const std::string &key, int page);
|
||||
void refresh_platforms();
|
||||
@@ -279,7 +298,7 @@ class App
|
||||
// for only while they're on screen (wanted_), newest first.
|
||||
static constexpr int kCoverWorkers = 3;
|
||||
static constexpr int kCoversInFlight = 6;
|
||||
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1:8780
|
||||
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1
|
||||
std::vector<std::string> wanted_; // drawn since the last tick, in draw order
|
||||
// Pages of games, on workers of their own too; asked for at the tick if
|
||||
// the screen still wants them.
|
||||
@@ -318,7 +337,9 @@ class App
|
||||
bool bundled_read_ = false;
|
||||
std::atomic<int> start_state_{0}; // 0 idle, 1 sending, 2 sent, 3 failed
|
||||
std::string start_error_; // written before start_state_ becomes 3
|
||||
bool tried_start_ = false, updated_payload_ = false;
|
||||
bool tried_start_ = false, updated_payload_ = false, repaired_ = false;
|
||||
bool payload_waiting_ = false;
|
||||
int unanswered_ = 0; // status requests in a row RomM Sync didn't answer
|
||||
int active_downloads_ = 0;
|
||||
std::string toast_;
|
||||
float toast_age_ = 0;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -136,7 +137,10 @@ class RommPS final : public app::Concept
|
||||
if (tab_ == kSettings)
|
||||
settings_.enter(app_);
|
||||
if (tab_ == kLibrary)
|
||||
{
|
||||
library_.enter(app_);
|
||||
app_.reload_platforms(); // emulators may have changed meanwhile
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settings' Emulators opens that setup step on its own.
|
||||
@@ -506,7 +510,7 @@ class RommPS final : public app::Concept
|
||||
const bool old_payload = s.reachable && app_.payload_too_old();
|
||||
std::string where = !s.known ? "Connecting"
|
||||
: !s.reachable ? "RomM Sync isn't running"
|
||||
: old_payload ? "RomM Sync " + s.version + " is too old: update it in Settings"
|
||||
: old_payload ? "Updating RomM Sync " + s.version
|
||||
: s.paired ? s.user + " @ " + s.server
|
||||
: "Not paired";
|
||||
const float dot_x = 1824 - 12 - ui::text(list, fonts.regular, context_.fonts.regular.font->fit(where, 22, 640), 1824, 90,
|
||||
@@ -568,9 +572,14 @@ class RommPS final : public app::Concept
|
||||
: s.conflicts > 1 ? std::to_string(s.conflicts) + " saves need you"
|
||||
: "Your saves are synced";
|
||||
ui::text(list, fonts.display, headline, x - 4, 304, 80, L.text);
|
||||
std::string line = s.syncing ? (s.phase.empty() ? "Working" : s.phase)
|
||||
: s.sync_count ? "Last sync " + ago(s.uptime - s.last_run) + " \xC2\xB7 " + s.last_result
|
||||
: "No sync yet";
|
||||
// Saves changed in both places are settled in the web UI, for now.
|
||||
char web[96];
|
||||
std::snprintf(web, sizeof web, "http://%s:%d", s.ip.c_str(), s.port);
|
||||
std::string line = s.syncing ? (s.phase.empty() ? "Working" : s.phase)
|
||||
: s.conflicts > 0 && !s.ip.empty() ? std::string("Choose which copy to keep at ") + web
|
||||
: s.sync_count ? "Last sync " + ago(std::difftime(std::time(nullptr), s.last_run)) +
|
||||
" \xC2\xB7 " + s.last_result
|
||||
: "No sync yet";
|
||||
ui::text(list, fonts.regular, context_.fonts.regular.font->fit(line, 28, 1500), x, 360, 28, L.muted);
|
||||
|
||||
// Sync now
|
||||
|
||||
@@ -89,7 +89,18 @@ std::vector<Row> SettingsPage::rows(const App &app) const
|
||||
Row &update = r[kUpdate];
|
||||
update.kind = RowKind::action;
|
||||
update.label = "RomM Sync " + (u.current.empty() ? app.status().version : u.current);
|
||||
if (u.busy())
|
||||
if (!u.self_update)
|
||||
{
|
||||
// RommPS brings RomM Sync with it: the store updates both.
|
||||
update.label = std::string("RommPS ") + App::kMinPayload;
|
||||
update.value = u.state == "checking" ? "Checking" : "Check for updates";
|
||||
update.enabled = u.state != "checking";
|
||||
update.detail = u.available ? "RommPS " + u.latest + " is out: update it in ProsperoStore"
|
||||
: u.state == "error" ? (u.error.empty() ? u.message : u.error)
|
||||
: u.latest.empty() ? "Updates come from ProsperoStore, with RomM Sync in them"
|
||||
: "Up to date";
|
||||
}
|
||||
else if (u.busy())
|
||||
{
|
||||
update.value = u.message.empty() ? "Working" : u.message;
|
||||
if (u.total > 0)
|
||||
@@ -230,7 +241,7 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui:
|
||||
app.set_config(flag_json("cover_cache", !c.cover_cache), [](Config &k) { k.cover_cache = !k.cover_cache; });
|
||||
break;
|
||||
case kUpdate:
|
||||
if (app.update().available)
|
||||
if (app.update().available && app.update().self_update)
|
||||
app.install_update();
|
||||
else
|
||||
app.check_update();
|
||||
@@ -286,7 +297,7 @@ void SettingsPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, c
|
||||
std::snprintf(web, sizeof web, "http://%s:%d", s.ip.c_str(), s.port);
|
||||
item("ALSO ON YOUR PHONE OR COMPUTER", s.ip.empty() ? "" : web);
|
||||
const Update &u = app.update();
|
||||
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No, load romm-sync.elf after each boot");
|
||||
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No: turn it on in Set up again");
|
||||
list.line(panel.x + 36, panel.y + panel.h - 196, panel.x + panel.w - 36, panel.y + panel.h - 196, 1.5f, look.outline);
|
||||
draw_credits(list, fonts, look, {x, panel.y + panel.h - 168, panel.w - 72, 136}, on_credits_, clock);
|
||||
list.pop_opacity();
|
||||
|
||||
@@ -871,6 +871,7 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d
|
||||
busy_ = false;
|
||||
a->say("Emulators saved");
|
||||
a->refresh_config();
|
||||
a->reload_platforms();
|
||||
return;
|
||||
}
|
||||
go(kPreview);
|
||||
@@ -940,11 +941,19 @@ void SetupPage::update_preview(App &app, const hui::InputFrame &input, float dt,
|
||||
|
||||
void SetupPage::load_autostart(App &app)
|
||||
{
|
||||
app.get("/api/autostart", [this](const Response &r) {
|
||||
app.get("/api/autostart", [this, &app](const Response &r) {
|
||||
Json j(r.json(), cJSON_Delete);
|
||||
autostart_supported_ = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j.get(), "supported"));
|
||||
autostart_installed_ = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j.get(), "installed"));
|
||||
autostart_on_ = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j.get(), "enabled"));
|
||||
// On by default the first time: RomM Sync then starts with the
|
||||
// console and nobody has to load it. Setting up again keeps the choice.
|
||||
if (autostart_supported_ && !autostart_on_ && !autostart_defaulted_ && !app.status().setup_complete)
|
||||
{
|
||||
autostart_defaulted_ = true;
|
||||
autostart_on_ = true;
|
||||
app.install_autostart([this, &app](const Response &) { load_autostart(app); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1011,13 +1020,14 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt,
|
||||
if (autostart_row)
|
||||
{
|
||||
autostart_on_ = !autostart_on_;
|
||||
// Turned on with no payload in the HEN's folder yet: the one RommPS carries goes there.
|
||||
if (autostart_on_ && !autostart_installed_)
|
||||
// On: the payload RommPS carries goes to every HEN's folder that
|
||||
// lacks it or has an older one, and autostart is turned on there.
|
||||
if (autostart_on_)
|
||||
{
|
||||
app.install_autostart([this, &app](const Response &) { load_autostart(app); });
|
||||
break;
|
||||
}
|
||||
app.post("/api/autostart", autostart_on_ ? "{\"enable\":true}" : "{\"enable\":false}",
|
||||
app.post("/api/autostart", "{\"enable\":false}",
|
||||
[this, &app](const Response &r) {
|
||||
if (!r.ok())
|
||||
app.say("Couldn't change the autostart: " + r.message());
|
||||
|
||||
@@ -183,6 +183,7 @@ class SetupPage
|
||||
Config finish_;
|
||||
bool finish_known_ = false;
|
||||
bool autostart_supported_ = false, autostart_installed_ = false, autostart_on_ = false;
|
||||
bool autostart_defaulted_ = false; // turned on for a first setup already
|
||||
};
|
||||
|
||||
} // namespace rommps
|
||||
@@ -10,7 +10,7 @@
|
||||
"conceptId": "76677",
|
||||
"contentBadgeType": 1,
|
||||
"contentId": "UP9000-PPSA76677_00-ROMMPS0000000000",
|
||||
"contentVersion": "01.001.000",
|
||||
"contentVersion": "01.001.001",
|
||||
"downloadDataSize": 256,
|
||||
"gameIntent": {
|
||||
"permittedIntents": [
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "cJSON.h"
|
||||
@@ -95,6 +96,29 @@ Response http_request(const std::string &host, int port, const std::string &meth
|
||||
return r;
|
||||
}
|
||||
|
||||
int payload_port()
|
||||
{
|
||||
static const int port = [] {
|
||||
int p = 8780;
|
||||
if (FILE *f = std::fopen("/data/romm-sync/config.json", "rb"))
|
||||
{
|
||||
char buf[8192];
|
||||
const size_t n = std::fread(buf, 1, sizeof buf - 1, f);
|
||||
std::fclose(f);
|
||||
buf[n] = 0;
|
||||
if (const char *k = std::strstr(buf, "\"web_port\""))
|
||||
if (const char *colon = std::strchr(k, ':'))
|
||||
{
|
||||
const long v = std::strtol(colon + 1, nullptr, 10);
|
||||
if (v > 0 && v < 65536)
|
||||
p = static_cast<int>(v);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}();
|
||||
return port;
|
||||
}
|
||||
|
||||
Api::Api(std::string host, int port) : host_(std::move(host)), port_(port), thread_(&Api::worker, this) {}
|
||||
|
||||
Api::~Api() {
|
||||
|
||||
@@ -37,11 +37,14 @@ struct Response {
|
||||
Response http_request(const std::string &host, int port, const std::string &method, const std::string &path,
|
||||
const std::string &body = "", int timeout_ms = 15000);
|
||||
|
||||
// The port RomM Sync's API is on: web_port in its config.json, 8780 by default.
|
||||
int payload_port();
|
||||
|
||||
class Api {
|
||||
public:
|
||||
using Callback = std::function<void(const Response &)>;
|
||||
|
||||
explicit Api(std::string host = "127.0.0.1", int port = 8780);
|
||||
explicit Api(std::string host = "127.0.0.1", int port = payload_port());
|
||||
~Api();
|
||||
Api(const Api &) = delete;
|
||||
Api &operator=(const Api &) = delete;
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
make-title-art.py OUT.png
|
||||
|
||||
A 3840x2160 picture for sce_sys/pic0.dds and pic1.dds (tools/title-art.sh
|
||||
turns it into BC7): RommPS's Acrylic aurora, a shelf of abstract cover cards
|
||||
easing into the distance, and the RommPS wordmark in Montserrat (OFL,
|
||||
third_party/Montserrat-Medium.ttf). No game's art: every shape is drawn here.
|
||||
turns it into BC7): deep space with soft nebulae, a spiral galaxy swirling on
|
||||
the right, a field of stars, and the RommPS wordmark in Montserrat (OFL,
|
||||
third_party/Montserrat-Medium.ttf), its "PS" lit in the app's cyan and violet.
|
||||
Every shape is drawn here.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,111 +21,177 @@ from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
W, H = 3840, 2160
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
FONT = REPO / "third_party/Montserrat-Medium.ttf"
|
||||
CYAN, VIOLET, BLUE = 0x76d6ff, 0xc04bd6, 0x3a5bd9
|
||||
|
||||
|
||||
def hex_rgb(value):
|
||||
return np.array([(value >> 16) & 255, (value >> 8) & 255, value & 255], dtype=np.float32)
|
||||
|
||||
|
||||
def aurora():
|
||||
"""The app's Acrylic backdrop: deep navy, with blue and violet light."""
|
||||
def blur(field, radius):
|
||||
"""A float field blurred, through Pillow (values kept in 0..1)."""
|
||||
img = Image.fromarray(np.clip(field * 255, 0, 255).astype(np.uint8), "L")
|
||||
return np.asarray(img.filter(ImageFilter.GaussianBlur(radius)), dtype=np.float32) / 255
|
||||
|
||||
|
||||
def noise(rng, cells, radius):
|
||||
"""Smooth clouds: random cells scaled up and softened."""
|
||||
small = Image.fromarray((rng.random((cells[1], cells[0])) * 255).astype(np.uint8), "L")
|
||||
big = small.resize((W, H), Image.BICUBIC).filter(ImageFilter.GaussianBlur(radius))
|
||||
return np.asarray(big, dtype=np.float32) / 255
|
||||
|
||||
|
||||
def space(rng):
|
||||
"""Deep navy, with nebulae in the app's blue, violet and cyan."""
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
u, v = x / W, y / H
|
||||
base = hex_rgb(0x070a1c) * (1 - v[..., None]) + hex_rgb(0x0b1026) * v[..., None]
|
||||
img = base.copy()
|
||||
# Soft lights: centre, radius, colour, strength.
|
||||
for cx, cy, r, colour, k in [
|
||||
(0.78, 0.18, 0.55, 0x3a5bd9, 0.85),
|
||||
(0.95, 0.70, 0.45, 0xc04bd6, 0.55),
|
||||
(0.30, 0.95, 0.60, 0x1b1f4a, 0.90),
|
||||
(0.55, 0.45, 0.35, 0x76d6ff, 0.18),
|
||||
]:
|
||||
d = np.sqrt(((u - cx) * (W / H)) ** 2 + (v - cy) ** 2) / r
|
||||
fall = np.exp(-d * d * 2.2)[..., None] * k
|
||||
img = img * (1 - fall) + hex_rgb(colour) * fall
|
||||
# Aurora ribbons: sine bands across the upper half.
|
||||
for phase, colour, k in [(0.0, 0x76d6ff, 0.10), (1.7, 0xc04bd6, 0.08)]:
|
||||
band = 0.30 + 0.06 * np.sin(u * 5.0 + phase) + 0.03 * np.sin(u * 13.0 + phase * 2)
|
||||
d = (v - band) / 0.05
|
||||
fall = (np.exp(-d * d) * (0.4 + 0.6 * u))[..., None] * k
|
||||
img = img + hex_rgb(colour) * fall
|
||||
# A whisper of grain, so the gradients never band.
|
||||
rng = np.random.default_rng(7)
|
||||
img += rng.normal(0, 1.2, img.shape).astype(np.float32)
|
||||
return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
||||
img = hex_rgb(0x03050d) * (1 - v[..., None]) + hex_rgb(0x090c20) * v[..., None]
|
||||
clouds = 0.55 * noise(rng, (8, 5), 260) + 0.30 * noise(rng, (16, 9), 140) + 0.15 * noise(rng, (32, 18), 70)
|
||||
clouds = np.clip((clouds - 0.40) * 2.6, 0, 1) ** 1.8
|
||||
# Brighter towards the galaxy, quiet behind the wordmark.
|
||||
near = np.exp(-(((u - 0.70) * W / H) ** 2 + (v - 0.42) ** 2) / 0.35)
|
||||
for colour, k, shift in [(BLUE, 0.55, 0.0), (VIOLET, 0.40, 0.25), (CYAN, 0.18, 0.5)]:
|
||||
tint = np.roll(clouds, int(shift * W / 3), axis=1) * (0.25 + 0.75 * near)
|
||||
img = img + hex_rgb(colour) * (tint * k)[..., None]
|
||||
return img
|
||||
|
||||
|
||||
def cards(canvas):
|
||||
"""A shelf of cover-shaped cards on the right, receding and fading."""
|
||||
rnd = random.Random(42)
|
||||
hues = [0x3a5bd9, 0xc04bd6, 0x76d6ff, 0x4ade80, 0xfbbf24, 0xf87171, 0x8b5cf6, 0x22d3ee]
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
def galaxy(rng, img):
|
||||
"""A two-armed spiral seen at a slant, its arms trailing into the swirl."""
|
||||
cx, cy, tilt, squash = 0.68 * W, 0.43 * H, math.radians(-22), 0.52
|
||||
stars = np.zeros((H, W), dtype=np.float32)
|
||||
colour = np.zeros((H, W, 3), dtype=np.float32)
|
||||
|
||||
def place(r, theta, bright, rgb):
|
||||
# The disc: a circle squashed into an ellipse, then tilted.
|
||||
dx, dy = r * np.cos(theta), r * np.sin(theta) * squash
|
||||
x = cx + dx * math.cos(tilt) - dy * math.sin(tilt)
|
||||
y = cy + dx * math.sin(tilt) + dy * math.cos(tilt)
|
||||
keep = (x >= 0) & (x < W) & (y >= 0) & (y < H)
|
||||
xi, yi = x[keep].astype(int), y[keep].astype(int)
|
||||
np.add.at(stars, (yi, xi), bright[keep])
|
||||
np.add.at(colour, (yi, xi), rgb[None, :] * bright[keep, None])
|
||||
|
||||
n = 160000
|
||||
for arm in range(2):
|
||||
t = rng.random(n) ** 0.8 * 4.2 * math.pi # how far round the arm
|
||||
r = 95 * np.exp(0.185 * t) # a logarithmic spiral
|
||||
spread = 0.07 + 0.05 * t / (4.2 * math.pi)
|
||||
theta = t + arm * math.pi + rng.normal(0, spread, n) * (1 + t / 8)
|
||||
r = r * (1 + rng.normal(0, 0.10, n))
|
||||
bright = (0.15 + rng.random(n) ** 3) * (1.3 - 0.8 * t / (4.2 * math.pi))
|
||||
place(r, theta, bright, hex_rgb(0xcfdcff))
|
||||
# The outer arms take the app's colours.
|
||||
far = t > 2.2 * math.pi
|
||||
place(r[far] * 1.01, theta[far] + 0.02, bright[far] * 0.6, hex_rgb(CYAN if arm else VIOLET))
|
||||
# The bulge: a dense, warm core.
|
||||
m = 40000
|
||||
r = np.abs(rng.normal(0, 150, m))
|
||||
place(r, rng.random(m) * 2 * math.pi, rng.random(m) ** 2 * 0.5, hex_rgb(0xffe6c4))
|
||||
|
||||
norm = np.maximum(stars, 1e-6)[..., None]
|
||||
tint = colour / norm
|
||||
sharp = np.clip(stars, 0, 1)
|
||||
haze = blur(np.clip(stars * 0.08, 0, 1), 40) * 6 + blur(np.clip(stars * 0.2, 0, 1), 10) * 2.5
|
||||
glow_tint = np.stack([blur(np.clip(tint[..., c] / 255 * np.clip(stars, 0, 1), 0, 1), 30) for c in range(3)], -1)
|
||||
glow_tint = glow_tint / np.maximum(blur(sharp, 30), 1e-3)[..., None] * 255
|
||||
img = img + glow_tint * np.clip(haze, 0, 1.5)[..., None] * 0.55
|
||||
img = img + tint * sharp[..., None] * 0.9
|
||||
# The core's light, falling off over the whole disc.
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
dx, dy = x - cx, y - cy
|
||||
rx = dx * math.cos(-tilt) - dy * math.sin(-tilt)
|
||||
ry = (dx * math.sin(-tilt) + dy * math.cos(-tilt)) / squash
|
||||
d = np.sqrt(rx * rx + ry * ry)
|
||||
img = img + hex_rgb(0xfff1dc) * (np.exp(-d / 45) * 0.75 + np.exp(-d / 220) * 0.28)[..., None]
|
||||
img = img + hex_rgb(BLUE) * (np.exp(-d / 1100) * 0.22)[..., None]
|
||||
return img
|
||||
|
||||
|
||||
def starfield(rng, img):
|
||||
"""Stars across the sky; a few bright ones with a soft cross."""
|
||||
n = 14000
|
||||
x, y = rng.integers(0, W, n), rng.integers(0, H, n)
|
||||
bright = 0.12 + 0.88 * rng.random(n) ** 5
|
||||
field = np.zeros((H, W), dtype=np.float32)
|
||||
np.add.at(field, (y, x), bright * 1.4)
|
||||
img = img + 255 * np.clip(field, 0, 1)[..., None] + 255 * blur(np.clip(field * 0.5, 0, 1), 2)[..., None] * 2
|
||||
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
gd, ld = ImageDraw.Draw(glow), ImageDraw.Draw(layer)
|
||||
base_y = 1380
|
||||
for i in range(9):
|
||||
t = i / 8
|
||||
scale = 1.0 - 0.55 * t # nearer cards first, from the left
|
||||
w, h = 430 * scale, 573 * scale
|
||||
x = 1880 + 520 * i * (1.0 - 0.32 * t)
|
||||
y = base_y - h + 40 * t
|
||||
alpha = int(255 * (0.92 - 0.6 * t))
|
||||
top, bottom = hex_rgb(hues[i % len(hues)]), hex_rgb(hues[(i + 3) % len(hues)]) * 0.45
|
||||
# The card: a vertical gradient, rounded, with a light edge.
|
||||
card = Image.new("RGBA", (int(w), int(h)))
|
||||
grad = np.linspace(0, 1, int(h), dtype=np.float32)[:, None, None]
|
||||
rgb = top * (1 - grad) + bottom * grad
|
||||
rgb = np.broadcast_to(rgb, (int(h), int(w), 3))
|
||||
a = np.full((int(h), int(w), 1), alpha, dtype=np.float32)
|
||||
card = Image.fromarray(np.concatenate([rgb, a], axis=2).astype(np.uint8), "RGBA")
|
||||
mask = Image.new("L", card.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, card.size[0] - 1, card.size[1] - 1], radius=int(34 * scale),
|
||||
fill=alpha)
|
||||
layer.paste(card, (int(x), int(y)), mask)
|
||||
ld.rounded_rectangle([x, y, x + w, y + h], radius=int(34 * scale), outline=(255, 255, 255, int(alpha * 0.35)),
|
||||
width=max(2, int(4 * scale)))
|
||||
gd.rounded_rectangle([x - 30, y - 30, x + w + 30, y + h + 30], radius=int(60 * scale),
|
||||
fill=tuple(int(c) for c in top) + (int(alpha * 0.45),))
|
||||
# The focus ring on the first card, as the app draws it.
|
||||
if i == 0:
|
||||
ld.rounded_rectangle([x - 16, y - 16, x + w + 16, y + h + 16], radius=int(46 * scale),
|
||||
outline=(255, 255, 255, 240), width=10)
|
||||
# A reflection fading into the floor.
|
||||
refl = card.transpose(Image.FLIP_TOP_BOTTOM).crop((0, 0, card.size[0], int(h * 0.35)))
|
||||
fade = np.linspace(0.22, 0, refl.size[1], dtype=np.float32)[:, None] * alpha
|
||||
rmask = Image.fromarray(np.broadcast_to(fade, (refl.size[1], refl.size[0])).astype(np.uint8), "L")
|
||||
layer.paste(refl, (int(x), int(y + h + 18)), rmask)
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(70))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(layer)
|
||||
# The shelf's far end dissolves into the backdrop.
|
||||
fade = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ramp = np.clip((np.arange(W, dtype=np.float32) - 3000) / 840, 0, 1) ** 1.5
|
||||
alpha = np.broadcast_to((ramp * 200)[None, :], (H, W)).astype(np.uint8)
|
||||
fade.putalpha(Image.fromarray(alpha, "L"))
|
||||
tint = Image.new("RGBA", (W, H), (11, 16, 38, 255))
|
||||
tint.putalpha(Image.fromarray(alpha, "L"))
|
||||
canvas.alpha_composite(tint)
|
||||
draw = ImageDraw.Draw(layer)
|
||||
for _ in range(26):
|
||||
sx, sy, s = rng.integers(0, W), rng.integers(0, H), rng.uniform(10, 34)
|
||||
rgb = tuple(int(c) for c in [hex_rgb(0xffffff), hex_rgb(CYAN), hex_rgb(0xffe6c4)][rng.integers(0, 3)])
|
||||
draw.line([(sx - s, sy), (sx + s, sy)], fill=rgb + (150,), width=2)
|
||||
draw.line([(sx, sy - s), (sx, sy + s)], fill=rgb + (150,), width=2)
|
||||
draw.ellipse([sx - 4, sy - 4, sx + 4, sy + 4], fill=rgb + (255,))
|
||||
glow = layer.filter(ImageFilter.GaussianBlur(5))
|
||||
out = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB").convert("RGBA")
|
||||
out.alpha_composite(glow)
|
||||
out.alpha_composite(layer)
|
||||
return out
|
||||
|
||||
|
||||
def swirl(rng, canvas):
|
||||
"""Faint star trails arcing round the galaxy, as on a long exposure."""
|
||||
cx, cy = 0.68 * W, 0.43 * H
|
||||
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
for _ in range(420):
|
||||
r = rng.uniform(600, 2600)
|
||||
start = rng.uniform(0, 360)
|
||||
length = rng.uniform(4, 14) * (1100 / r) # degrees: inner trails sweep further
|
||||
rgb = [hex_rgb(0xffffff), hex_rgb(CYAN), hex_rgb(VIOLET), hex_rgb(0xcfdcff)][rng.integers(0, 4)]
|
||||
alpha = int(rng.uniform(25, 90) * min(1, 900 / r + 0.3))
|
||||
box = [cx - r, cy - r * 0.62, cx + r, cy + r * 0.62]
|
||||
draw.arc(box, start, start + length, fill=tuple(int(c) for c in rgb) + (alpha,), width=2)
|
||||
canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(1.2)))
|
||||
|
||||
|
||||
def shade(canvas):
|
||||
"""Darker towards the lower left, so the wordmark reads."""
|
||||
y, x = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
d = np.sqrt(((x / W - 0.0) * 1.2) ** 2 + ((y / H - 1.0) * 1.0) ** 2)
|
||||
a = (np.clip(1 - d / 0.75, 0, 1) ** 1.5 * 150).astype(np.uint8)
|
||||
dark = Image.new("RGBA", (W, H), (2, 3, 10, 255))
|
||||
dark.putalpha(Image.fromarray(a, "L"))
|
||||
canvas.alpha_composite(dark)
|
||||
|
||||
|
||||
def wordmark(canvas):
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
big = ImageFont.truetype(str(FONT), 300)
|
||||
small = ImageFont.truetype(str(FONT), 76)
|
||||
x, y = 250, 1560
|
||||
# A soft shadow, then the name.
|
||||
""""Romm" in white, "PS" filled cyan to violet, with a glow of its own."""
|
||||
font = ImageFont.truetype(str(FONT), 300)
|
||||
x, y = 250, 1700
|
||||
romm_w = font.getlength("Romm")
|
||||
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(shadow).text((x, y + 18), "RommPS", font=big, fill=(0, 0, 0, 170), anchor="ls")
|
||||
ImageDraw.Draw(shadow).text((x, y + 18), "RommPS", font=font, fill=(0, 0, 0, 180), anchor="ls")
|
||||
canvas.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(26)))
|
||||
draw.text((x, y), "RommPS", font=big, fill=(255, 255, 255, 255), anchor="ls")
|
||||
draw.rounded_rectangle([x + 8, y + 54, x + 8 + 280, y + 66], radius=6, fill=(118, 214, 255, 255))
|
||||
draw.text((x + 8, y + 190), "Your RomM library and save sync", font=small, fill=(255, 255, 255, 190), anchor="ls")
|
||||
ImageDraw.Draw(canvas).text((x, y), "Romm", font=font, fill=(255, 255, 255, 255), anchor="ls")
|
||||
|
||||
# The letters' shape, filled with a diagonal gradient.
|
||||
mask = Image.new("L", (W, H), 0)
|
||||
ImageDraw.Draw(mask).text((x + romm_w, y), "PS", font=font, fill=255, anchor="ls")
|
||||
yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
left, width = x + romm_w, font.getlength("PS")
|
||||
t = np.clip(((xx - left) / width) * 0.75 + ((y - yy) / 220) * 0.25, 0, 1)[..., None]
|
||||
fill = hex_rgb(CYAN) * (1 - t) + hex_rgb(VIOLET) * t
|
||||
gradient = Image.fromarray(fill.astype(np.uint8), "RGB").convert("RGBA")
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
glow.paste(gradient, (0, 0), mask.filter(ImageFilter.MaxFilter(9)))
|
||||
canvas.alpha_composite(glow.filter(ImageFilter.GaussianBlur(34)))
|
||||
letters = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
letters.paste(gradient, (0, 0), mask)
|
||||
canvas.alpha_composite(letters)
|
||||
|
||||
|
||||
def main():
|
||||
out = Path(sys.argv[1])
|
||||
canvas = aurora()
|
||||
cards(canvas)
|
||||
rng = np.random.default_rng(11)
|
||||
img = space(rng)
|
||||
img = galaxy(rng, img)
|
||||
img += rng.normal(0, 1.2, img.shape).astype(np.float32) # grain, so gradients never band
|
||||
canvas = starfield(rng, img)
|
||||
swirl(rng, canvas)
|
||||
shade(canvas)
|
||||
wordmark(canvas)
|
||||
canvas.convert("RGB").convert("RGBA").save(out)
|
||||
print(f"title art: {out} ({W}x{H})")
|
||||
|
||||
+57
-103
@@ -1,9 +1,9 @@
|
||||
/* platform.h for the PS5 (etaHEN, ps5-payload-dev SDK). The parts shared with
|
||||
* the PS4 are in platform/console/console.c.
|
||||
*
|
||||
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: the home screen tile
|
||||
* (ftpsrv install-ps5.c) and the foreground app (websrv). Links against
|
||||
* SceSystemService and SceAppInstUtil. */
|
||||
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: removing the old home
|
||||
* screen tile (ftpsrv install-ps5.c) and the foreground app (websrv). Links
|
||||
* against SceSystemService and SceAppInstUtil. */
|
||||
#include "platform.h"
|
||||
#include "emulators_json.h" /* generated from platform/ps5/emulators.json */
|
||||
|
||||
@@ -20,11 +20,12 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "console.h"
|
||||
#include "util.h" /* APP_VERSION */
|
||||
|
||||
#define ROMM_TITLE_ID "ROMM00001"
|
||||
#define ROMM_TITLE_NAME "RomM Sync"
|
||||
#define ROMM_APP_DIR "/user/app/" ROMM_TITLE_ID
|
||||
#define ROMM_APPMETA "/user/appmeta/" ROMM_TITLE_ID "/param.json"
|
||||
/* The "RomM Sync" tile versions before 1.1.1 installed, opening the web UI,
|
||||
* and RommPS, which took its place. */
|
||||
#define OLD_TILE_ID "ROMM00001"
|
||||
#define ROMMPS_ID "PPSA76677"
|
||||
|
||||
/* AuthID used by ftpsrv/websrv (ShellCore-like privileges). */
|
||||
#define ROMM_AUTHID 0x4801000000000013L
|
||||
@@ -46,7 +47,6 @@ int sceSystemServiceGetAppTitleId(int app_id, char *title_id);
|
||||
|
||||
/* libSceAppInstUtil: ftpsrv install-ps5.c */
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
int sceAppInstUtilAppUnInstall(const char *);
|
||||
|
||||
|
||||
@@ -60,10 +60,12 @@ static const char *const EMULATOR_DIRS[] = {NULL};
|
||||
* next to it. onionHEN (github.com/aydencharles/onionHEN) keeps its runtime
|
||||
* state in /system_tmp/onionhen, which is gone after a reboot, so that tells
|
||||
* the one running now; they refuse to run together. Both leave the ELF loader
|
||||
* on 9021 to us. An update replaces RomM Sync in the other HEN's folder too,
|
||||
* so switching HENs never brings back an old copy. */
|
||||
* on 9021 to us. Autostart is set up in both HENs' folders, so RomM Sync
|
||||
* starts with either, and switching HENs never brings back an old copy. */
|
||||
static const char *const ETAHEN_ALSO[] = {"/data/OnionHEN/payloads", NULL};
|
||||
static const char *const ONIONHEN_ALSO[] = {"/data/etaHEN/payloads", NULL};
|
||||
/* pldmgr's autoload starts the copies it keeps here. */
|
||||
static const char *const PAYLOAD_COPIES[] = {"/data/pldmgr/payloads/romm-sync/romm-sync.elf", NULL};
|
||||
|
||||
static plat_info_t INFO = {
|
||||
.name = "ps5",
|
||||
@@ -76,14 +78,15 @@ static plat_info_t INFO = {
|
||||
.autostart_dir = "/data/etaHEN/payloads",
|
||||
.autostart_flag = "romm-sync.elf.auto_start",
|
||||
.autostart_also = ETAHEN_ALSO,
|
||||
.payload_copies = PAYLOAD_COPIES,
|
||||
.loader_dir = "/data/etaHEN",
|
||||
.homebrew_dirs = HOMEBREW_DIRS,
|
||||
.emulator_dirs = EMULATOR_DIRS,
|
||||
.emulator_catalog = EMULATORS_JSON,
|
||||
.retroarch_root = "/data/homebrew/PPSA99169",
|
||||
.mednafen_root = "/data/homebrew/Mednafen",
|
||||
.has_tile = 1,
|
||||
.can_relaunch = 1,
|
||||
.self_update = 0,
|
||||
};
|
||||
|
||||
static int is_dir(const char *path) {
|
||||
@@ -110,12 +113,12 @@ const plat_info_t *plat_info(void) {
|
||||
|
||||
int plat_init(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
console_single_instance();
|
||||
|
||||
/* Elevated AuthID as ftpsrv/websrv do; needed for /user/app writes and
|
||||
* AppInstUtil. Non-fatal: kernel_* returns -1 without kernel R/W. */
|
||||
/* Elevated AuthID as ftpsrv/websrv do; needed for AppInstUtil.
|
||||
* Non-fatal: kernel_* returns -1 without kernel R/W. */
|
||||
kernel_set_ucred_authid(getpid(), ROMM_AUTHID);
|
||||
|
||||
if (console_single_instance(APP_VERSION)) return 1;
|
||||
if (console_mkdir_p(CONSOLE_DATA_DIR)) return -1;
|
||||
return 0;
|
||||
}
|
||||
@@ -159,56 +162,6 @@ int plat_running_title(char *title_id, size_t n) {
|
||||
}
|
||||
|
||||
|
||||
static int write_file(const char *path, const void *data, size_t size) {
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return -1;
|
||||
if (size && fwrite(data, size, 1, f) != 1) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
return fclose(f) ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Returns malloc'd contents (NUL-terminated) or NULL. */
|
||||
static char *read_file(const char *path, size_t *size) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
char *buf = NULL;
|
||||
long len;
|
||||
if (!f) return NULL;
|
||||
if (fseek(f, 0, SEEK_END) || (len = ftell(f)) < 0 || len > 16 * 1024 * 1024 ||
|
||||
fseek(f, 0, SEEK_SET) || !(buf = malloc((size_t)len + 1)) ||
|
||||
(len && fread(buf, (size_t)len, 1, f) != 1)) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
fclose(f);
|
||||
buf[len] = 0;
|
||||
if (size) *size = (size_t)len;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int copy_file(const char *src, const char *dst) {
|
||||
size_t size;
|
||||
char *data = read_file(src, &size);
|
||||
int r;
|
||||
if (!data) return -1;
|
||||
r = write_file(dst, data, size);
|
||||
free(data);
|
||||
return r;
|
||||
}
|
||||
|
||||
static int install_title_dir(const char *title_id, const char *dir) {
|
||||
int (*install_dir)(const char *, const char *, void *) = NULL;
|
||||
const char *nid = "Wudg3Xe3heE"; /* sceAppInstUtilAppInstallTitleDir */
|
||||
uint32_t handle;
|
||||
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle))
|
||||
install_dir = (void *)kernel_dynlib_resolve(-1, handle, nid);
|
||||
if (install_dir) return install_dir(title_id, dir, NULL);
|
||||
return sceAppInstUtilAppInstallAll(NULL);
|
||||
}
|
||||
|
||||
void plat_describe(char *buf, size_t n) {
|
||||
app_info_t info;
|
||||
char host[16] = "";
|
||||
@@ -217,49 +170,50 @@ void plat_describe(char *buf, size_t n) {
|
||||
console_describe(buf, n, host);
|
||||
}
|
||||
|
||||
int plat_install_tile(int port) {
|
||||
char param[512];
|
||||
struct stat st;
|
||||
char *old;
|
||||
int installed, have_icon, err;
|
||||
static int on_home_screen(const char *title_id) {
|
||||
char app[64], meta[64];
|
||||
snprintf(app, sizeof app, "/user/app/%s", title_id);
|
||||
snprintf(meta, sizeof meta, "/user/appmeta/%s", title_id);
|
||||
return dir_exists(app) || dir_exists(meta);
|
||||
}
|
||||
|
||||
if (port <= 0 || port > 65535) return -1;
|
||||
int plat_app_installed(void) { return on_home_screen(ROMMPS_ID); }
|
||||
|
||||
snprintf(param, sizeof param,
|
||||
"{\n"
|
||||
" \"titleId\": \"" ROMM_TITLE_ID "\",\n"
|
||||
" \"deeplinkUri\": \"http://127.0.0.1:%d/\",\n"
|
||||
" \"localizedParameters\": {\n"
|
||||
" \"defaultLanguage\": \"en-US\",\n"
|
||||
" \"en-US\": {\n"
|
||||
" \"titleName\": \"" ROMM_TITLE_NAME "\"\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
port);
|
||||
|
||||
installed = !stat(ROMM_APPMETA, &st);
|
||||
have_icon = !stat(CONSOLE_ICON, &st);
|
||||
|
||||
/* Already installed with identical param.json (same port) -> nothing to do. */
|
||||
if (installed && (old = read_file(ROMM_APP_DIR "/sce_sys/param.json", NULL))) {
|
||||
int same = !strcmp(old, param);
|
||||
free(old);
|
||||
if (same && (!have_icon || !stat(ROMM_APP_DIR "/sce_sys/icon0.png", &st)))
|
||||
return 0;
|
||||
/* From its param.json: contentVersion "01.001.000" is 1.1.0. The app's own
|
||||
* folder first (mount.lnk names it when ShadowMount mounts it from
|
||||
* /data/homebrew): copying a new version over it leaves the home screen's
|
||||
* copies in /user/app and /user/appmeta at the old one. */
|
||||
void plat_app_version(char *out, size_t n) {
|
||||
char params[3][PATH_MAX_LEN] = {"", "/user/app/" ROMMPS_ID "/sce_sys/param.json", "/user/appmeta/" ROMMPS_ID "/param.json"};
|
||||
char *link = read_file("/user/app/" ROMMPS_ID "/mount.lnk", NULL);
|
||||
if (link) {
|
||||
link[strcspn(link, "\r\n")] = 0;
|
||||
if (link[0] == '/') snprintf(params[0], sizeof params[0], "%s/sce_sys/param.json", link);
|
||||
free(link);
|
||||
}
|
||||
out[0] = 0;
|
||||
for (size_t i = 0; i < 3 && !out[0]; i++) {
|
||||
if (!params[i][0]) continue;
|
||||
char *json = read_file(params[i], NULL);
|
||||
const char *k = json ? strstr(json, "\"contentVersion\"") : NULL;
|
||||
const char *q = k ? strchr(k + 16, '"') : NULL;
|
||||
int v[3];
|
||||
if (q && sscanf(q + 1, "%d.%d.%d", &v[0], &v[1], &v[2]) == 3) snprintf(out, n, "%d.%d.%d", v[0], v[1], v[2]);
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) return -1;
|
||||
|
||||
/* Re-install (e.g. port changed): uninstall may remove /user/app/<id>. */
|
||||
if (installed) sceAppInstUtilAppUnInstall(ROMM_TITLE_ID);
|
||||
|
||||
if (console_mkdir_p(ROMM_APP_DIR "/sce_sys")) return -1;
|
||||
if (write_file(ROMM_APP_DIR "/sce_sys/param.json", param, strlen(param))) return -1;
|
||||
if (have_icon) copy_file(CONSOLE_ICON, ROMM_APP_DIR "/sce_sys/icon0.png");
|
||||
|
||||
if ((err = install_title_dir(ROMM_TITLE_ID, "/user/app/"))) return -1;
|
||||
return 0;
|
||||
void plat_remove_old_tile(void) {
|
||||
if (!on_home_screen(OLD_TILE_ID)) return;
|
||||
/* Without RommPS the tile is the only way in from the home screen. */
|
||||
if (!on_home_screen(ROMMPS_ID)) {
|
||||
LOGI("kept the old RomM Sync tile: RommPS isn't installed");
|
||||
return;
|
||||
}
|
||||
int err = sceAppInstUtilInitialize();
|
||||
if (!err) err = sceAppInstUtilAppUnInstall(OLD_TILE_ID);
|
||||
if (err) LOGW("could not remove the old RomM Sync tile (0x%x)", (unsigned)err);
|
||||
else LOGI("removed the old RomM Sync tile: RommPS is the app now");
|
||||
}
|
||||
|
||||
/* etaHEN's loader runs whatever arrives on port 9021, loopback included
|
||||
|
||||
+281
-94
@@ -9,123 +9,310 @@
|
||||
#include "platform.h"
|
||||
#include "util.h"
|
||||
|
||||
/* The folder the loader starts payloads from (plat_info), or NULL if the
|
||||
* loader can't start payloads at boot. */
|
||||
static const char *autostart_dir(void) {
|
||||
const char *e = getenv("ROMM_SYNC_AUTOSTART");
|
||||
return e && *e ? e : plat_info()->autostart_dir;
|
||||
#define MAX_PLACES 8
|
||||
|
||||
/* A loader's payload folder: the payload in it and the file that turns it on
|
||||
* ("" when the payload's presence is enough). */
|
||||
typedef struct {
|
||||
char elf[PATH_MAX_LEN];
|
||||
char flag[PATH_MAX_LEN];
|
||||
int present; /* the loader is installed */
|
||||
} place;
|
||||
|
||||
/* "a,b,c" into list, at most max entries. */
|
||||
static int split(const char *s, char list[][PATH_MAX_LEN], int max) {
|
||||
int n = 0;
|
||||
while (s && *s && n < max) {
|
||||
const char *comma = strchr(s, ',');
|
||||
size_t len = comma ? (size_t)(comma - s) : strlen(s);
|
||||
if (len && len < PATH_MAX_LEN) {
|
||||
memcpy(list[n], s, len);
|
||||
list[n++][len] = 0;
|
||||
}
|
||||
s = comma ? comma + 1 : NULL;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The loader counts as installed when plat_info's loader_dir exists, or else the
|
||||
* folder above its payload folder: /data/etaHEN for /data/etaHEN/payloads. */
|
||||
static int loader_found(void) {
|
||||
static int parent_exists(const char *path) {
|
||||
char parent[PATH_MAX_LEN];
|
||||
if (!autostart_dir()) return 0;
|
||||
if (plat_info()->loader_dir && !getenv("ROMM_SYNC_AUTOSTART")) return dir_exists(plat_info()->loader_dir);
|
||||
str_copy(parent, sizeof parent, autostart_dir());
|
||||
str_copy(parent, sizeof parent, path);
|
||||
char *slash = strrchr(parent, '/');
|
||||
if (slash && slash != parent) *slash = 0;
|
||||
return dir_exists(parent);
|
||||
}
|
||||
|
||||
/* Same mode "make install" leaves over FTP. */
|
||||
static int write_payload(const char *elf, const void *data, size_t len) {
|
||||
if (write_file_atomic(elf, data, len) != 0) return -1;
|
||||
chmod(elf, 0777);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* flag is "" when the payload's presence is enough. Both are "" without autostart. */
|
||||
static void paths(char *elf, char *flag, size_t n) {
|
||||
/* Every loader payload folder, the running loader's first. ROMM_SYNC_AUTOSTART
|
||||
* (comma separated, running loader first) replaces them in tests. 0 if the
|
||||
* loader can't start payloads at boot. */
|
||||
static int places(place *p, int max) {
|
||||
const plat_info_t *pi = plat_info();
|
||||
elf[0] = flag[0] = 0;
|
||||
if (!autostart_dir()) return;
|
||||
snprintf(elf, n, "%s/%s", autostart_dir(), pi->payload);
|
||||
if (pi->autostart_flag) snprintf(flag, n, "%s/%s", autostart_dir(), pi->autostart_flag);
|
||||
char dirs[MAX_PLACES][PATH_MAX_LEN];
|
||||
int n = 0;
|
||||
const char *env = getenv("ROMM_SYNC_AUTOSTART");
|
||||
if (env && *env) {
|
||||
n = split(env, dirs, MAX_PLACES);
|
||||
} else if (pi->autostart_dir) {
|
||||
str_copy(dirs[n++], PATH_MAX_LEN, pi->autostart_dir);
|
||||
for (int i = 0; pi->autostart_also && pi->autostart_also[i] && n < MAX_PLACES; i++)
|
||||
str_copy(dirs[n++], PATH_MAX_LEN, pi->autostart_also[i]);
|
||||
}
|
||||
if (n > max) n = max;
|
||||
for (int i = 0; i < n; i++) {
|
||||
snprintf(p[i].elf, sizeof p[i].elf, "%s/%s", dirs[i], pi->payload);
|
||||
p[i].flag[0] = 0;
|
||||
if (pi->autostart_flag) snprintf(p[i].flag, sizeof p[i].flag, "%s/%s", dirs[i], pi->autostart_flag);
|
||||
/* A loader counts as installed when its folder (/data/etaHEN for
|
||||
* /data/etaHEN/payloads) exists; plat_info's loader_dir for the
|
||||
* running one. */
|
||||
p[i].present = (i == 0 && pi->loader_dir && !(env && *env)) ? dir_exists(pi->loader_dir) : parent_exists(dirs[i]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
cJSON *autostart_status(void) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN];
|
||||
paths(elf, flag, sizeof elf);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j, "supported", autostart_dir() != NULL);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddStringToObject(j, "payload", plat_info()->payload);
|
||||
if (plat_info()->autostart_hint) cJSON_AddStringToObject(j, "hint", plat_info()->autostart_hint);
|
||||
cJSON_AddBoolToObject(j, "loader_found", loader_found());
|
||||
cJSON_AddBoolToObject(j, "installed", elf[0] && file_exists(elf));
|
||||
cJSON_AddBoolToObject(j, "enabled", elf[0] && file_exists(elf) && (!flag[0] || file_exists(flag)));
|
||||
cJSON_AddStringToObject(j, "path", elf);
|
||||
return j;
|
||||
/* Copies other tools keep, that exist. */
|
||||
static int copies(char list[][PATH_MAX_LEN], int max) {
|
||||
const char *env = getenv("ROMM_SYNC_PAYLOAD_COPIES");
|
||||
int n = 0;
|
||||
if (env) {
|
||||
n = split(env, list, max);
|
||||
} else {
|
||||
const char *const *c = plat_info()->payload_copies;
|
||||
for (int i = 0; c && c[i] && n < max; i++) str_copy(list[n++], PATH_MAX_LEN, c[i]);
|
||||
}
|
||||
int kept = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (file_exists(list[i])) memmove(list[kept++], list[i], PATH_MAX_LEN);
|
||||
return kept;
|
||||
}
|
||||
|
||||
int autostart_replace(const void *data, size_t len, char *err, int en) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN], bak[PATH_MAX_LEN + 8];
|
||||
paths(elf, flag, sizeof elf);
|
||||
if (!elf[0] || !file_exists(elf)) return 0;
|
||||
snprintf(bak, sizeof bak, "%s.bak", elf);
|
||||
if (copy_file(elf, bak) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot back up %s", elf);
|
||||
static int enabled_at(const place *p) { return file_exists(p->elf) && (!p->flag[0] || file_exists(p->flag)); }
|
||||
|
||||
/* The version of the payload at path, "" if none or too old to say. Read
|
||||
* every time: two builds can have the same size and date to the second. */
|
||||
static void file_version(const char *path, char *out, size_t n) {
|
||||
size_t len;
|
||||
char *data = read_file(path, &len);
|
||||
out[0] = 0;
|
||||
if (data) payload_version(data, len, out, n);
|
||||
free(data);
|
||||
}
|
||||
|
||||
/* Writes the payload to path unless a newer one is already there. Same mode
|
||||
* "make install" leaves over FTP. 1 if written, 0 if kept, -1 on error. */
|
||||
static int put(const char *path, const void *data, size_t len, const char *version) {
|
||||
char have[32];
|
||||
if (file_exists(path)) {
|
||||
file_version(path, have, sizeof have);
|
||||
if (version_cmp(have, version) > 0) {
|
||||
LOGI("kept %s: it's %s, newer than %s", path, have, version[0] ? version : "this payload");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (write_file_atomic(path, data, len) != 0) {
|
||||
LOGW("could not write %s", path);
|
||||
return -1;
|
||||
}
|
||||
if (write_payload(elf, data, len) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", elf);
|
||||
return -1;
|
||||
}
|
||||
LOGI("replaced %s (%zu bytes), previous version kept as %s", elf, len, bak);
|
||||
/* A copy left in another loader's folder (from before switching loaders)
|
||||
* would start the old version if that loader came back. */
|
||||
const char *const *also = plat_info()->autostart_also;
|
||||
for (int i = 0; also && also[i] && !getenv("ROMM_SYNC_AUTOSTART"); i++) {
|
||||
char other[PATH_MAX_LEN];
|
||||
snprintf(other, sizeof other, "%s/%s", also[i], plat_info()->payload);
|
||||
if (!file_exists(other)) continue;
|
||||
if (write_payload(other, data, len) == 0) LOGI("also replaced %s", other);
|
||||
else LOGW("could not replace %s", other);
|
||||
}
|
||||
chmod(path, 0777);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
char elf[PATH_MAX_LEN], flag[PATH_MAX_LEN];
|
||||
paths(elf, flag, sizeof elf);
|
||||
if (!autostart_dir()) {
|
||||
snprintf(err, (size_t)en, "%s can't start payloads with the console", plat_info()->loader);
|
||||
return -1;
|
||||
static void remove_bak(const place *p) {
|
||||
char bak[PATH_MAX_LEN + 8];
|
||||
snprintf(bak, sizeof bak, "%s.bak", p->elf);
|
||||
if (file_exists(bak) && unlink(bak) == 0) LOGI("removed %s", bak);
|
||||
}
|
||||
|
||||
cJSON *autostart_status(void) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
char version[32] = "";
|
||||
if (n && file_exists(p[0].elf)) file_version(p[0].elf, version, sizeof version);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j, "supported", n > 0);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddStringToObject(j, "payload", plat_info()->payload);
|
||||
if (plat_info()->autostart_hint) cJSON_AddStringToObject(j, "hint", plat_info()->autostart_hint);
|
||||
cJSON_AddBoolToObject(j, "loader_found", n && p[0].present);
|
||||
cJSON_AddBoolToObject(j, "installed", n && file_exists(p[0].elf));
|
||||
cJSON_AddBoolToObject(j, "enabled", n && enabled_at(&p[0]));
|
||||
cJSON_AddStringToObject(j, "path", n ? p[0].elf : "");
|
||||
cJSON_AddStringToObject(j, "version", version);
|
||||
cJSON *list = cJSON_AddArrayToObject(j, "copies");
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (!file_exists(path)) continue;
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "path", path);
|
||||
cJSON_AddStringToObject(o, "version", v);
|
||||
cJSON_AddBoolToObject(o, "enabled", i < n ? enabled_at(&p[i]) : 1);
|
||||
cJSON_AddItemToArray(list, o);
|
||||
}
|
||||
if (!loader_found()) {
|
||||
snprintf(err, (size_t)en, "%s folder not found (%s)", plat_info()->loader, autostart_dir());
|
||||
return -1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Writes the payload to every installed loader's folder (or, with existing_only,
|
||||
* only over copies already there) and over other tools' copies. The number
|
||||
* written or kept, -1 if one couldn't be written. */
|
||||
static int put_everywhere(const void *data, size_t len, int existing_only) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES), done = 0, failed = 0;
|
||||
char version[32];
|
||||
payload_version(data, len, version, sizeof version);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!p[i].present || (existing_only && !file_exists(p[i].elf))) continue;
|
||||
int r = put(p[i].elf, data, len, version);
|
||||
if (r < 0) failed = 1;
|
||||
else done++;
|
||||
if (r > 0) LOGI("installed %s %s (%zu bytes)", version[0] ? version : "payload", p[i].elf, len);
|
||||
remove_bak(&p[i]);
|
||||
}
|
||||
if (data && len) {
|
||||
/* Only accept ELF files. */
|
||||
if (len < 4 || memcmp(data, "\x7f" "ELF", 4) != 0) {
|
||||
snprintf(err, (size_t)en, "that file is not an ELF payload");
|
||||
return -1;
|
||||
}
|
||||
if (write_payload(elf, data, len) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", elf);
|
||||
return -1;
|
||||
}
|
||||
LOGI("installed payload to %s (%zu bytes)", elf, len);
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
for (int i = 0; i < ne; i++) {
|
||||
int r = put(extra[i], data, len, version);
|
||||
if (r < 0) failed = 1;
|
||||
else done++;
|
||||
if (r > 0) LOGI("updated %s to %s", extra[i], version[0] ? version : "this payload");
|
||||
}
|
||||
if (enable) {
|
||||
if (!file_exists(elf)) {
|
||||
snprintf(err, (size_t)en, "upload %s first", plat_info()->payload);
|
||||
return failed ? -1 : done;
|
||||
}
|
||||
|
||||
/* Turns autostart on in every installed loader's folder that has the payload. */
|
||||
static int enable_all(char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!p[i].present || !p[i].flag[0] || !file_exists(p[i].elf) || file_exists(p[i].flag)) continue;
|
||||
if (write_file_atomic(p[i].flag, "", 0) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", p[i].flag);
|
||||
return -1;
|
||||
}
|
||||
if (flag[0] && write_file_atomic(flag, "", 0) != 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", flag);
|
||||
return -1;
|
||||
}
|
||||
LOGI("autostart enabled");
|
||||
} else if (flag[0]) {
|
||||
unlink(flag);
|
||||
LOGI("autostart disabled");
|
||||
} else {
|
||||
/* Nothing but the payload itself turns autostart on, so remove it. */
|
||||
unlink(elf);
|
||||
LOGI("autostart disabled, removed %s", elf);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int check_elf(const void *data, size_t len, char *err, int en) {
|
||||
if (len < 4 || memcmp(data, "\x7f" "ELF", 4) != 0) {
|
||||
snprintf(err, (size_t)en, "that file is not an ELF payload");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int autostart_set(int enable, const void *data, size_t len, char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
if (!n) {
|
||||
snprintf(err, (size_t)en, "%s can't start payloads with the console", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
if (!p[0].present) {
|
||||
snprintf(err, (size_t)en, "%s folder not found (%s)", plat_info()->loader, p[0].elf);
|
||||
return -1;
|
||||
}
|
||||
if (data && len) {
|
||||
if (check_elf(data, len, err, en)) return -1;
|
||||
if (put_everywhere(data, len, 0) < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write %s", p[0].elf);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (enable) {
|
||||
if (!file_exists(p[0].elf)) {
|
||||
snprintf(err, (size_t)en, "upload %s first", plat_info()->payload);
|
||||
return -1;
|
||||
}
|
||||
/* A loader that's installed but has no copy yet gets the running
|
||||
* loader's, so the payload starts whichever one runs. */
|
||||
for (int i = 1; i < n; i++)
|
||||
if (p[i].present && !file_exists(p[i].elf) && copy_file(p[0].elf, p[i].elf) == 0) chmod(p[i].elf, 0777);
|
||||
if (enable_all(err, en)) return -1;
|
||||
LOGI("autostart enabled");
|
||||
return 0;
|
||||
}
|
||||
/* Off: nothing of ours is left in any loader's folder to start later. */
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (p[i].flag[0]) unlink(p[i].flag);
|
||||
if (file_exists(p[i].elf) && unlink(p[i].elf) == 0) LOGI("removed %s", p[i].elf);
|
||||
remove_bak(&p[i]);
|
||||
}
|
||||
LOGI("autostart disabled");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int autostart_install(const void *data, size_t len, char *err, int en) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
if (check_elf(data, len, err, en)) return -1;
|
||||
int on = 0;
|
||||
for (int i = 0; i < n; i++) on |= p[i].present && enabled_at(&p[i]);
|
||||
if (put_everywhere(data, len, !on) < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write the payload to %s's folder", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
return on ? enable_all(err, en) : 0;
|
||||
}
|
||||
|
||||
int autostart_replace(const void *data, size_t len, char *err, int en) {
|
||||
int r = put_everywhere(data, len, 1);
|
||||
if (r < 0) {
|
||||
snprintf(err, (size_t)en, "cannot write the payload to %s's folder", plat_info()->loader);
|
||||
return -1;
|
||||
}
|
||||
return r > 0;
|
||||
}
|
||||
|
||||
void autostart_repair(void) {
|
||||
place p[MAX_PLACES];
|
||||
int n = places(p, MAX_PLACES);
|
||||
char extra[MAX_PLACES][PATH_MAX_LEN];
|
||||
int ne = copies(extra, MAX_PLACES);
|
||||
|
||||
/* The newest copy on the console, and whether autostart is on anywhere. */
|
||||
char best[PATH_MAX_LEN] = "", best_v[32] = "";
|
||||
int on = 0;
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (i < n) {
|
||||
remove_bak(&p[i]);
|
||||
if (!p[i].present) continue;
|
||||
on |= enabled_at(&p[i]);
|
||||
}
|
||||
if (!file_exists(path)) continue;
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
if (!best[0] || version_cmp(v, best_v) > 0) {
|
||||
str_copy(best, sizeof best, path);
|
||||
str_copy(best_v, sizeof best_v, v);
|
||||
}
|
||||
}
|
||||
if (!best[0]) return;
|
||||
|
||||
for (int i = 0; i < n + ne; i++) {
|
||||
const char *path = i < n ? p[i].elf : extra[i - n];
|
||||
if (i < n && !p[i].present) continue;
|
||||
int have = file_exists(path);
|
||||
/* Missing copies are only added to loaders, and only while it's on. */
|
||||
if (!have && !(i < n && on)) continue;
|
||||
if (have) {
|
||||
char v[32];
|
||||
file_version(path, v, sizeof v);
|
||||
if (version_cmp(v, best_v) >= 0) continue;
|
||||
}
|
||||
if (copy_file(best, path) == 0) {
|
||||
chmod(path, 0777);
|
||||
LOGI("%s %s with %s from %s", have ? "updated" : "set up", path, best_v[0] ? best_v : "the payload", best);
|
||||
} else {
|
||||
LOGW("could not copy %s to %s", best, path);
|
||||
}
|
||||
}
|
||||
if (on) {
|
||||
char err[256];
|
||||
if (enable_all(err, sizeof err)) LOGW("%s", err);
|
||||
}
|
||||
}
|
||||
+21
-6
@@ -1,5 +1,9 @@
|
||||
/* Starting with the console through the loader (etaHEN on the PS5). Consoles
|
||||
* whose loader can't do that report supported: false. */
|
||||
/* Starting with the console through the loader (etaHEN or onionHEN on the
|
||||
* PS5). Consoles whose loader can't do that report supported: false.
|
||||
*
|
||||
* The payload goes into every installed loader's payload folder, so it starts
|
||||
* whichever loader the console runs, and copies other tools keep (a payload
|
||||
* manager's) are kept up to date. A copy is never replaced by an older one. */
|
||||
#ifndef ROMM_AUTOSTART_H
|
||||
#define ROMM_AUTOSTART_H
|
||||
|
||||
@@ -7,11 +11,22 @@
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
cJSON *autostart_status(void); /* {supported, loader, payload, loader_found, installed, enabled, path} */
|
||||
/* Turns autostart on or off, installing the payload first if one is given. */
|
||||
/* {supported, loader, payload, hint, loader_found, installed, enabled, path,
|
||||
* version, copies: [{path, version, enabled}]}. installed, enabled, path and
|
||||
* version are about the running loader's folder. */
|
||||
cJSON *autostart_status(void);
|
||||
/* Turns autostart on or off, installing the payload first if one is given.
|
||||
* Off removes the payload from every loader's folder. */
|
||||
int autostart_set(int enable, const void *elf, size_t elf_len, char *err, int en);
|
||||
/* Replaces an installed payload, keeping the old one as <payload>.bak.
|
||||
* 1 if replaced, 0 if none is installed, -1 on error. */
|
||||
/* Installs the payload wherever it belongs without turning autostart on or
|
||||
* off: in every loader's folder when it's on, else only over existing copies. */
|
||||
int autostart_install(const void *elf, size_t elf_len, char *err, int en);
|
||||
/* Replaces every copy of the payload with this one (an update).
|
||||
* 1 if one was replaced, 0 if none is installed, -1 on error. */
|
||||
int autostart_replace(const void *elf, size_t elf_len, char *err, int en);
|
||||
/* At startup: brings every copy up to the newest one on the console, sets up
|
||||
* any installed loader that's missing autostart while it's on, and removes
|
||||
* the .bak files older versions left. */
|
||||
void autostart_repair(void);
|
||||
|
||||
#endif
|
||||
@@ -90,6 +90,7 @@ void config_apply_json(const cJSON *j) {
|
||||
get_int(j, "notify", &g_cfg.notify);
|
||||
get_int(j, "update_check", &g_cfg.update_check);
|
||||
get_int(j, "cover_cache", &g_cfg.cover_cache);
|
||||
get_int(j, "autostart_chosen", &g_cfg.autostart_chosen);
|
||||
get_int(j, "keep_backups", &g_cfg.keep_backups);
|
||||
get_int(j, "server_versions", &g_cfg.server_versions);
|
||||
if (g_cfg.server_versions < 0) g_cfg.server_versions = 0;
|
||||
@@ -177,6 +178,7 @@ cJSON *config_to_json(int include_secrets) {
|
||||
cJSON_AddBoolToObject(j, "notify", g_cfg.notify);
|
||||
cJSON_AddBoolToObject(j, "update_check", g_cfg.update_check);
|
||||
cJSON_AddBoolToObject(j, "cover_cache", g_cfg.cover_cache);
|
||||
cJSON_AddBoolToObject(j, "autostart_chosen", g_cfg.autostart_chosen);
|
||||
cJSON_AddNumberToObject(j, "keep_backups", g_cfg.keep_backups);
|
||||
cJSON_AddNumberToObject(j, "server_versions", g_cfg.server_versions);
|
||||
cJSON_AddNumberToObject(j, "download_concurrency", g_cfg.download_concurrency);
|
||||
|
||||
@@ -36,6 +36,7 @@ typedef struct {
|
||||
char ps2_cards[16]; /* "per_game" or "backup" */
|
||||
char ui_theme[24]; /* web UI theme id (ui/themes/<id>.css) */
|
||||
int cover_cache; /* keep every game's cover on the console (covers.h) */
|
||||
int autostart_chosen; /* someone turned autostart on or off here (1.1.1 on) */
|
||||
char server_version[32];
|
||||
} config_t;
|
||||
|
||||
|
||||
+33
-13
@@ -9,11 +9,11 @@
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "autostart.h"
|
||||
#include "config.h"
|
||||
#include "covers.h"
|
||||
#include "detect.h"
|
||||
#include "http.h"
|
||||
#include "icon_png.h" /* generated from assets/icon0.png */
|
||||
#include "library.h"
|
||||
#include "platform.h"
|
||||
#include "romm.h"
|
||||
@@ -40,16 +40,15 @@ static int is_game_title(const char *t) {
|
||||
if (!strncmp(t, "NPXS", 4)) return 0;
|
||||
/* RomM Sync's own apps (RommPS on the PS5, the launcher on the PS4) aren't
|
||||
* games: while one is open, syncs, downloads and covers carry on. */
|
||||
if (!strcmp(t, "PPSA76677") || !strcmp(t, "ROOM00002")) return 0;
|
||||
if (!strcmp(t, "PPSA76677") || !strcmp(t, "ROMM00002")) return 0;
|
||||
if (!strncmp(t, "ROMM0", 5)) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* AppInstUtil can take a while, so this gets its own thread. */
|
||||
static void *tile_thread(void *arg) {
|
||||
if (plat_install_tile((int)(long)arg) == 0)
|
||||
LOGI("home screen tile ready");
|
||||
else
|
||||
LOGW("home screen tile not installed (unsupported or failed)");
|
||||
(void)arg;
|
||||
plat_remove_old_tile();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -97,7 +96,8 @@ static void *app_main(void *arg) {
|
||||
(void)arg;
|
||||
int init = plat_init();
|
||||
if (init > 0) {
|
||||
plat_notify("RomM Sync is already running");
|
||||
/* RommPS and the HEN can both start a copy at boot; that's no news. */
|
||||
if (!plat_app_installed()) plat_notify("RomM Sync is already running");
|
||||
return NULL;
|
||||
}
|
||||
if (init != 0) fprintf(stderr, "platform init reported a problem\n");
|
||||
@@ -126,6 +126,8 @@ static void *app_main(void *arg) {
|
||||
config_load();
|
||||
if (g_cfg.profiles_custom && detect_refresh_profiles(g_cfg.profiles) > 0) config_save();
|
||||
state_load();
|
||||
/* Copies of older versions left on the console are brought up to date. */
|
||||
autostart_repair();
|
||||
http_global_init(plat_ca_bundle(), g_cfg.tls_verify);
|
||||
|
||||
sync_init();
|
||||
@@ -135,15 +137,27 @@ static void *app_main(void *arg) {
|
||||
int port = g_cfg.web_port;
|
||||
if (web_start(port) != 0) LOGE("web UI unavailable");
|
||||
|
||||
/* Installing the tile takes about 25 s on the console, so it gets its own thread. */
|
||||
/* The old tile's icon, from versions before 1.1.1. */
|
||||
path_join(path, sizeof path, plat_data_dir(), "icon0.png");
|
||||
if (!file_exists(path)) write_file_atomic(path, ICON_PNG, sizeof ICON_PNG - 1);
|
||||
thread_start(tile_thread, (void *)(long)port);
|
||||
if (file_exists(path)) unlink(path);
|
||||
thread_start(tile_thread, NULL);
|
||||
|
||||
char ip[64] = "";
|
||||
plat_local_ip(ip, sizeof ip);
|
||||
LOGI("web UI on http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
if (config_is_paired()) {
|
||||
/* With RommPS installed, it's where the user sees all this. An older
|
||||
* RommPS, though, starts its own older copy now and then (1.1.0 does when
|
||||
* this one is slow to answer), so it's worth updating. */
|
||||
int quiet = plat_app_installed();
|
||||
char app_version[32];
|
||||
plat_app_version(app_version, sizeof app_version);
|
||||
if (quiet && app_version[0] && version_cmp(app_version, APP_VERSION) < 0) {
|
||||
LOGI("RommPS %s is older than RomM Sync %s", app_version, APP_VERSION);
|
||||
plat_notify("RommPS %s is out of date. Update it from ProsperoStore.", app_version);
|
||||
} else if (quiet) {
|
||||
} else if (!plat_info()->self_update) {
|
||||
plat_notify("RomM Sync is running. Install RommPS from ProsperoStore to use it on the console.");
|
||||
} else if (config_is_paired()) {
|
||||
plat_notify("RomM Sync running - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
} else {
|
||||
plat_notify("RomM Sync: open http://%s:%d to pair with RomM", ip[0] ? ip : "127.0.0.1", port);
|
||||
@@ -166,7 +180,7 @@ static void *app_main(void *arg) {
|
||||
|
||||
char current[32] = "";
|
||||
time_t game_start = 0, exit_at = 0, now, last_tick = time(NULL), resume_notified = 0;
|
||||
int title_failures = 0, resume_ticks = 0;
|
||||
int title_failures = 0, resume_ticks = 0, rommps_seen = 0;
|
||||
for (;;) {
|
||||
sleep(MONITOR_INTERVAL_SEC);
|
||||
now = time(NULL);
|
||||
@@ -184,7 +198,7 @@ static void *app_main(void *arg) {
|
||||
if (ip[0] || --resume_ticks == 0) {
|
||||
resume_ticks = 0;
|
||||
resume_notified = now;
|
||||
plat_notify("RomM Sync resumed - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
if (!quiet) plat_notify("RomM Sync resumed - http://%s:%d", ip[0] ? ip : "127.0.0.1", port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +211,12 @@ static void *app_main(void *arg) {
|
||||
} else if (title_failures < MAX_TITLE_FAILURES && ++title_failures == MAX_TITLE_FAILURES) {
|
||||
LOGW("can't tell which game is running (%d failed reads); relying on periodic sync", title_failures);
|
||||
}
|
||||
/* RommPS installed while this copy runs (someone coming from 1.0.2):
|
||||
* the old tile goes the first time it's opened, not at the next boot. */
|
||||
if (fg > 0 && !strcmp(title, "PPSA76677") && !rommps_seen) {
|
||||
rommps_seen = 1;
|
||||
thread_start(tile_thread, NULL);
|
||||
}
|
||||
if (fg <= 0 || !is_game_title(title)) title[0] = 0;
|
||||
web_set_foreground(title);
|
||||
sync_set_game_running(title[0] != 0);
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ static void finish_pairing(const char *base, const char *token, const char *devi
|
||||
snprintf(msg, sizeof msg, "Paired as %s", g_cfg.username[0] ? g_cfg.username : "(unknown user)");
|
||||
set_status("ok", msg);
|
||||
LOGI("paired with %s, device %s", base, device_id);
|
||||
plat_notify("RomM Sync paired with %s", base);
|
||||
if (!plat_app_installed()) plat_notify("RomM Sync paired with %s", base);
|
||||
sync_new_pairing();
|
||||
sync_request("paired");
|
||||
}
|
||||
|
||||
+19
-6
@@ -20,9 +20,14 @@ typedef struct {
|
||||
* NULL if the loader can't. ROMM_SYNC_AUTOSTART overrides it, for tests. */
|
||||
const char *autostart_dir;
|
||||
const char *autostart_flag;
|
||||
/* Other loaders' payload folders (NULL-terminated, may be NULL): an update
|
||||
* replaces a copy of the payload there as well. */
|
||||
/* Other loaders' payload folders (NULL-terminated, may be NULL). Autostart
|
||||
* is set up in every one whose loader is installed, so the payload starts
|
||||
* whichever loader the console runs. */
|
||||
const char *const *autostart_also;
|
||||
/* Copies other tools keep and start (a payload manager's autoload), kept
|
||||
* up to date when they exist but never created or removed. NULL-terminated,
|
||||
* may be NULL. ROMM_SYNC_PAYLOAD_COPIES (comma separated) overrides it. */
|
||||
const char *const *payload_copies;
|
||||
const char *autostart_hint; /* what the user does in the loader's menu, or NULL */
|
||||
const char *loader_dir; /* present when the loader is installed; NULL: autostart_dir's parent */
|
||||
/* Folders to look in for emulators. homebrew_dirs hold one app per subfolder;
|
||||
@@ -34,9 +39,11 @@ typedef struct {
|
||||
const char *mednafen_root;
|
||||
/* Standalone emulators this console has, as a JSON array (detect.c), or NULL. */
|
||||
const char *emulator_catalog;
|
||||
int has_tile; /* plat_install_tile can add a home-screen tile */
|
||||
int detach; /* main() must return to the loader: the app runs on a thread */
|
||||
int can_relaunch; /* plat_launch_elf works, so an update can restart in place */
|
||||
/* The payload updates itself from GitHub releases. Off on the PS5, where
|
||||
* RommPS carries the payload and its updates bring the new one. */
|
||||
int self_update;
|
||||
} plat_info_t;
|
||||
|
||||
const plat_info_t *plat_info(void);
|
||||
@@ -57,9 +64,15 @@ const char *plat_data_dir(void);
|
||||
/* CA bundle for libcurl, or NULL for the default. */
|
||||
const char *plat_ca_bundle(void);
|
||||
|
||||
/* Install a home-screen tile that opens http://127.0.0.1:<port>/.
|
||||
* Returns 0 on success, -1 if unsupported/failed. */
|
||||
int plat_install_tile(int port);
|
||||
/* Removes the home-screen tile versions before 1.1.1 installed, when RommPS
|
||||
* is installed to take its place. */
|
||||
void plat_remove_old_tile(void);
|
||||
|
||||
/* Whether the console has an app that speaks for RomM Sync (RommPS on the
|
||||
* PS5): then it tells the user what's going on, and RomM Sync keeps quiet. */
|
||||
int plat_app_installed(void);
|
||||
/* That app's version ("1.1.0"), or "" if it isn't installed or doesn't say. */
|
||||
void plat_app_version(char *out, size_t n);
|
||||
|
||||
/* Starts a payload through the console's loader on loopback, if can_relaunch.
|
||||
* The new copy
|
||||
|
||||
+11
-3
@@ -1,6 +1,9 @@
|
||||
/* Updates from GitHub releases. Nothing installs by itself: the UI checks,
|
||||
* shows the release notes and installs when the user asks.
|
||||
*
|
||||
* On the PS5 (plat_info()->self_update off) the payload doesn't install
|
||||
* itself: RommPS carries it, so the check only says a new RommPS is out.
|
||||
*
|
||||
* A release carries a payload, manifest and signature for each console, named in
|
||||
* plat_info(): romm-sync.elf, manifest.json and manifest.sig for the PS5,
|
||||
* romm-sync-ps4.elf, manifest-ps4.json and manifest-ps4.sig for the PS4. The
|
||||
@@ -137,7 +140,8 @@ static void *check_thread(void *arg) {
|
||||
LOGI("update check: latest release %s, running %s", g_latest, APP_VERSION);
|
||||
if (notify && available && strcmp(g_notified, g_latest) != 0) {
|
||||
str_copy(g_notified, sizeof g_notified, g_latest);
|
||||
plat_notify("RomM Sync %s is available. Update it from Settings.", g_latest);
|
||||
if (plat_info()->self_update) plat_notify("RomM Sync %s is available. Update it from Settings.", g_latest);
|
||||
else plat_notify("RommPS %s is available. Update it from ProsperoStore.", g_latest);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
@@ -310,7 +314,8 @@ fail:
|
||||
int update_install(char *err, int en) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int rc = -1;
|
||||
if (busy(g_state)) snprintf(err, (size_t)en, "an update check or install is already running");
|
||||
if (!plat_info()->self_update) snprintf(err, (size_t)en, "update RommPS from ProsperoStore: it brings the new RomM Sync with it");
|
||||
else if (busy(g_state)) snprintf(err, (size_t)en, "an update check or install is already running");
|
||||
else if (!g_latest[0] || !newer_than(g_latest, APP_VERSION)) snprintf(err, (size_t)en, "no newer version found, check again first");
|
||||
else if (!strcmp(g_pending, g_latest)) snprintf(err, (size_t)en, "%s is already installed and starts with the next jailbreak", g_latest);
|
||||
else if (!g_elf_url[0] || !g_manifest_url[0] || !g_sig_url[0])
|
||||
@@ -337,6 +342,8 @@ cJSON *update_status(void) {
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON *as = autostart_status();
|
||||
cJSON_AddStringToObject(j, "current", APP_VERSION);
|
||||
/* false: updates come with RommPS, not from here. */
|
||||
cJSON_AddBoolToObject(j, "self_update", plat_info()->self_update);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
cJSON_AddStringToObject(j, "state", STATE_NAMES[g_state]);
|
||||
cJSON_AddStringToObject(j, "message", g_message);
|
||||
@@ -352,7 +359,8 @@ cJSON *update_status(void) {
|
||||
cJSON_AddNumberToObject(j, "done", (double)g_done);
|
||||
cJSON_AddNumberToObject(j, "total", (double)g_total);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
cJSON_AddBoolToObject(j, "autostart", cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(as, "installed")));
|
||||
/* On, not just installed: what "starts with the console" means. */
|
||||
cJSON_AddBoolToObject(j, "autostart", cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(as, "enabled")));
|
||||
cJSON_AddBoolToObject(j, "autostart_supported", cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(as, "supported")));
|
||||
cJSON_Delete(as);
|
||||
return j;
|
||||
|
||||
+30
@@ -229,6 +229,36 @@ int copy_file(const char *src, const char *dst) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
static void parse_version(const char *s, int v[3]) {
|
||||
v[0] = v[1] = v[2] = 0;
|
||||
if (!s) return;
|
||||
if (*s == 'v') s++;
|
||||
sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]);
|
||||
}
|
||||
|
||||
int version_cmp(const char *a, const char *b) {
|
||||
int x[3], y[3];
|
||||
parse_version(a, x);
|
||||
parse_version(b, y);
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (x[i] != y[i]) return x[i] < y[i] ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void payload_version(const void *elf, size_t len, char *out, size_t n) {
|
||||
static const char tag[] = "ROMM_SYNC_VERSION=";
|
||||
const size_t tl = sizeof tag - 1;
|
||||
const char *p = elf;
|
||||
out[0] = 0;
|
||||
for (size_t i = 0; len >= tl && i <= len - tl; i++) {
|
||||
if (p[i] != 'R' || memcmp(p + i, tag, tl) != 0) continue;
|
||||
size_t j = i + tl, k = 0;
|
||||
while (j < len && p[j] != '\n' && p[j] && k + 1 < n) out[k++] = p[j++];
|
||||
out[k] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int move_file(const char *src, const char *dst) {
|
||||
mkdir_parent(dst);
|
||||
if (rename(src, dst) == 0) return 0;
|
||||
|
||||
+8
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
#define APP_NAME "romm-sync"
|
||||
#ifndef APP_VERSION
|
||||
#define APP_VERSION "1.1.0"
|
||||
#define APP_VERSION "1.1.1"
|
||||
#endif
|
||||
#ifndef APP_BUILD /* git describe of the build, from the Makefile */
|
||||
#define APP_BUILD "unknown"
|
||||
@@ -44,6 +44,13 @@ int set_file_mtime(const char *path, time_t t);
|
||||
char *read_file(const char *path, size_t *len); /* malloc'd, NUL-terminated */
|
||||
int write_file_atomic(const char *path, const void *data, size_t len);
|
||||
int copy_file(const char *src, const char *dst);
|
||||
|
||||
/* -1, 0 or 1 as a is older, the same as or newer than b ("1.2.3", a leading
|
||||
* "v" and anything after the patch ignored). Unreadable counts as 0.0.0. */
|
||||
int version_cmp(const char *a, const char *b);
|
||||
/* The version a payload ELF says it is (its ROMM_SYNC_VERSION= tag, from
|
||||
* 1.1.0 on), or "" if it has none. */
|
||||
void payload_version(const void *elf, size_t len, char *out, size_t n);
|
||||
int link_or_copy(const char *src, const char *dst); /* hard link, a copy across drives; folders too */
|
||||
int move_file(const char *src, const char *dst); /* rename, falls back to copy+unlink */
|
||||
void remove_tree(const char *path); /* rm -rf */
|
||||
|
||||
@@ -51,6 +51,8 @@ typedef struct {
|
||||
char query[2048];
|
||||
char *body;
|
||||
size_t body_len;
|
||||
/* For telling our own pages from other sites' (cross_site). */
|
||||
char host[256], origin[256], fetch_site[32];
|
||||
} request;
|
||||
|
||||
|
||||
@@ -158,6 +160,35 @@ static int qint(const request *r, const char *key, int def) {
|
||||
return qparam(r, key, b, sizeof b) && b[0] ? atoi(b) : def;
|
||||
}
|
||||
|
||||
static void autostart_chosen(void) {
|
||||
config_lock();
|
||||
if (!g_cfg.autostart_chosen) {
|
||||
g_cfg.autostart_chosen = 1;
|
||||
config_save();
|
||||
}
|
||||
config_unlock();
|
||||
}
|
||||
|
||||
/* A header's value, from just after "Name:" to the end of its line. */
|
||||
static void header_value(const char *v, char *out, size_t n) {
|
||||
while (*v == ' ' || *v == '\t') v++;
|
||||
size_t len = strcspn(v, "\r\n");
|
||||
while (len && (v[len - 1] == ' ' || v[len - 1] == '\t')) len--;
|
||||
if (len >= n) len = n - 1;
|
||||
memcpy(out, v, len);
|
||||
out[len] = 0;
|
||||
}
|
||||
|
||||
/* Whether another site's page made the browser send this. Browsers say where
|
||||
* a request comes from (Origin, Sec-Fetch-Site); apps such as RommPS and curl
|
||||
* send neither, and our own pages come from this very address. */
|
||||
static int cross_site(const request *r) {
|
||||
if (!strcasecmp(r->fetch_site, "cross-site") || !strcasecmp(r->fetch_site, "same-site")) return 1;
|
||||
if (!r->origin[0]) return 0;
|
||||
const char *o = strstr(r->origin, "://");
|
||||
return !o || strcasecmp(o + 3, r->host) != 0; /* "null" included */
|
||||
}
|
||||
|
||||
static int read_request(int fd, request *r) {
|
||||
char *buf = malloc(MAX_HEADER + 1);
|
||||
if (!buf) return -1;
|
||||
@@ -193,8 +224,11 @@ static int read_request(int fd, request *r) {
|
||||
size_t clen = 0;
|
||||
for (char *h = strstr(buf, "\r\n"); h && h < hdr_end; h = strstr(h + 2, "\r\n")) {
|
||||
if (!strncasecmp(h + 2, "Content-Length:", 15)) clen = (size_t)strtoul(h + 17, NULL, 10);
|
||||
else if (!strncasecmp(h + 2, "Host:", 5)) header_value(h + 7, r->host, sizeof r->host);
|
||||
else if (!strncasecmp(h + 2, "Origin:", 7)) header_value(h + 9, r->origin, sizeof r->origin);
|
||||
else if (!strncasecmp(h + 2, "Sec-Fetch-Site:", 15)) header_value(h + 17, r->fetch_site, sizeof r->fetch_site);
|
||||
}
|
||||
if (clen > (strcmp(r->path, "/api/autostart/upload") == 0 ? MAX_UPLOAD : MAX_BODY)) {
|
||||
if (clen > (!strcmp(r->path, "/api/autostart/upload") || !strcmp(r->path, "/api/autostart/install") ? MAX_UPLOAD : MAX_BODY)) {
|
||||
free(buf);
|
||||
return -1;
|
||||
}
|
||||
@@ -225,7 +259,6 @@ static cJSON *status_json(void) {
|
||||
cJSON_AddStringToObject(j, "platform", plat_name());
|
||||
cJSON_AddStringToObject(j, "console", plat_info()->console);
|
||||
cJSON_AddStringToObject(j, "loader", plat_info()->loader);
|
||||
cJSON_AddBoolToObject(j, "has_tile", plat_info()->has_tile);
|
||||
cJSON_AddStringToObject(j, "ip", ip);
|
||||
cJSON_AddNumberToObject(j, "uptime", (double)(time(NULL) - g_started));
|
||||
config_lock();
|
||||
@@ -483,6 +516,13 @@ static cJSON *fs_folders(const char *path, char *err, int en) {
|
||||
static void route(request *r) {
|
||||
int fd = r->fd;
|
||||
int is_get = !strcmp(r->method, "GET"), is_post = !strcmp(r->method, "POST");
|
||||
/* Only our own pages and apps change anything: a page on another site
|
||||
* could otherwise post here from any browser on the network. */
|
||||
if (!is_get && strcmp(r->method, "HEAD") && cross_site(r)) {
|
||||
LOGW("refused %s %s from %s", r->method, r->path, r->origin[0] ? r->origin : "another site");
|
||||
send_error(fd, 403, "requests from other websites aren't accepted");
|
||||
return;
|
||||
}
|
||||
char err[256] = "";
|
||||
|
||||
if (is_get && (!strcmp(r->path, "/") || !strcmp(r->path, "/index.html"))) {
|
||||
@@ -588,6 +628,47 @@ static void route(request *r) {
|
||||
else send_json(fd, 200, update_status());
|
||||
return;
|
||||
}
|
||||
/* Autostart works before pairing too: RommPS sets it up and keeps it
|
||||
* current whether or not RomM Sync is paired yet. */
|
||||
if (is_get && !strcmp(r->path, "/api/autostart")) {
|
||||
cJSON *a = autostart_status();
|
||||
config_lock();
|
||||
cJSON_AddBoolToObject(a, "chosen", g_cfg.autostart_chosen);
|
||||
config_unlock();
|
||||
send_json(fd, 200, a);
|
||||
return;
|
||||
}
|
||||
/* Turning it on or off is remembered, so RommPS's one-time switch-on
|
||||
* for versions that left it off never overrides someone's choice. */
|
||||
if (is_post && !strcmp(r->path, "/api/autostart")) {
|
||||
cJSON *j = body_json(r);
|
||||
int rc = autostart_set(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j, "enable")), NULL, 0, err, sizeof err);
|
||||
cJSON_Delete(j);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else {
|
||||
autostart_chosen();
|
||||
send_json(fd, 200, autostart_status());
|
||||
}
|
||||
return;
|
||||
}
|
||||
/* Installs the payload and turns autostart on (RommPS's setup and the
|
||||
* web UI's upload). */
|
||||
if (is_post && !strcmp(r->path, "/api/autostart/upload")) {
|
||||
int rc = autostart_set(1, r->body, r->body_len, err, sizeof err);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else {
|
||||
autostart_chosen();
|
||||
send_json(fd, 200, autostart_status());
|
||||
}
|
||||
return;
|
||||
}
|
||||
/* Installs the payload, leaving autostart on or off as it is. */
|
||||
if (is_post && !strcmp(r->path, "/api/autostart/install")) {
|
||||
int rc = autostart_install(r->body, r->body_len, err, sizeof err);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (!config_is_paired() && !strncmp(r->path, "/api/", 5)) {
|
||||
send_error(fd, 409, "not paired with a RomM server yet");
|
||||
return;
|
||||
@@ -739,24 +820,6 @@ static void route(request *r) {
|
||||
else send_ok(fd);
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/api/autostart")) {
|
||||
send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/autostart")) {
|
||||
cJSON *j = body_json(r);
|
||||
int rc = autostart_set(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j, "enable")), NULL, 0, err, sizeof err);
|
||||
cJSON_Delete(j);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/autostart/upload")) {
|
||||
int rc = autostart_set(1, r->body, r->body_len, err, sizeof err);
|
||||
if (rc) send_error(fd, 400, err);
|
||||
else send_json(fd, 200, autostart_status());
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/sync")) {
|
||||
sync_request("manual");
|
||||
send_ok(fd);
|
||||
@@ -908,15 +971,6 @@ static void route(request *r) {
|
||||
send_ok(fd);
|
||||
return;
|
||||
}
|
||||
if (is_post && !strcmp(r->path, "/api/tile")) {
|
||||
int port;
|
||||
config_lock();
|
||||
port = g_cfg.web_port;
|
||||
config_unlock();
|
||||
if (plat_install_tile(port) == 0) send_ok(fd);
|
||||
else send_error(fd, 500, plat_info()->has_tile ? "tile install failed" : "home screen tiles aren't supported on this console");
|
||||
return;
|
||||
}
|
||||
if (is_get && !strcmp(r->path, "/video")) {
|
||||
/* RommPS's Codec call: a YouTube video in the console's browser, its
|
||||
* player filling the page. YouTube's embeds refuse to play as a page
|
||||
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Autostart on a console with two loaders (etaHEN and onionHEN folders) and a
|
||||
# payload manager's copy, against the host build.
|
||||
# make host && tests/autostart.sh
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
T="$(mktemp -d)"
|
||||
WEB_PORT=8783
|
||||
API="http://127.0.0.1:${WEB_PORT}"
|
||||
trap 'kill $(jobs -p) 2>/dev/null || true; rm -rf "$T"' EXIT
|
||||
|
||||
ETA="$T/etaHEN/payloads" ONION="$T/OnionHEN/payloads" PLD="$T/pldmgr/payloads/romm-sync/romm-sync.elf"
|
||||
fail() { echo "FAIL: $*"; echo "--- daemon log"; tail -30 "$T/daemon.log"; exit 1; }
|
||||
# A stand-in payload: an ELF header and, from 1.1.0 on, the version tag.
|
||||
elf() { if [[ -n "$1" ]]; then printf '\177ELF payload ROMM_SYNC_VERSION=%s\n' "$1"; else printf '\177ELF payload\n'; fi; }
|
||||
ver() { [[ -f "$1" ]] && { grep -ao 'ROMM_SYNC_VERSION=[0-9.]*' "$1" | cut -d= -f2 || echo untagged; } || echo none; }
|
||||
on() { [[ -f "$1/romm-sync.elf.auto_start" ]] && echo on || echo off; }
|
||||
start() {
|
||||
ROMM_SYNC_APP_INSTALLED="${APP_INSTALLED:-0}" ROMM_SYNC_APP_VERSION="${APP_VERSION:-}" ROMM_SYNC_SELF_UPDATE="${SELF_UPDATE:-1}" ROMM_SYNC_DATA="$T/data" ROMM_SYNC_HOMEBREW="$T/none" ROMM_SYNC_AUTOSTART="$ETA,$ONION" \
|
||||
ROMM_SYNC_PAYLOAD_COPIES="$PLD,$T/elsewhere/romm-sync.elf" "$ROOT/build/host/romm-sync" >>"$T/daemon.log" 2>&1 &
|
||||
for _ in $(seq 1 40); do curl -sf "$API/api/autostart" >/dev/null 2>&1 && return 0; sleep 0.25; done
|
||||
fail "the daemon didn't start"
|
||||
}
|
||||
stop() { kill $(jobs -p) 2>/dev/null || true; wait 2>/dev/null || true; }
|
||||
upload() { elf "$2" > "$T/up.elf"; curl -s -X POST "$API/api/autostart/$1" --data-binary @"$T/up.elf"; }
|
||||
expect() { # expect <what> <got> <want>
|
||||
[[ "$2" == "$3" ]] || fail "$1: got '$2', expected '$3'"; }
|
||||
|
||||
mkdir -p "$T/data" "$ETA" "$ONION" "$(dirname "$PLD")"
|
||||
echo "{\"web_port\": $WEB_PORT, \"sync_interval_min\": 0}" > "$T/data/config.json"
|
||||
|
||||
echo "1. at startup, every copy is brought up to the newest and onionHEN is set up too"
|
||||
elf 1.1.0 > "$ETA/romm-sync.elf"; : > "$ETA/romm-sync.elf.auto_start" # 1.1.0 installed it in etaHEN only
|
||||
elf "" > "$ETA/romm-sync.elf.bak" # an update's leftover
|
||||
elf "" > "$PLD" # 1.0.x kept in pldmgr's autoload
|
||||
start
|
||||
expect "etaHEN copy" "$(ver "$ETA/romm-sync.elf")" 1.1.0
|
||||
expect "onionHEN copy" "$(ver "$ONION/romm-sync.elf")" 1.1.0
|
||||
expect "onionHEN autostart" "$(on "$ONION")" on
|
||||
expect "pldmgr copy" "$(ver "$PLD")" 1.1.0
|
||||
[[ ! -e "$ETA/romm-sync.elf.bak" ]] || fail "the .bak wasn't removed"
|
||||
[[ ! -e "$T/elsewhere" ]] || fail "a copy that didn't exist was created"
|
||||
|
||||
chosen() { curl -sf "$API/api/autostart" | python3 -c "import json,sys; print(json.load(sys.stdin)['chosen'])"; }
|
||||
expect "a choice before anyone made one" "$(chosen)" False
|
||||
|
||||
echo "2. an older payload never replaces a newer one"
|
||||
upload upload 1.0.9 >/dev/null
|
||||
expect "etaHEN after an older upload" "$(ver "$ETA/romm-sync.elf")" 1.1.0
|
||||
expect "onionHEN after an older upload" "$(ver "$ONION/romm-sync.elf")" 1.1.0
|
||||
upload upload "" >/dev/null
|
||||
expect "etaHEN after an untagged (1.0.x) upload" "$(ver "$ETA/romm-sync.elf")" 1.1.0
|
||||
expect "pldmgr after an older upload" "$(ver "$PLD")" 1.1.0
|
||||
|
||||
echo "3. install puts the newer payload everywhere and leaves autostart on"
|
||||
r=$(upload install 1.1.1); [[ "$r" == *'"version":"1.1.1"'* ]] || fail "status after install: $r"
|
||||
for d in "$ETA" "$ONION"; do expect "$d" "$(ver "$d/romm-sync.elf")/$(on "$d")" 1.1.1/on; done
|
||||
expect "pldmgr" "$(ver "$PLD")" 1.1.1
|
||||
n=$(curl -sf "$API/api/autostart" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['copies']))")
|
||||
expect "copies reported" "$n" 3
|
||||
|
||||
echo "4. off removes the payload from every loader, but not the payload manager's copy"
|
||||
curl -sf -X POST "$API/api/autostart" -d '{"enable":false}' >/dev/null
|
||||
for d in "$ETA" "$ONION"; do [[ -z "$(ls -A "$d")" ]] || fail "$d not empty after turning autostart off: $(ls "$d")"; done
|
||||
expect "pldmgr after off" "$(ver "$PLD")" 1.1.1
|
||||
|
||||
echo "5. install while off only updates copies that exist; it doesn't turn autostart on"
|
||||
upload install 1.1.2 >/dev/null
|
||||
for d in "$ETA" "$ONION"; do [[ -z "$(ls -A "$d")" ]] || fail "install while off wrote to $d"; done
|
||||
expect "pldmgr" "$(ver "$PLD")" 1.1.2
|
||||
|
||||
echo "6. upload turns it on in every installed loader; a loader that isn't installed is left alone"
|
||||
rm -rf "$T/OnionHEN"
|
||||
upload upload 1.1.2 >/dev/null
|
||||
expect "etaHEN" "$(ver "$ETA/romm-sync.elf")/$(on "$ETA")" 1.1.2/on
|
||||
[[ ! -e "$T/OnionHEN" ]] || fail "a folder was made for a loader that isn't installed"
|
||||
|
||||
expect "turning it on is remembered" "$(chosen)" True
|
||||
|
||||
echo "7. a loader installed later gets set up at the next start"
|
||||
stop; mkdir -p "$ONION"; start
|
||||
expect "the choice after a restart" "$(chosen)" True
|
||||
expect "onionHEN after restart" "$(ver "$ONION/romm-sync.elf")/$(on "$ONION")" 1.1.2/on
|
||||
|
||||
echo "8. while off, the next start adds nothing but still updates what's there"
|
||||
curl -sf -X POST "$API/api/autostart" -d '{"enable":false}' >/dev/null
|
||||
stop; elf 1.0.9 > "$ONION/romm-sync.elf"; start # a stray copy, not turned on
|
||||
expect "etaHEN while off" "$(ver "$ETA/romm-sync.elf")" none
|
||||
expect "stray onionHEN copy" "$(ver "$ONION/romm-sync.elf")/$(on "$ONION")" 1.1.2/off
|
||||
|
||||
echo "9. on the PS5 the payload doesn't update itself: RommPS brings the new one"
|
||||
stop; SELF_UPDATE=0 start
|
||||
r=$(curl -s -X POST "$API/api/update/install"); [[ "$r" == *"update RommPS from ProsperoStore"* ]] || fail "install with self-update off: $r"
|
||||
r=$(curl -sf "$API/api/update"); [[ "$r" == *'"self_update":false'* ]] || fail "update status: $r"
|
||||
|
||||
echo "10. a page on another website can't change anything; our own page and apps can"
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' -X POST "$API/api/autostart" "$@"; }
|
||||
expect "post from another site" "$(code -H 'Origin: http://evil.example' -d '{"enable":true}')" 403
|
||||
expect "cross-site fetch" "$(code -H 'Sec-Fetch-Site: cross-site' -d '{"enable":true}')" 403
|
||||
expect "opaque origin" "$(code -H 'Origin: null' -d '{"enable":true}')" 403
|
||||
expect "etaHEN after refused posts" "$(ver "$ETA/romm-sync.elf")" none
|
||||
expect "our own page" "$(code -H "Origin: http://127.0.0.1:$WEB_PORT" -H 'Sec-Fetch-Site: same-origin' -d '{"enable":false}')" 200
|
||||
expect "an app (no Origin)" "$(code -d '{"enable":false}')" 200
|
||||
expect "reading from another site still works" "$(curl -s -o /dev/null -w '%{http_code}' -H 'Origin: http://evil.example' "$API/api/autostart")" 200
|
||||
|
||||
echo "11. with RommPS installed RomM Sync keeps quiet; without it, it says to install RommPS"
|
||||
stop; : > "$T/daemon.log"; SELF_UPDATE=0 APP_INSTALLED=1 start; sleep 1
|
||||
! grep -q NOTIFY "$T/daemon.log" || fail "a notification with RommPS installed: $(grep NOTIFY "$T/daemon.log")"
|
||||
stop; : > "$T/daemon.log"; SELF_UPDATE=0 APP_INSTALLED=1 APP_VERSION=1.1.0 start; sleep 1
|
||||
grep -q "NOTIFY.*RommPS 1.1.0 is out of date" "$T/daemon.log" || fail "no hint to update an older RommPS: $(grep NOTIFY "$T/daemon.log")"
|
||||
stop; : > "$T/daemon.log"; SELF_UPDATE=0 start; sleep 1
|
||||
grep -q "NOTIFY.*Install RommPS from ProsperoStore" "$T/daemon.log" || fail "no hint to install RommPS: $(grep NOTIFY "$T/daemon.log")"
|
||||
|
||||
echo PASS
|
||||
+1
-1
@@ -277,7 +277,7 @@ publish ok; r="$(install_err)"; [[ "$r" == restarting ]] || fail "good upd
|
||||
for _ in $(seq 1 10); do [[ -f "$T/data/launched.elf" ]] && break; sleep 0.5; done
|
||||
cmp -s "$REL/romm-sync.elf" "$T/data/launched.elf" || fail "the new payload was not launched"
|
||||
cmp -s "$REL/romm-sync.elf" "$T/etaHEN/payloads/romm-sync.elf" || fail "the installed payload was not replaced"
|
||||
[[ "$(cat "$T/etaHEN/payloads/romm-sync.elf.bak")" == old-payload ]] || fail "no backup of the old payload"
|
||||
[[ ! -e "$T/etaHEN/payloads/romm-sync.elf.bak" ]] || fail "the update left a .bak of the old payload behind"
|
||||
|
||||
echo "14. diagnostics report leaves out the token"
|
||||
curl -sf "$API/api/diagnostics" > "$T/diag.txt" || fail "no diagnostics report"
|
||||
|
||||
+26
-9
@@ -189,7 +189,7 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
|
||||
<div class="card">
|
||||
<h2>Server</h2>
|
||||
<p id="srvInfo"></p>
|
||||
<div class="row"><button id="tileBtn">Add home screen tile</button><button id="editEmus">Emulators</button><button id="rerunSetup">Run setup again</button><button class="danger" id="forget">Unpair</button></div>
|
||||
<div class="row"><button id="editEmus">Emulators</button><button id="rerunSetup">Run setup again</button><button class="danger" id="forget">Unpair</button></div>
|
||||
<h3>PS4 and PS5 packages</h3>
|
||||
<p class="muted">Add these feeds to FPKGi. They use your RomM username and password.</p>
|
||||
<p><code id="feed4"></code><br><code id="feed5"></code></p>
|
||||
@@ -340,7 +340,6 @@ each(".tab", function (t) { t.onclick = function () { showTab(t.dataset.tab); };
|
||||
// Status
|
||||
function renderStatus(s) {
|
||||
state.status = s;
|
||||
$("tileBtn").style.display = s.has_tile === false ? "none" : "";
|
||||
$("conn").innerHTML = s.paired
|
||||
? '<span class="ok">●</span> ' + esc(s.username || "") + " at " + esc(s.server_url.replace(/^https?:\/\//, ""))
|
||||
: '<span class="warn">●</span> Not paired';
|
||||
@@ -415,8 +414,18 @@ function wzGo(n) {
|
||||
each("#wzSteps span", function (x) { var k = +x.dataset.s; x.classList.toggle("on", k === n); x.classList.toggle("done", k < n); });
|
||||
$("pairErr").textContent = "";
|
||||
if (n === 3 && !wz.loaded[3]) loadEmus();
|
||||
if (n === 4) loadPreview();
|
||||
if (n === 5) loadAutostart();
|
||||
if (n === 4) { loadPreview(); wzLoadSettings(); }
|
||||
if (n === 5) { wzLoadSettings(); loadAutostart(); }
|
||||
}
|
||||
// The wizard's choices start from the current settings, not fixed defaults:
|
||||
// running it again (or after RommPS's setup) keeps what was chosen.
|
||||
function wzLoadSettings() {
|
||||
if (wz.loaded.settings) return;
|
||||
wz.loaded.settings = true;
|
||||
api("GET", "/api/config").then(function (c) {
|
||||
$("wzPolicy").value = c.conflict_policy; $("wzChange").checked = c.sync_on_change; $("wzExit").checked = c.sync_on_game_exit;
|
||||
$("wzStart").checked = c.sync_on_game_start; $("wzNotify").checked = c.notify; $("wzInterval").value = c.sync_interval_min;
|
||||
}).catch(function () {});
|
||||
}
|
||||
function renderQr(url) {
|
||||
if (wz.qrFor === url) return;
|
||||
@@ -720,7 +729,8 @@ function loadAutostart() {
|
||||
else if (a.enabled && a.hint) h = '<p class="ok">Copied to <code>' + esc(a.path) + "</code>.</p><p>" + esc(a.hint) + '</p><button id="asOff">Remove it</button>';
|
||||
else if (a.enabled) h = '<p class="ok">Starts with ' + ld + '.</p><button id="asOff">Turn off</button>';
|
||||
else if (a.installed) h = '<p class="muted">Installed at <code>' + esc(a.path) + '</code></p><button class="primary" id="asOn">Start with the console</button>';
|
||||
else h = '<p class="muted">' + ld + " needs a copy of " + pl + '. Pick the file from a computer, or run <code>make install</code>.</p><div class="row"><input type="file" id="asFile" accept=".elf"><button class="primary" id="asUpload">Upload</button></div>';
|
||||
else h = '<p class="muted">' + ld + " needs a copy of " + pl + "." + (state.status && state.status.platform === "ps5" ? " Open RommPS on the console and it sets this up, or pick the file from a computer." : " Pick the file from a computer.") +
|
||||
'</p><div class="row"><input type="file" id="asFile" accept=".elf"><button class="primary" id="asUpload">Upload</button></div>';
|
||||
$("autostart").innerHTML = h;
|
||||
if ($("asOn")) $("asOn").onclick = function () { api("POST", "/api/autostart", { enable: true }).then(loadAutostart).catch(function (e) { toast(e.message, 1); }); };
|
||||
if ($("asOff")) $("asOff").onclick = function () { api("POST", "/api/autostart", { enable: false }).then(loadAutostart); };
|
||||
@@ -982,6 +992,7 @@ function loadSettings() {
|
||||
$("policy").value = c.conflict_policy; $("slot").value = c.slot; $("concurrency").value = c.download_concurrency; $("backups").value = c.keep_backups;
|
||||
$("profiles").value = JSON.stringify(c.profiles, null, 2);
|
||||
$("updAuto").checked = c.update_check;
|
||||
state.cfg = c;
|
||||
$("themeSel").innerHTML = THEMES.map(function (t) { return '<option value="' + t.id + '"' + (t.id === c.ui_theme ? " selected" : "") + ">" + esc(t.name) + "</option>"; }).join("");
|
||||
});
|
||||
loadUpdate();
|
||||
@@ -1050,12 +1061,17 @@ $("ps2Status").onclick = function (ev) {
|
||||
api("POST", "/api/memcards/restore", { slot: +b.dataset.restore }).then(function () { toast("Memory card restored"); loadSettings(); }).catch(function (e) { b.disabled = false; toast(e.message, 1); });
|
||||
};
|
||||
$("saveCfg").onclick = function () {
|
||||
api("POST", "/api/config", {
|
||||
var form = {
|
||||
device_name: $("devname").value, sync_interval_min: +$("interval").value, sync_on_change: $("onchange").checked,
|
||||
sync_on_game_exit: $("onexit").checked, sync_on_game_start: $("onstart").checked, states: $("states").value, server_versions: +$("serverVersions").value,
|
||||
notify: $("notify").checked, tls_verify: $("tls").checked, conflict_policy: $("policy").value, slot: $("slot").value,
|
||||
keep_backups: +$("backups").value, download_concurrency: +$("concurrency").value
|
||||
}).then(function () { toast("Saved"); }).catch(function (e) { toast(e.message, 1); });
|
||||
};
|
||||
// Only what was changed here: RommPS may have changed the rest since this page loaded.
|
||||
var changed = {}, any = false, was = state.cfg || {};
|
||||
Object.keys(form).forEach(function (k) { if (form[k] !== was[k]) { changed[k] = form[k]; any = true; } });
|
||||
if (!any) { toast("Nothing changed"); return; }
|
||||
api("POST", "/api/config", changed).then(function () { toast("Saved"); loadSettings(); }).catch(function (e) { toast(e.message, 1); });
|
||||
};
|
||||
$("saveProfiles").onclick = function () {
|
||||
var p; try { p = JSON.parse($("profiles").value); } catch (e) { toast("Invalid JSON: " + e.message, 1); return; }
|
||||
@@ -1078,6 +1094,8 @@ function renderUpdate(u) {
|
||||
var pct = u.total ? Math.round(u.done * 100 / u.total) : 0;
|
||||
h += "<p>" + esc(u.message || u.state) + (u.state === "downloading" && u.total ? " (" + pct + "%)" : "") + "</p>";
|
||||
if (u.state === "downloading") h += '<div class="bar"><i style="width:' + pct + '%"></i></div>';
|
||||
} else if (u.available && u.self_update === false) {
|
||||
h += '<p class="ok">RommPS ' + esc(u.latest) + " is available. Update it in ProsperoStore, and RomM Sync comes with it.</p>";
|
||||
} else if (u.available) {
|
||||
h += '<p class="ok">Version ' + esc(u.latest) + " is available.</p>";
|
||||
if (!u.autostart) h += '<p class="warn">' + (u.autostart_supported ? "Autostart isn't set up, so the" : "The") + " update only lasts until the console restarts.</p>";
|
||||
@@ -1090,7 +1108,7 @@ function renderUpdate(u) {
|
||||
$("updBody").innerHTML = h;
|
||||
if ($("updInstall")) $("updInstall").onclick = installUpdate;
|
||||
$("updNotice").style.display = u.available && !busy ? "" : "none";
|
||||
$("updNotice").innerHTML = "<b>RomM Sync " + esc(u.latest) + ' is available.</b> <button class="small" id="updGo">See what\'s new</button>';
|
||||
$("updNotice").innerHTML = "<b>" + (u.self_update === false ? "RommPS " : "RomM Sync ") + esc(u.latest) + ' is available.</b> <button class="small" id="updGo">See what\'s new</button>';
|
||||
$("updGo").onclick = function () { showTab("settings"); };
|
||||
}
|
||||
function loadUpdate() { return api("GET", "/api/update").then(renderUpdate).catch(function () {}); }
|
||||
@@ -1112,7 +1130,6 @@ function installUpdate() {
|
||||
$("updCheck").onclick = function () { api("POST", "/api/update/check").then(function (u) { renderUpdate(u); watchUpdate(); }).catch(function (e) { toast(e.message, 1); }); };
|
||||
$("updAuto").onchange = function () { api("POST", "/api/config", { update_check: this.checked }).then(function () { toast("Saved"); }).catch(function (e) { toast(e.message, 1); }); };
|
||||
|
||||
$("tileBtn").onclick = function () { api("POST", "/api/tile").then(function () { toast("Tile added"); }).catch(function (e) { toast(e.message, 1); }); };
|
||||
$("forget").onclick = function () { if (confirm("Unpair from this RomM server? Files on the console are kept.")) api("POST", "/api/pair/forget").then(function () { poll(); showTab("pair"); }); };
|
||||
|
||||
// Collapsible settings cards, collapsed until opened. The choice sticks per browser.
|
||||
|
||||
Reference in new issue
Block a user