mirror of
https://github.com/elripalda/psgfx.git
synced 2026-10-06 09:00:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f059dc32b | ||
|
|
a1fc21a34f | ||
|
|
a5894f49f9 |
No files matched your search
@@ -5,3 +5,4 @@ psgfx.json
|
||||
ps5-art-studio.json
|
||||
__pycache__/
|
||||
fake-ps5/
|
||||
PSGFX Layout/
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="../../releases/latest"><b>Download PSGFX for Windows</b></a>
|
||||
</p>
|
||||
|
||||
PSGFX gives your PS5 homebrew and games new home-screen art. Pick an app, drop in any image, and click **Apply**. PSGFX resizes and converts it to the exact format the console expects, uploads it to the right folder, and keeps a backup of the original so you can undo it with one click.
|
||||
PSGFX gives your PS5 homebrew and games new home-screen art, and lets you arrange the home screen itself: hide tiles like the PlayStation Store, rename anything, move apps between Games and Media, and lock your games in the order you want. Pick an app, drop in any image, and click **Apply**. PSGFX resizes and converts it to the exact format the console expects, uploads it to the right folder, and keeps a backup of the original so you can undo it with one click.
|
||||
|
||||
It works through [PS5 Upload](https://github.com/phantomptr/ps5upload)'s payload, which is already running on your console. There's nothing to install on the PS5 or on your PC: PSGFX is a single `.exe`.
|
||||
|
||||
@@ -18,8 +18,13 @@ It works through [PS5 Upload](https://github.com/phantomptr/ps5upload)'s payload
|
||||
- **Custom backgrounds.** Any 16:9 image becomes the backdrop behind the app on the home screen. Homebrew gets full 4K backgrounds.
|
||||
- **Homebrew and installed games.** Homebrew apps registered with PS5 Upload show up with their icons, and so do your installed PS4 and PS5 games, with their real names.
|
||||
- **Live preview.** A replica of the PS5 home screen shows your art before anything touches the console. Drop an image on the tile to set the icon, or anywhere else to set the background.
|
||||
- **Rename from the Art view.** Change a homebrew app's or game's home-screen name right next to its art. For homebrew, PSGFX also updates the name in the app's `param.json` so changing its art later keeps the new name.
|
||||
- **One-click restore.** The first time you change something, PSGFX saves the original next to it. **Restore original art** puts it back exactly.
|
||||
- **Clean list.** System apps and leftovers are tucked away under *Show other items*.
|
||||
- **Home layout (new in 1.1).** Hide any tile (PlayStation Store, Game Library, PS Plus, media apps), rename games and apps, move apps between the Games and Media tabs, and reorder your games by dragging or with A–Z / Z–A.
|
||||
- **Lock order.** Keeps your arrangement in place even after you play other games.
|
||||
- **Layout presets.** Save a layout when you apply it and re-apply it in one click after a firmware update or database rebuild resets the home screen.
|
||||
- **Home-screen backups.** PSGFX saves the home-screen database before every layout change, plus a copy of the very first one it read. Restore any of them from the Backups dialog.
|
||||
- **No setup.** A single portable Windows app that opens in your browser. It remembers your PS5's IP address.
|
||||
|
||||
## Requirements
|
||||
@@ -38,6 +43,15 @@ It works through [PS5 Upload](https://github.com/phantomptr/ps5upload)'s payload
|
||||
|
||||
To undo, select the app and click **Restore original art**.
|
||||
|
||||
### Home layout
|
||||
|
||||
1. Connect, then click **Home layout** at the top.
|
||||
2. Pick a tile in the strip under the preview. Choose **Games**, **Media** or **Hidden**, or type a new name.
|
||||
3. Drag games into any order, or use **A–Z** / **Z–A**. Tick **Lock order** to keep it that way after you play other games.
|
||||
4. Click **Apply to PS5**, optionally saving the layout as a preset, then restart the PS5.
|
||||
|
||||
PSGFX edits the console's home-screen database (`/system_data/priv/mms/app.db`) through PS5 Upload's built-in FTP server, which it starts for you. Backups and presets are kept in the **PSGFX Layout** folder next to `PSGFX.exe`. If the home screen ever looks wrong, restore a backup, or use Safe Mode → **Rebuild Database**. App Library always comes back on its own; the system adds it regardless of the database.
|
||||
|
||||

|
||||
|
||||
## Tips
|
||||
@@ -51,7 +65,7 @@ To undo, select the app and click **Restore original art**.
|
||||
|
||||
## Build from source
|
||||
|
||||
PSGFX is plain C++17 with no external dependencies. Everything it needs is in `src/lib`.
|
||||
PSGFX is plain C++17 with no external dependencies. Everything it needs is in `src/lib` (including the SQLite amalgamation used for Home layout).
|
||||
|
||||
```bash
|
||||
# Linux / WSL: produces build/PSGFX.exe
|
||||
@@ -66,7 +80,7 @@ On Windows, run `./build.sh` from an [MSYS2](https://www.msys2.org/) MinGW shell
|
||||
|
||||
### How it works
|
||||
|
||||
PSGFX runs a small local web server (127.0.0.1 only) and opens your browser to it. It talks to PS5 Upload's payload over its FTX2 protocol on ports 9113 and 9114. It uses the payload's existing commands to list apps, read files, upload them (BLAKE3-verified), copy, and back up. Homebrew art is written to the app's `sce_sys` folder and to the home-screen metadata folder. Game art is written to `/user/appmeta/<title id>` in the same size and format as the original files. Backgrounds are encoded as BC7 DDS where the console uses DDS.
|
||||
PSGFX runs a small local web server (127.0.0.1 only) and opens your browser to it. It talks to PS5 Upload's payload over its FTX2 protocol on ports 9113 and 9114. It uses the payload's existing commands to list apps, read files, upload them (BLAKE3-verified), copy, and back up. Homebrew art is written to the app's `sce_sys` folder and to the home-screen metadata folder. Game art is written to `/user/appmeta/<title id>` in the same size and format as the original files. When the home-screen database (`app.db`) shows the game's tile art coming from somewhere PSGFX can't write, such as inside the game's own package, PSGFX writes the image to `/user/appmeta/<title id>/psgfx_*` and points the database at it. It keeps the original paths in `psgfx_original.json` for **Restore original art** and backs up the database first, as Home layout does. Backgrounds are encoded as BC7 DDS where the console uses DDS.
|
||||
|
||||
`tools/mock_ps5.py` is a stand-in for the payload that serves a folder on your computer as if it were a PS5. Use it to develop without a console:
|
||||
|
||||
|
||||
@@ -8,13 +8,15 @@ cd "$(dirname "$0")"
|
||||
mkdir -p build
|
||||
|
||||
BLAKE3_FLAGS="-O2 -DBLAKE3_NO_SSE2 -DBLAKE3_NO_SSE41 -DBLAKE3_NO_AVX2 -DBLAKE3_NO_AVX512"
|
||||
SQLITE_FLAGS="-O2 -DSQLITE_THREADSAFE=1 -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_DEFAULT_MEMSTATUS=0"
|
||||
SOURCES="src/main.cpp src/ui.cpp src/assets_embed.cpp src/lib/bc7enc.cpp src/lib/bc7decomp.cpp"
|
||||
|
||||
if [[ "${1:-}" == "linux" ]]; then
|
||||
for f in src/lib/blake3.c src/lib/blake3_dispatch.c src/lib/blake3_portable.c; do
|
||||
gcc $BLAKE3_FLAGS -c "$f" -o "build/$(basename "${f%.c}").o"
|
||||
done
|
||||
g++ -O2 -std=c++17 -pthread $SOURCES build/blake3*.o -o build/psgfx
|
||||
[[ -f build/sqlite3.o ]] || gcc $SQLITE_FLAGS -c src/lib/sqlite3.c -o build/sqlite3.o
|
||||
g++ -O2 -std=c++17 -pthread $SOURCES build/blake3*.o build/sqlite3.o -o build/psgfx
|
||||
echo "Built build/psgfx"
|
||||
exit 0
|
||||
fi
|
||||
@@ -23,8 +25,9 @@ if command -v x86_64-w64-mingw32-g++ >/dev/null; then P=x86_64-w64-mingw32-; els
|
||||
for f in src/lib/blake3.c src/lib/blake3_dispatch.c src/lib/blake3_portable.c; do
|
||||
${P}gcc $BLAKE3_FLAGS -c "$f" -o "build/$(basename "${f%.c}").o"
|
||||
done
|
||||
[[ -f build/sqlite3_win.o ]] || ${P}gcc $SQLITE_FLAGS -c src/lib/sqlite3.c -o build/sqlite3_win.o
|
||||
(cd src && ${P}windres psgfx.rc -O coff -o ../build/psgfx_res.o)
|
||||
${P}g++ -O2 -std=c++17 -static -static-libgcc -static-libstdc++ -pthread \
|
||||
$SOURCES build/blake3*.o build/psgfx_res.o -lws2_32 -lshell32 -o build/PSGFX.exe
|
||||
$SOURCES build/blake3*.o build/sqlite3_win.o build/psgfx_res.o -lws2_32 -lshell32 -o build/PSGFX.exe
|
||||
${P}strip build/PSGFX.exe
|
||||
echo "Built build/PSGFX.exe"
|
||||
+35
-26
@@ -1,26 +1,35 @@
|
||||
PSGFX 1.0
|
||||
Custom icons and backgrounds for your PS5 homebrew and games.
|
||||
|
||||
BEFORE YOU START
|
||||
- PS5 Upload's payload must be running on your PS5 (the same setup you use to upload homebrew).
|
||||
- Your PC and PS5 must be on the same network.
|
||||
|
||||
STEPS
|
||||
1. Double-click PSGFX.exe. PSGFX opens in your browser.
|
||||
(If Windows SmartScreen warns, click "More info" > "Run anyway".
|
||||
If the firewall asks, allow it on private networks.)
|
||||
2. Type your PS5's IP address and click Connect.
|
||||
3. Pick a homebrew app or an installed game from the list.
|
||||
4. Drop an image on the preview's tile (icon) or background (background),
|
||||
or use the "Choose image" buttons. Any PNG or JPEG works.
|
||||
5. Close that app or game on the PS5, then click "Apply to PS5".
|
||||
6. Restart the PS5 to see the new art.
|
||||
|
||||
UNDO
|
||||
- Select the app and click "Restore original art".
|
||||
|
||||
TIPS
|
||||
- Best results: icons 512x512 or larger, backgrounds 3840x2160.
|
||||
- A game update may bring back that game's original art. Just apply yours again.
|
||||
- Your PS5 IP is saved in psgfx.json next to PSGFX.exe.
|
||||
- To quit, close the black console window.
|
||||
PSGFX 1.1
|
||||
Custom icons and backgrounds for your PS5 homebrew and games, and your home-screen layout.
|
||||
|
||||
BEFORE YOU START
|
||||
- PS5 Upload's payload must be running on your PS5 (the same setup you use to upload homebrew).
|
||||
- Your PC and PS5 must be on the same network.
|
||||
|
||||
STEPS
|
||||
1. Double-click PSGFX.exe. PSGFX opens in your browser.
|
||||
(If Windows SmartScreen warns, click "More info" > "Run anyway".
|
||||
If the firewall asks, allow it on private networks.)
|
||||
2. Type your PS5's IP address and click Connect.
|
||||
3. Pick a homebrew app or an installed game from the list.
|
||||
4. Drop an image on the preview's tile (icon) or background (background),
|
||||
or use the "Choose image" buttons. Any PNG or JPEG works.
|
||||
5. Close that app or game on the PS5, then click "Apply to PS5".
|
||||
6. Restart the PS5 to see the new art.
|
||||
|
||||
UNDO
|
||||
- Select the app and click "Restore original art".
|
||||
|
||||
HOME LAYOUT (new in 1.1)
|
||||
1. Connect, then click "Home layout" at the top.
|
||||
2. Select a tile: choose Games, Media or Hidden, or rename it.
|
||||
3. Drag games into order or use A-Z / Z-A. Tick "Lock order" to keep it.
|
||||
4. Click "Apply to PS5" (optionally save it as a preset), then restart the PS5.
|
||||
- Backups and presets live in the "PSGFX Layout" folder next to PSGFX.exe.
|
||||
Use "Backups" to undo, or Safe Mode > Rebuild Database as a last resort.
|
||||
- After a firmware update, open "Presets", load your layout and apply it again.
|
||||
|
||||
TIPS
|
||||
- Best results: icons 512x512 or larger, backgrounds 3840x2160.
|
||||
- A game update may bring back that game's original art. Just apply yours again.
|
||||
- Your PS5 IP is saved in psgfx.json next to PSGFX.exe.
|
||||
- To quit, close the black console window.
|
||||
@@ -0,0 +1,173 @@
|
||||
// Minimal FTP client, used only for the home-screen database.
|
||||
//
|
||||
// PS5 Upload's own file commands only write under /data, /user and USB drives, but the
|
||||
// home screen lives in /system_data/priv/mms/app.db. The payload can start its built-in
|
||||
// FTP server on request (management frame FTP_START), and that server can reach the
|
||||
// whole filesystem, so the Home layout feature goes through it.
|
||||
#pragma once
|
||||
#include "ps5client.h"
|
||||
#include "lib/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ftp {
|
||||
|
||||
using ps5::Error;
|
||||
|
||||
constexpr uint16_t FTP_START = 224, FTP_START_ACK = 225;
|
||||
constexpr int DEFAULT_PORT = 2122;
|
||||
|
||||
// Ask PS5 Upload's payload to start its FTP server; returns the port it listens on.
|
||||
inline int start_server(ps5::Client &c) {
|
||||
std::string body = nlohmann::json{{"port", DEFAULT_PORT}, {"root", "/"}, {"readonly", false}}.dump();
|
||||
auto j = nlohmann::json::parse(c.call_str(FTP_START, body, FTP_START_ACK, 15000));
|
||||
std::string err = j.value("error", "");
|
||||
if (j.value("ok", false) || err == "already_running") return j.value("port", DEFAULT_PORT) ? j.value("port", DEFAULT_PORT) : DEFAULT_PORT;
|
||||
if (err == "stopping") throw Error("PS5 Upload's file server is still shutting down. Try again in a few seconds.");
|
||||
throw Error("PS5 Upload couldn't start its file server (" + err + ").");
|
||||
}
|
||||
|
||||
class Session {
|
||||
public:
|
||||
Session(const std::string &host, int port) : host_(host) {
|
||||
ctl_ = dial(host, port);
|
||||
expect(reply(), 2, "connect");
|
||||
cmd("USER anonymous"); int code = reply();
|
||||
if (code / 100 == 3) { cmd("PASS anonymous"); code = reply(); }
|
||||
if (code / 100 != 2) throw Error("The PS5's file server refused the login (" + last_ + ")");
|
||||
cmd("TYPE I"); expect(reply(), 2, "binary mode");
|
||||
}
|
||||
~Session() { if (ctl_ != SOCK_BAD) { try { cmd("QUIT"); } catch (...) {} sock_close(ctl_); } }
|
||||
Session(const Session &) = delete; Session &operator=(const Session &) = delete;
|
||||
|
||||
std::vector<uint8_t> get(const std::string &path) {
|
||||
sock_t d = pasv();
|
||||
cmd("RETR " + path);
|
||||
int code = reply();
|
||||
if (code / 100 != 1) { sock_close(d); throw Error("Couldn't read " + path + " (" + last_ + ")"); }
|
||||
std::vector<uint8_t> out; char buf[1 << 16];
|
||||
for (;;) { int n = recv(d, buf, sizeof buf, 0); if (n <= 0) break; out.insert(out.end(), buf, buf + n); }
|
||||
sock_close(d);
|
||||
expect(reply(), 2, "read " + path);
|
||||
return out;
|
||||
}
|
||||
|
||||
void put(const std::string &path, const std::vector<uint8_t> &data) {
|
||||
sock_t d = pasv();
|
||||
cmd("STOR " + path);
|
||||
int code = reply();
|
||||
if (code / 100 != 1) { sock_close(d); throw Error("Couldn't write " + path + " (" + last_ + ")"); }
|
||||
size_t off = 0;
|
||||
while (off < data.size()) {
|
||||
int n = ::send(d, (const char *)data.data() + off, (int)std::min<size_t>(data.size() - off, 1 << 20), 0);
|
||||
if (n <= 0) { sock_close(d); throw Error("Upload of " + path + " was interrupted"); }
|
||||
off += (size_t)n;
|
||||
}
|
||||
sock_close(d);
|
||||
expect(reply(), 2, "write " + path);
|
||||
}
|
||||
|
||||
std::vector<std::string> list(const std::string &dir) {
|
||||
sock_t d = pasv();
|
||||
cmd("NLST " + dir);
|
||||
int code = reply();
|
||||
std::vector<std::string> names;
|
||||
if (code / 100 != 1) { sock_close(d); return names; }
|
||||
std::string all; char buf[8192];
|
||||
for (;;) { int n = recv(d, buf, sizeof buf, 0); if (n <= 0) break; all.append(buf, n); }
|
||||
sock_close(d);
|
||||
reply();
|
||||
size_t p = 0;
|
||||
while (p < all.size()) {
|
||||
size_t e = all.find('\n', p); if (e == std::string::npos) e = all.size();
|
||||
std::string line = all.substr(p, e - p);
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back();
|
||||
if (!line.empty()) { size_t s = line.find_last_of('/'); names.push_back(s == std::string::npos ? line : line.substr(s + 1)); }
|
||||
p = e + 1;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
bool exists(const std::string &path) {
|
||||
size_t s = path.find_last_of('/');
|
||||
std::string dir = s == 0 ? "/" : path.substr(0, s), name = path.substr(s + 1);
|
||||
for (auto &n : list(dir)) if (n == name) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void mkdirs(const std::string &path) {
|
||||
std::string cur;
|
||||
size_t p = 1;
|
||||
while (p <= path.size()) {
|
||||
size_t e = path.find('/', p); if (e == std::string::npos) e = path.size();
|
||||
cur += "/" + path.substr(p, e - p);
|
||||
cmd("MKD " + cur); reply(); // "already exists" is fine
|
||||
p = e + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::string host_, last_, pending_;
|
||||
sock_t ctl_ = SOCK_BAD;
|
||||
|
||||
static sock_t dial(const std::string &host, int port) {
|
||||
addrinfo hints{}, *res = nullptr;
|
||||
hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0 || !res) throw Error("Can't resolve " + host);
|
||||
sock_t s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
#ifdef _WIN32
|
||||
DWORD t = 30000;
|
||||
#else
|
||||
timeval t{30, 0};
|
||||
#endif
|
||||
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char *)&t, sizeof t);
|
||||
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char *)&t, sizeof t);
|
||||
if (s == SOCK_BAD || connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) {
|
||||
freeaddrinfo(res); if (s != SOCK_BAD) sock_close(s);
|
||||
throw Error("Can't reach the PS5's file server at " + host + ":" + std::to_string(port));
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
return s;
|
||||
}
|
||||
void cmd(const std::string &c) {
|
||||
std::string line = c + "\r\n";
|
||||
if (::send(ctl_, line.data(), (int)line.size(), 0) <= 0) throw Error("Lost the connection to the PS5's file server");
|
||||
}
|
||||
std::string read_line() {
|
||||
for (;;) {
|
||||
size_t e = pending_.find('\n');
|
||||
if (e != std::string::npos) { std::string l = pending_.substr(0, e); pending_.erase(0, e + 1); if (!l.empty() && l.back() == '\r') l.pop_back(); return l; }
|
||||
char buf[1024]; int n = recv(ctl_, buf, sizeof buf, 0);
|
||||
if (n <= 0) throw Error("The PS5's file server stopped responding");
|
||||
pending_.append(buf, n);
|
||||
}
|
||||
}
|
||||
// Reads one (possibly multi-line) reply; returns its code.
|
||||
int reply() {
|
||||
std::string l = read_line();
|
||||
if (l.size() >= 4 && l[3] == '-') {
|
||||
std::string code = l.substr(0, 3);
|
||||
for (;;) { std::string m = read_line(); if (m.size() >= 4 && m.compare(0, 3, code) == 0 && m[3] == ' ') { l = m; break; } }
|
||||
}
|
||||
last_ = l;
|
||||
return l.size() >= 3 ? atoi(l.substr(0, 3).c_str()) : 0;
|
||||
}
|
||||
void expect(int code, int cls, const std::string &what) {
|
||||
if (code / 100 != cls) throw Error("The PS5's file server refused to " + what + " (" + last_ + ")");
|
||||
}
|
||||
// Passive data connection. Connects to the same host as the control channel.
|
||||
sock_t pasv() {
|
||||
cmd("PASV");
|
||||
expect(reply(), 2, "open a data connection");
|
||||
int a, b, c, d, p1, p2;
|
||||
size_t o = last_.find('(');
|
||||
if (o == std::string::npos || sscanf(last_.c_str() + o, "(%d,%d,%d,%d,%d,%d)", &a, &b, &c, &d, &p1, &p2) != 6)
|
||||
throw Error("Unexpected reply from the PS5's file server: " + last_);
|
||||
return dial(host_, p1 * 256 + p2);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ftp
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
// Home layout: hide, rename, move between Games/Media and reorder home-screen tiles by
|
||||
// editing the console's home-screen database, /system_data/priv/mms/app.db.
|
||||
//
|
||||
// Schema (per console user <u>):
|
||||
// tbl_iconinfo_<u> one row per tile: titleId, titleName, dispLocation, visible,
|
||||
// lastAccessIndex/Time, lastPlayed*, recentActivity*
|
||||
// tbl_concepticoninfo_<u> per-concept name/art: conceptName, icon0Info, pic0Info
|
||||
// tbl_group_<u> groupName 'MyHome': which system tiles (PS Plus, Store,
|
||||
// Game Library) appear on the Games row
|
||||
// tbl_contentinfo, tbl_conceptmetadata global copies of name/location/recency
|
||||
// tbl_version counters: access_index, played_index
|
||||
// dispLocation is a bit field: 0x02 = Games home, 0x04 = Media home. Only those two
|
||||
// bits are ever changed. The home screen sorts by play history, so ordering sets every
|
||||
// recency field consistently.
|
||||
#pragma once
|
||||
#include "lib/sqlite3.h"
|
||||
#include "lib/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace layout {
|
||||
|
||||
using json = nlohmann::json;
|
||||
struct Error : std::runtime_error { using std::runtime_error::runtime_error; };
|
||||
|
||||
const char *const DB_PATH = "/system_data/priv/mms/app.db";
|
||||
const char *const DB_DIR = "/system_data/priv/mms";
|
||||
const char *const LIBRARY = "NPXS40071";
|
||||
constexpr int64_t BIT_GAMES = 0x02, BIT_MEDIA = 0x04;
|
||||
|
||||
// Disc placeholders and similar internal tiles; never shown.
|
||||
static const std::set<std::string> kInternal = {"NPXS40140", "NPXS40144", "NPXS40145", "NPXS40148",
|
||||
"NPXS40149", "NPXS40150", "NPXS40205", "NPXS40106", "NPXS40063"};
|
||||
|
||||
using Val = std::variant<std::nullptr_t, int64_t, std::string>;
|
||||
|
||||
// An app.db loaded into memory. Nothing touches a file until serialize().
|
||||
class DB {
|
||||
public:
|
||||
explicit DB(const std::vector<uint8_t> &data) {
|
||||
if (sqlite3_open(":memory:", &db_) != SQLITE_OK) throw Error("Couldn't start SQLite");
|
||||
auto *buf = (unsigned char *)sqlite3_malloc64(data.size());
|
||||
if (!buf) throw Error("Out of memory");
|
||||
memcpy(buf, data.data(), data.size());
|
||||
if (sqlite3_deserialize(db_, "main", buf, (sqlite3_int64)data.size(), (sqlite3_int64)data.size(),
|
||||
SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE) != SQLITE_OK)
|
||||
throw Error("That isn't a readable home-screen database");
|
||||
std::string ok = scalar_str("PRAGMA integrity_check");
|
||||
if (ok != "ok") throw Error("The home-screen database failed its integrity check: " + ok);
|
||||
}
|
||||
~DB() { if (db_) sqlite3_close(db_); }
|
||||
DB(const DB &) = delete; DB &operator=(const DB &) = delete;
|
||||
|
||||
std::vector<uint8_t> serialize() {
|
||||
sqlite3_int64 n = 0;
|
||||
unsigned char *p = sqlite3_serialize(db_, "main", &n, 0);
|
||||
if (!p) throw Error("Couldn't save the database");
|
||||
std::vector<uint8_t> out(p, p + n);
|
||||
sqlite3_free(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
void exec(const std::string &sql, const std::vector<Val> &args = {}) {
|
||||
sqlite3_stmt *st = prepare(sql, args);
|
||||
int rc = sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE && rc != SQLITE_ROW) throw Error(std::string("Database error: ") + sqlite3_errmsg(db_));
|
||||
}
|
||||
|
||||
// Runs a query, calling fn(stmt) for every row.
|
||||
template <class F> void each(const std::string &sql, const std::vector<Val> &args, F fn) {
|
||||
sqlite3_stmt *st = prepare(sql, args);
|
||||
int rc;
|
||||
while ((rc = sqlite3_step(st)) == SQLITE_ROW) fn(st);
|
||||
sqlite3_finalize(st);
|
||||
if (rc != SQLITE_DONE) throw Error(std::string("Database error: ") + sqlite3_errmsg(db_));
|
||||
}
|
||||
|
||||
std::string scalar_str(const std::string &sql, const std::vector<Val> &args = {}) {
|
||||
std::string out;
|
||||
each(sql, args, [&](sqlite3_stmt *st) { out = text(st, 0); });
|
||||
return out;
|
||||
}
|
||||
int64_t scalar_int(const std::string &sql, const std::vector<Val> &args = {}) {
|
||||
int64_t out = 0;
|
||||
each(sql, args, [&](sqlite3_stmt *st) { out = sqlite3_column_int64(st, 0); });
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string text(sqlite3_stmt *st, int i) {
|
||||
const unsigned char *t = sqlite3_column_text(st, i);
|
||||
return t ? std::string((const char *)t) : std::string();
|
||||
}
|
||||
static bool null(sqlite3_stmt *st, int i) { return sqlite3_column_type(st, i) == SQLITE_NULL; }
|
||||
|
||||
private:
|
||||
sqlite3 *db_ = nullptr;
|
||||
sqlite3_stmt *prepare(const std::string &sql, const std::vector<Val> &args) {
|
||||
sqlite3_stmt *st = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql.c_str(), -1, &st, nullptr) != SQLITE_OK)
|
||||
throw Error(std::string("Database error: ") + sqlite3_errmsg(db_));
|
||||
for (size_t i = 0; i < args.size(); i++) {
|
||||
int k = (int)i + 1;
|
||||
if (auto *n = std::get_if<int64_t>(&args[i])) sqlite3_bind_int64(st, k, *n);
|
||||
else if (auto *s = std::get_if<std::string>(&args[i])) sqlite3_bind_text(st, k, s->c_str(), (int)s->size(), SQLITE_TRANSIENT);
|
||||
else sqlite3_bind_null(st, k);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
};
|
||||
|
||||
inline std::string q(const std::string &id) {
|
||||
std::string o = "\"";
|
||||
for (char c : id) { if (c == '"') o += '"'; o += c; }
|
||||
return o + "\"";
|
||||
}
|
||||
inline std::string strip_query(const std::string &p) { size_t i = p.find('?'); return i == std::string::npos ? p : p.substr(0, i); }
|
||||
|
||||
struct Tile {
|
||||
std::string id, name, concept, kind, where, icon, bg;
|
||||
int64_t disp = 0, home_idx = 0, access = 0;
|
||||
bool visible = false, home = false;
|
||||
};
|
||||
|
||||
struct Info {
|
||||
std::string user, icon_t, concept_t, group_t;
|
||||
std::vector<std::string> users, warnings;
|
||||
std::vector<Tile> tiles;
|
||||
std::vector<std::pair<std::string, int64_t>> ghosts; // MyHome rows for titles not on the console
|
||||
std::map<std::string, std::set<std::string>> cols;
|
||||
bool has_content = false, has_meta = false, can_order = false;
|
||||
int64_t access_index = 0, played_index = 0;
|
||||
|
||||
const Tile *find(const std::string &id) const {
|
||||
for (auto &t : tiles) if (t.id == id) return &t;
|
||||
return nullptr;
|
||||
}
|
||||
json to_json() const {
|
||||
json ts = json::array();
|
||||
for (auto &t : tiles)
|
||||
ts.push_back({{"id", t.id}, {"name", t.name}, {"kind", t.kind}, {"where", t.where}, {"visible", t.visible},
|
||||
{"home", t.home}, {"homeIdx", t.home_idx}, {"access", t.access}, {"icon", t.icon}, {"bg", t.bg}});
|
||||
return {{"user", user}, {"users", users}, {"tiles", ts}, {"warnings", warnings}, {"canOrder", can_order}};
|
||||
}
|
||||
};
|
||||
|
||||
inline Info analyze(DB &db, const std::string &want_user = "") {
|
||||
Info info;
|
||||
std::set<std::string> tables;
|
||||
db.each("SELECT name FROM sqlite_master WHERE type='table'", {}, [&](sqlite3_stmt *st) { tables.insert(DB::text(st, 0)); });
|
||||
int best = -1;
|
||||
for (auto &t : tables) {
|
||||
if (t.rfind("tbl_iconinfo_", 0) != 0) continue;
|
||||
std::string u = t.substr(13);
|
||||
info.users.push_back(u);
|
||||
int n = (int)db.scalar_int("SELECT count(*) FROM " + q(t));
|
||||
if (u == want_user) { info.user = u; best = 1 << 30; }
|
||||
else if (want_user.empty() && n > best) { best = n; info.user = u; }
|
||||
}
|
||||
if (info.user.empty()) throw Error("This console's home-screen database has a layout PSGFX doesn't know yet (no tbl_iconinfo_ tables).");
|
||||
std::sort(info.users.begin(), info.users.end());
|
||||
info.icon_t = "tbl_iconinfo_" + info.user;
|
||||
info.concept_t = tables.count("tbl_concepticoninfo_" + info.user) ? "tbl_concepticoninfo_" + info.user : "";
|
||||
info.group_t = tables.count("tbl_group_" + info.user) ? "tbl_group_" + info.user : "";
|
||||
info.has_content = tables.count("tbl_contentinfo") > 0;
|
||||
info.has_meta = tables.count("tbl_conceptmetadata") > 0;
|
||||
if (info.users.size() > 1)
|
||||
info.warnings.push_back("This console has " + std::to_string(info.users.size()) + " user profiles. You're editing the one with the most tiles; switch with the profile menu.");
|
||||
if (tables.count("tbl_version")) {
|
||||
info.access_index = db.scalar_int("SELECT status FROM tbl_version WHERE category='access_index'");
|
||||
info.played_index = db.scalar_int("SELECT status FROM tbl_version WHERE category='played_index'");
|
||||
}
|
||||
for (auto &t : tables)
|
||||
db.each("SELECT name FROM pragma_table_info(?)", {t}, [&](sqlite3_stmt *st) { info.cols[t].insert(DB::text(st, 0)); });
|
||||
|
||||
struct Concept { std::string name, icon, bg; };
|
||||
std::map<std::string, Concept> concepts;
|
||||
if (!info.concept_t.empty())
|
||||
db.each("SELECT localConceptId, conceptName, icon0Info, pic0Info FROM " + q(info.concept_t), {}, [&](sqlite3_stmt *st) {
|
||||
concepts[DB::text(st, 0)] = {DB::text(st, 1), strip_query(DB::text(st, 2)), strip_query(DB::text(st, 3))};
|
||||
});
|
||||
std::map<std::string, int64_t> home;
|
||||
if (!info.group_t.empty())
|
||||
db.each("SELECT itemId, idx FROM " + q(info.group_t) + " WHERE groupName='MyHome' ORDER BY idx", {}, [&](sqlite3_stmt *st) {
|
||||
home[DB::text(st, 0)] = sqlite3_column_int64(st, 1);
|
||||
});
|
||||
|
||||
std::set<std::string> seen;
|
||||
db.each("SELECT titleId, titleName, localConceptId, dispLocation, visible, lastAccessIndex, installStatus FROM " + q(info.icon_t), {},
|
||||
[&](sqlite3_stmt *st) {
|
||||
Tile t;
|
||||
t.id = DB::text(st, 0);
|
||||
if (t.id.empty() || kInternal.count(t.id)) return;
|
||||
seen.insert(t.id);
|
||||
t.name = DB::text(st, 1); t.concept = DB::text(st, 2);
|
||||
t.disp = sqlite3_column_int64(st, 3); t.visible = sqlite3_column_int64(st, 4) == 1;
|
||||
t.access = sqlite3_column_int64(st, 5);
|
||||
int64_t install = sqlite3_column_int64(st, 6);
|
||||
auto c = concepts.find(t.concept);
|
||||
if (c != concepts.end()) { if (!c->second.name.empty()) t.name = c->second.name; t.icon = c->second.icon; t.bg = c->second.bg; }
|
||||
t.kind = t.id.rfind("NPXS", 0) == 0 ? "system" : "game";
|
||||
auto h = home.find(t.id); // the console only honours MyHome for its own system tiles
|
||||
if (h != home.end() && t.kind == "system") { t.home = true; t.home_idx = h->second; }
|
||||
if (t.home) t.where = "games";
|
||||
else if (!t.visible) t.where = "hidden";
|
||||
else if (t.disp & BIT_GAMES) t.where = "games";
|
||||
else if (t.disp & BIT_MEDIA) t.where = "media";
|
||||
else t.where = "hidden";
|
||||
if (t.where == "hidden" && install != 2) return; // uninstalled leftovers
|
||||
info.tiles.push_back(t);
|
||||
});
|
||||
for (auto &[id, idx] : home) if (!seen.count(id)) info.ghosts.push_back({id, idx});
|
||||
std::sort(info.ghosts.begin(), info.ghosts.end(), [](auto &a, auto &b) { return a.second < b.second; });
|
||||
std::stable_sort(info.tiles.begin(), info.tiles.end(), [](const Tile &a, const Tile &b) { return a.access > b.access; });
|
||||
info.can_order = info.access_index > 0;
|
||||
return info;
|
||||
}
|
||||
|
||||
inline std::string fmt_time(time_t t) {
|
||||
char b[32]; tm g{};
|
||||
#ifdef _WIN32
|
||||
gmtime_s(&g, &t);
|
||||
#else
|
||||
gmtime_r(&t, &g);
|
||||
#endif
|
||||
strftime(b, sizeof b, "%Y-%m-%d %H:%M:%S.000", &g);
|
||||
return b;
|
||||
}
|
||||
|
||||
inline void insert_before_library(std::vector<std::string> &v, const std::string &id) {
|
||||
if (std::find(v.begin(), v.end(), id) != v.end()) return;
|
||||
auto it = std::find(v.begin(), v.end(), LIBRARY);
|
||||
v.insert(it, id);
|
||||
}
|
||||
|
||||
// req: {edits: {id: {where?, name?}}, recent: [ids], recentChanged, lock}
|
||||
inline void apply(DB &db, const Info &info, const json &req, std::vector<std::string> &log) {
|
||||
db.exec("BEGIN");
|
||||
try {
|
||||
struct Target { std::string table, key; bool by_concept; };
|
||||
std::vector<Target> targets = {{info.icon_t, "titleId", false}};
|
||||
if (!info.concept_t.empty()) targets.push_back({info.concept_t, "localConceptId", true});
|
||||
if (info.has_content) targets.push_back({"tbl_contentinfo", "titleId", false});
|
||||
if (info.has_meta) targets.push_back({"tbl_conceptmetadata", "localConceptId", true});
|
||||
auto key_of = [](const Tile &t, const Target &tg) { return tg.by_concept ? t.concept : t.id; };
|
||||
auto has = [&](const std::string &table, const std::string &col) {
|
||||
auto it = info.cols.find(table); return it != info.cols.end() && it->second.count(col) > 0;
|
||||
};
|
||||
|
||||
std::vector<std::string> home_order;
|
||||
for (auto &t : info.tiles) if (t.home) home_order.push_back(t.id);
|
||||
std::sort(home_order.begin(), home_order.end(), [&](auto &a, auto &b) { return info.find(a)->home_idx < info.find(b)->home_idx; });
|
||||
bool home_changed = false;
|
||||
|
||||
const json edits = req.value("edits", json::object());
|
||||
for (auto &[id, e] : edits.items()) {
|
||||
const Tile *t = info.find(id);
|
||||
if (!t || !e.is_object()) continue;
|
||||
std::vector<std::string> did;
|
||||
std::string where = e.value("where", "");
|
||||
if (where == "hidden") {
|
||||
if (t->home) { home_order.erase(std::remove(home_order.begin(), home_order.end(), id), home_order.end()); home_changed = true; }
|
||||
db.exec("UPDATE " + q(info.icon_t) + " SET visible=0 WHERE titleId=?", {id});
|
||||
did.push_back("hidden");
|
||||
} else if (where == "games" || where == "media") {
|
||||
int64_t set = where == "games" ? BIT_GAMES : BIT_MEDIA, clr = where == "games" ? BIT_MEDIA : BIT_GAMES;
|
||||
for (auto &tg : targets)
|
||||
if (has(tg.table, "dispLocation"))
|
||||
db.exec("UPDATE " + q(tg.table) + " SET dispLocation=((dispLocation | ?) & ~?) WHERE " + q(tg.key) + "=?", {set, clr, key_of(*t, tg)});
|
||||
db.exec("UPDATE " + q(info.icon_t) + " SET visible=1 WHERE titleId=?", {id});
|
||||
if (t->kind == "system" && where == "games" && !t->home) { insert_before_library(home_order, id); home_changed = true; }
|
||||
did.push_back("shown on " + where);
|
||||
}
|
||||
if (e.contains("name") && e["name"].is_string()) {
|
||||
std::string n = e["name"].get<std::string>();
|
||||
while (!n.empty() && n.back() == ' ') n.pop_back();
|
||||
if (!n.empty()) {
|
||||
db.exec("UPDATE " + q(info.icon_t) + " SET titleName=? WHERE titleId=?", {n, id});
|
||||
if (!info.concept_t.empty()) {
|
||||
std::string set = "conceptName=?";
|
||||
std::vector<Val> args = {n};
|
||||
if (has(info.concept_t, "primaryTitleName")) { set += ", primaryTitleName=?"; args.push_back(n); }
|
||||
if (has(info.concept_t, "ucmConceptName")) { set += ", ucmConceptName=CASE WHEN ucmConceptName IS NULL THEN NULL ELSE ? END"; args.push_back(n); }
|
||||
args.push_back(t->concept);
|
||||
db.exec("UPDATE " + q(info.concept_t) + " SET " + set + " WHERE localConceptId=?", args);
|
||||
}
|
||||
if (info.has_content) db.exec("UPDATE tbl_contentinfo SET titleName=? WHERE titleId=?", {n, id});
|
||||
if (info.has_meta) db.exec("UPDATE tbl_conceptmetadata SET conceptName=? WHERE localConceptId=?", {n, t->concept});
|
||||
did.push_back("renamed to \"" + n + "\"");
|
||||
}
|
||||
}
|
||||
if (!did.empty()) {
|
||||
std::string s; for (auto &d : did) s += (s.empty() ? "" : ", ") + d;
|
||||
log.push_back(t->name + ": " + s);
|
||||
}
|
||||
}
|
||||
|
||||
if (home_changed && !info.group_t.empty()) {
|
||||
std::vector<std::string> fin = home_order;
|
||||
for (auto &g : info.ghosts) {
|
||||
if (!fin.empty() && fin.front() == LIBRARY) fin.push_back(g.first);
|
||||
else insert_before_library(fin, g.first);
|
||||
}
|
||||
db.exec("DELETE FROM " + q(info.group_t) + " WHERE groupName='MyHome'");
|
||||
for (size_t i = 0; i < fin.size(); i++)
|
||||
db.exec("INSERT INTO " + q(info.group_t) + " (itemId, groupName, type, idx, lastModifiedDate) VALUES (?, 'MyHome', 'title', ?, NULL)",
|
||||
{fin[i], (int64_t)i + 1});
|
||||
}
|
||||
|
||||
std::vector<std::string> recent;
|
||||
const json recent_in = req.value("recent", json::array());
|
||||
for (auto &x : recent_in) if (x.is_string()) recent.push_back(x.get<std::string>());
|
||||
if (req.value("recentChanged", false) && info.can_order && !recent.empty()) {
|
||||
bool lock = req.value("lock", false);
|
||||
int64_t n = (int64_t)recent.size();
|
||||
time_t base = time(nullptr);
|
||||
int64_t acc = info.access_index + n, played = info.played_index + n;
|
||||
if (lock) { base = 4102401600; acc = 1000000000; played = 1000000000; } // 2099-12-31 12:00 UTC
|
||||
static const char *always[] = {"lastAccessTime", "recentActivityDatePlayedOrInstalled", "lastInteractedTime"};
|
||||
static const char *played_only[] = {"lastPlayedDate", "lastPlayedDateOnConsole", "recentActivityDatePlayedOrPurchased"};
|
||||
std::string first;
|
||||
for (int64_t i = 0; i < n; i++) {
|
||||
const Tile *t = info.find(recent[(size_t)i]);
|
||||
if (!t) continue;
|
||||
if (first.size() < 120) first += (first.empty() ? "" : " > ") + t->name;
|
||||
std::string when = fmt_time(base - i * 60);
|
||||
for (auto &tg : targets) {
|
||||
std::string set; std::vector<Val> args;
|
||||
auto add = [&](const std::string &s, Val v) { set += (set.empty() ? "" : ", ") + s; args.push_back(std::move(v)); };
|
||||
if (has(tg.table, "lastAccessIndex")) add("lastAccessIndex=?", acc - i);
|
||||
if (has(tg.table, "lastPlayedIndex")) add("lastPlayedIndex=CASE WHEN lastPlayedIndex IS NULL THEN NULL ELSE ? END", played - i);
|
||||
for (auto c : always) if (has(tg.table, c)) add(q(c) + "=?", when);
|
||||
for (auto c : played_only) if (has(tg.table, c)) add(q(c) + "=CASE WHEN " + q(c) + " IS NULL THEN NULL ELSE ? END", when);
|
||||
if (set.empty()) continue;
|
||||
args.push_back(key_of(*t, tg));
|
||||
db.exec("UPDATE " + q(tg.table) + " SET " + set + " WHERE " + q(tg.key) + "=?", args);
|
||||
}
|
||||
}
|
||||
if (!lock) {
|
||||
db.exec("UPDATE tbl_version SET status=? WHERE category='access_index'", {acc});
|
||||
db.exec("UPDATE tbl_version SET status=? WHERE category='played_index'", {played});
|
||||
}
|
||||
log.push_back(std::string(lock ? "Order locked" : "Order set") + " for " + std::to_string(n) + " tiles: " + first + (n > 4 ? " > ..." : ""));
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (...) {
|
||||
try { db.exec("ROLLBACK"); } catch (...) {}
|
||||
throw;
|
||||
}
|
||||
std::string ok = db.scalar_str("PRAGMA integrity_check");
|
||||
if (ok != "ok") throw Error("The edited database failed its integrity check, so nothing was uploaded: " + ok);
|
||||
}
|
||||
|
||||
} // namespace layout
|
||||
+269649
File diff suppressed because it is too large.
Load diff
+14349
File diff suppressed because it is too large.
Load diff
+421
-12
@@ -8,6 +8,8 @@
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include "imaging.h"
|
||||
#include "ps5client.h"
|
||||
#include "ftp.h"
|
||||
#include "layout.h"
|
||||
#include "lib/json.hpp"
|
||||
|
||||
#include <atomic>
|
||||
@@ -18,11 +20,14 @@
|
||||
#include <set>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#ifdef _WIN32
|
||||
#include <shellapi.h>
|
||||
#include <tlhelp32.h>
|
||||
#endif
|
||||
#define STUDIO_VERSION "1.0"
|
||||
#define STUDIO_VERSION "1.1"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
@@ -42,6 +47,7 @@ static std::map<std::string, Staged> g_staged; // "icon" | "background
|
||||
static std::map<std::string, std::vector<uint8_t>> g_icon_cache;
|
||||
static std::string g_config_path = "psgfx.json";
|
||||
static std::string g_legacy_config_path; // pre-1.0 name, read once if present
|
||||
static std::string g_data_dir = "PSGFX Layout"; // home-layout backups and presets, next to the exe
|
||||
|
||||
static void load_config() {
|
||||
std::ifstream f(g_config_path);
|
||||
@@ -65,6 +71,11 @@ static ps5::Client client() {
|
||||
static std::string lower(std::string s) { for (auto &ch : s) ch = (char)tolower((unsigned char)ch); return s; }
|
||||
static bool ends_with(const std::string &s, const std::string &t) { return s.size() >= t.size() && s.compare(s.size() - t.size(), t.size(), t) == 0; }
|
||||
static std::string appmeta(const std::string &id) { return "/user/appmeta/" + id; }
|
||||
// Games PS5 Upload runs from a mounted image (ShadowMount+, or its own mounts). Their sce_sys is
|
||||
// inside the image, read-only or nearly full, so their art is changed like an installed game's.
|
||||
static bool mounted_image(const std::string &src) {
|
||||
return src.rfind("/mnt/shadowmnt/", 0) == 0 || src.rfind("/mnt/ps5upload/", 0) == 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────── PS5 file helpers ─────────────────────
|
||||
static json list_dir(ps5::Client &c, const std::string &dir) {
|
||||
@@ -112,27 +123,196 @@ static std::string meta_title(ps5::Client &c, const std::string &id) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─────────────────────────────── home layout ──────────────────────────
|
||||
// Edits the home-screen database through PS5 Upload's built-in FTP server (see ftp.h).
|
||||
// Every apply re-downloads the database, saves it to PSGFX Layout/backups first, edits
|
||||
// a copy in memory, checks it, then uploads it.
|
||||
namespace fs = std::filesystem;
|
||||
static std::mutex g_layout_mu; // one layout operation at a time
|
||||
static std::mutex g_ftp_mu;
|
||||
static std::unique_ptr<ftp::Session> g_ftp;
|
||||
static std::string g_layout_user;
|
||||
static std::map<std::string, std::vector<uint8_t>> g_tile_img_cache;
|
||||
|
||||
static fs::path data_path(const std::string &sub) { fs::path p = fs::path(g_data_dir) / sub; fs::create_directories(p); return p; }
|
||||
|
||||
static std::string now_stamp(const char *fmt) {
|
||||
auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
|
||||
tm l{};
|
||||
#ifdef _WIN32
|
||||
localtime_s(&l, &t);
|
||||
#else
|
||||
localtime_r(&t, &l);
|
||||
#endif
|
||||
char b[64]; strftime(b, sizeof b, fmt, &l); return b;
|
||||
}
|
||||
|
||||
// Runs fn with a live FTP session, reconnecting once if the old one has gone stale.
|
||||
template <class F> static auto with_ftp(F fn) {
|
||||
std::lock_guard<std::mutex> l(g_ftp_mu);
|
||||
for (int attempt = 0;; attempt++) {
|
||||
try {
|
||||
if (!g_ftp) {
|
||||
auto c = client();
|
||||
int port = ftp::start_server(c);
|
||||
if (const char *e = getenv("PS5AS_FTP_PORT")) port = atoi(e); // test hook
|
||||
g_ftp = std::make_unique<ftp::Session>(c.host, port);
|
||||
}
|
||||
return fn(*g_ftp);
|
||||
} catch (const ps5::Error &) {
|
||||
g_ftp.reset();
|
||||
if (attempt) throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> read_local(const fs::path &p) {
|
||||
std::ifstream f(p, std::ios::binary);
|
||||
return std::vector<uint8_t>((std::istreambuf_iterator<char>(f)), {});
|
||||
}
|
||||
static void write_local(const fs::path &p, const std::vector<uint8_t> &d) {
|
||||
std::ofstream f(p, std::ios::binary); f.write((const char *)d.data(), (std::streamsize)d.size());
|
||||
if (!f) throw ps5::Error("Couldn't save " + p.u8string());
|
||||
}
|
||||
|
||||
static std::string make_backup(const std::vector<uint8_t> &db, const std::string &label) {
|
||||
std::string id = now_stamp("%Y-%m-%d_%H%M%S");
|
||||
fs::path dir = data_path("backups") / id;
|
||||
for (int n = 2; fs::exists(dir); n++) dir = data_path("backups") / (id + "_" + std::to_string(n));
|
||||
fs::create_directories(dir);
|
||||
write_local(dir / "app.db", db);
|
||||
json m = {{"id", dir.filename().u8string()}, {"label", label}, {"ps5", g_ip}, {"created", now_stamp("%Y-%m-%d %H:%M:%S")}};
|
||||
std::ofstream(dir / "backup.json") << m.dump(2);
|
||||
return dir.filename().u8string();
|
||||
}
|
||||
|
||||
static json layout_state(const std::vector<uint8_t> &db_bytes, std::vector<std::string> extra_warn = {}) {
|
||||
layout::DB db(db_bytes);
|
||||
auto info = layout::analyze(db, g_layout_user);
|
||||
json j = info.to_json();
|
||||
for (auto &w : extra_warn) j["warnings"].push_back(w);
|
||||
j["ok"] = true;
|
||||
return j;
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> pull_db(std::vector<std::string> *warn = nullptr) {
|
||||
return with_ftp([&](ftp::Session &f) {
|
||||
if (warn)
|
||||
for (auto &n : f.list(layout::DB_DIR))
|
||||
if (n == "app.db-wal" || n == "app.db-journal")
|
||||
warn->push_back("The console has unsaved home-screen changes (" + n + "). For the most accurate result, restart the PS5 and reconnect.");
|
||||
auto b = f.get(layout::DB_PATH);
|
||||
if (b.empty()) throw ps5::Error("The PS5 sent an empty home-screen database.");
|
||||
return b;
|
||||
});
|
||||
}
|
||||
|
||||
static json layout_load(const std::string &user) {
|
||||
std::lock_guard<std::mutex> l(g_layout_mu);
|
||||
g_layout_user = user;
|
||||
{ std::lock_guard<std::mutex> c(g_mu); g_tile_img_cache.clear(); }
|
||||
std::vector<std::string> warn;
|
||||
auto db = pull_db(&warn);
|
||||
fs::path marker = data_path("backups") / (".original-" + g_ip);
|
||||
if (!fs::exists(marker)) { make_backup(db, "Original - first time PSGFX read this console"); std::ofstream(marker) << "1"; }
|
||||
return layout_state(db, warn);
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> tile_image(const std::string &path) {
|
||||
if (path.empty() || path[0] != '/' || path.find("..") != std::string::npos) return {};
|
||||
std::string ext = lower(path.substr(path.find_last_of('.') + 1));
|
||||
if (ext != "png" && ext != "dds" && ext != "jpg") return {};
|
||||
{
|
||||
std::lock_guard<std::mutex> l(g_mu);
|
||||
auto it = g_tile_img_cache.find(path);
|
||||
if (it != g_tile_img_cache.end()) return it->second;
|
||||
}
|
||||
std::vector<uint8_t> out;
|
||||
try {
|
||||
auto b = with_ftp([&](ftp::Session &f) { return f.get(path); });
|
||||
img::Image im;
|
||||
if (ext == "dds") { if (img::from_bc7_dds(b, im)) out = img::to_png(img::resize(im, 960, 540)); }
|
||||
else out = std::move(b);
|
||||
} catch (...) {}
|
||||
std::lock_guard<std::mutex> l(g_mu);
|
||||
g_tile_img_cache[path] = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
static json layout_apply(const json &req) {
|
||||
std::lock_guard<std::mutex> l(g_layout_mu);
|
||||
auto orig = pull_db();
|
||||
std::string backup = make_backup(orig, "Before changes");
|
||||
std::vector<std::string> log = {"Backup saved: " + backup};
|
||||
layout::DB db(orig);
|
||||
auto info = layout::analyze(db, g_layout_user);
|
||||
layout::apply(db, info, req, log);
|
||||
auto out = db.serialize();
|
||||
with_ftp([&](ftp::Session &f) { f.put(layout::DB_PATH, out); return 0; });
|
||||
log.push_back("Uploaded. Restart the PS5 to see the new home screen.");
|
||||
return {{"ok", true}, {"log", log}, {"backup", backup}, {"layout", layout_state(out)}};
|
||||
}
|
||||
|
||||
static json layout_backups() {
|
||||
json out = json::array();
|
||||
std::vector<fs::path> dirs;
|
||||
for (auto &e : fs::directory_iterator(data_path("backups"))) if (e.is_directory()) dirs.push_back(e.path());
|
||||
std::sort(dirs.rbegin(), dirs.rend());
|
||||
for (auto &d : dirs) {
|
||||
try { std::ifstream f(d / "backup.json"); json m; f >> m; out.push_back(m); } catch (...) {}
|
||||
}
|
||||
return {{"ok", true}, {"backups", out}};
|
||||
}
|
||||
|
||||
static json layout_restore(const std::string &id) {
|
||||
std::lock_guard<std::mutex> l(g_layout_mu);
|
||||
if (id.empty() || id.find_first_of("/\\") != std::string::npos || id.find("..") != std::string::npos) throw ps5::Error("Unknown backup.");
|
||||
auto db = read_local(data_path("backups") / fs::u8path(id) / "app.db");
|
||||
if (db.empty()) throw ps5::Error("That backup is missing its database file.");
|
||||
{ layout::DB check(db); } // throws if it isn't a healthy database
|
||||
with_ftp([&](ftp::Session &f) { f.put(layout::DB_PATH, db); return 0; });
|
||||
return {{"ok", true}, {"layout", layout_state(db)}};
|
||||
}
|
||||
|
||||
static std::string preset_file(std::string name) {
|
||||
std::string o;
|
||||
for (char c : name) o += (isalnum((unsigned char)c) || c == ' ' || c == '-' || c == '_') ? c : '_';
|
||||
while (!o.empty() && o.back() == ' ') o.pop_back();
|
||||
if (o.empty()) throw ps5::Error("Give the preset a name.");
|
||||
return o;
|
||||
}
|
||||
static json layout_presets() {
|
||||
json names = json::array();
|
||||
for (auto &e : fs::directory_iterator(data_path("presets")))
|
||||
if (e.path().extension() == ".json") names.push_back(e.path().stem().u8string());
|
||||
return {{"ok", true}, {"presets", names}};
|
||||
}
|
||||
|
||||
// ─────────────────────────────── actions ──────────────────────────────
|
||||
// Homebrew registered by PS5 Upload (we know its folder) + installed games (edited in
|
||||
// /user/appmeta/<id>, where the home screen reads its art). System apps and nameless
|
||||
// leftovers are marked hidden so the list stays clean.
|
||||
// Homebrew registered by PS5 Upload (we know its folder) + installed games (in Sony's app
|
||||
// list or with a /user/appmeta/<id> folder). System apps and nameless leftovers are
|
||||
// marked hidden so the list stays clean.
|
||||
static json list_apps() {
|
||||
auto c = client();
|
||||
std::map<std::string, std::string> src, name;
|
||||
std::set<std::string> mounted;
|
||||
json reg = json::parse(c.call_str(ps5::APP_LIST_REGISTERED, "", ps5::APP_LIST_REGISTERED_ACK));
|
||||
for (auto &a : reg.value("apps", json::array())) {
|
||||
std::string id = a.value("title_id", "");
|
||||
src[id] = a.value("src", "");
|
||||
if (a.value("image_backed", false) || mounted_image(src[id])) mounted.insert(id);
|
||||
std::string n = a.value("title_name", "");
|
||||
if (n != id) name[id] = n;
|
||||
}
|
||||
bool have_db = false;
|
||||
std::set<std::string> in_db;
|
||||
try { // Sony's app list with real names (newer PS5 Upload payloads)
|
||||
json db = json::parse(c.call_str(ps5::APPDB_QUERY, "{}", ps5::APPDB_QUERY_ACK));
|
||||
for (auto &a : db.value("apps", json::array())) {
|
||||
std::string id = a.value("title_id", ""), n = a.value("name", "");
|
||||
if (id.empty()) continue;
|
||||
have_db = true;
|
||||
in_db.insert(id);
|
||||
if (!src.count(id)) src[id] = "";
|
||||
if (!n.empty() && !name.count(id)) name[id] = n;
|
||||
}
|
||||
@@ -142,8 +322,8 @@ static json list_apps() {
|
||||
|
||||
json out = json::array();
|
||||
for (auto &[id, s] : src) {
|
||||
std::string kind = !s.empty() ? "homebrew"
|
||||
: (metas.count(id) && id.rfind("NPXS", 0) != 0) ? "game" : "other";
|
||||
std::string kind = mounted.count(id) ? "game" : !s.empty() ? "homebrew"
|
||||
: ((metas.count(id) || in_db.count(id)) && id.rfind("NPXS", 0) != 0) ? "game" : "other";
|
||||
std::string n = name.count(id) ? name[id] : "";
|
||||
if (n.empty() && kind != "other" && !have_db) n = meta_title(c, id);
|
||||
bool hidden = kind == "other" || (kind == "game" && n.empty());
|
||||
@@ -233,7 +413,8 @@ static std::vector<Target> game_targets(ps5::Client &c, const std::string &id, c
|
||||
std::vector<Target> out;
|
||||
for (auto &e : list_dir(c, appmeta(id))) {
|
||||
std::string name = e.value("name", ""), ln = lower(name);
|
||||
if (e.value("kind", "") != "file") continue;
|
||||
std::string ek = e.value("kind", "");
|
||||
if (ek != "file") { if (ek == "link") seen.push_back(name + " (link)"); continue; }
|
||||
seen.push_back(name);
|
||||
bool png = ends_with(ln, ".png"), dds = ends_with(ln, ".dds");
|
||||
if (!png && !dds) continue;
|
||||
@@ -300,7 +481,147 @@ static void sync_home(ps5::Client &c, const std::string &id, const std::string &
|
||||
}
|
||||
}
|
||||
|
||||
static json do_apply(const std::string &id, const std::string &src, const std::string &kind_of_app) {
|
||||
// ── Installed games: the art the home screen actually draws ──
|
||||
// The home screen draws each tile from the paths in its app.db concept row (icon0Info,
|
||||
// pic0Info). For many installed games those point into the game's own read-only package,
|
||||
// not /user/appmeta, so replacing files in the game's art folder can't change what's shown.
|
||||
// For those, PSGFX writes the new image to /user/appmeta/<id>/psgfx_<name>_<time> and points
|
||||
// the database at it. The original paths are kept in psgfx_original.json in that folder for
|
||||
// Restore, and the database is backed up first, as Home layout does.
|
||||
static const char *const ORIGINALS = "psgfx_original.json";
|
||||
static std::string art_column(const std::string &kind) { return kind == "icon" ? "icon0Info" : "pic0Info"; }
|
||||
|
||||
// Size and format from an image's first bytes; false if it isn't a PNG or DDS we can match.
|
||||
static bool probe_art(const std::vector<uint8_t> &h, Target &t) {
|
||||
if (h.size() >= 24 && memcmp(h.data(), "\x89PNG", 4) == 0) {
|
||||
t.dds = false;
|
||||
t.w = (int)(h[16] << 24 | h[17] << 16 | h[18] << 8 | h[19]);
|
||||
t.h = (int)(h[20] << 24 | h[21] << 16 | h[22] << 8 | h[23]);
|
||||
} else if (h.size() >= 20 && memcmp(h.data(), "DDS ", 4) == 0) {
|
||||
t.dds = true; t.h = (int)ps5::get32(&h[12]); t.w = (int)ps5::get32(&h[16]);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return t.w >= 16 && t.h >= 16 && t.w <= 8192 && t.h <= 8192 && (!t.dds || (t.w % 4 == 0 && t.h % 4 == 0));
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> read_head(ps5::Client &c, const std::string &path) {
|
||||
try { return c.call(ps5::FS_READ, json{{"path", path}, {"offset", 0}, {"limit", 160}}.dump(), ps5::FS_READ_ACK); }
|
||||
catch (...) {}
|
||||
try { return with_ftp([&](ftp::Session &f) { return f.get(path); }); } // outside /user and /data
|
||||
catch (...) { return {}; }
|
||||
}
|
||||
|
||||
// Sets an art column for this title in every table that has it (all user profiles).
|
||||
static void set_art_column(layout::DB &db, const layout::Info &info, const std::string &id, const std::string &concept,
|
||||
const std::string &col, const std::string &value) {
|
||||
for (auto &[table, cols] : info.cols) {
|
||||
if (!cols.count(col)) continue;
|
||||
if (cols.count("localConceptId") && !concept.empty())
|
||||
db.exec("UPDATE " + layout::q(table) + " SET " + layout::q(col) + "=? WHERE localConceptId=?", {value, concept});
|
||||
else if (cols.count("titleId"))
|
||||
db.exec("UPDATE " + layout::q(table) + " SET " + layout::q(col) + "=? WHERE titleId=?", {value, id});
|
||||
}
|
||||
}
|
||||
|
||||
static std::string concept_of(layout::DB &db, const layout::Info &info, const std::string &id) {
|
||||
return db.scalar_str("SELECT localConceptId FROM " + layout::q(info.icon_t) + " WHERE titleId=?", {id});
|
||||
}
|
||||
|
||||
static void upload_db(const std::vector<uint8_t> &orig, layout::DB &db, const std::string &label, json &log) {
|
||||
std::string backup = make_backup(orig, label);
|
||||
auto out = db.serialize();
|
||||
{ layout::DB check(out); } // throws if the edit broke it; nothing is uploaded then
|
||||
with_ftp([&](ftp::Session &f) { f.put(layout::DB_PATH, out); return 0; });
|
||||
{ std::lock_guard<std::mutex> l(g_mu); g_tile_img_cache.clear(); }
|
||||
log.push_back("Home-screen database updated (backup " + backup + ")");
|
||||
}
|
||||
|
||||
static json read_originals(ps5::Client &c, const std::string &id) {
|
||||
if (!exists(c, appmeta(id), ORIGINALS)) return json::object();
|
||||
try { auto b = c.read_file(appmeta(id) + "/" + ORIGINALS, 1u << 20); return json::parse(b.begin(), b.end()); }
|
||||
catch (...) { return json::object(); }
|
||||
}
|
||||
|
||||
// Returns the staged slots it handled; the rest fall back to replacing the art-folder files.
|
||||
static std::set<std::string> game_apply_db(ps5::Client &c, const std::string &id, const std::map<std::string, Staged> &staged, json &log) {
|
||||
std::set<std::string> done;
|
||||
std::lock_guard<std::mutex> l(g_layout_mu);
|
||||
std::vector<uint8_t> orig;
|
||||
try { orig = pull_db(); }
|
||||
catch (const std::exception &e) {
|
||||
log.push_back(std::string("Couldn't read the home-screen database (") + e.what() + "), so only the game's art folder was checked.");
|
||||
return done;
|
||||
}
|
||||
layout::DB db(orig);
|
||||
auto info = layout::analyze(db, g_layout_user);
|
||||
if (info.concept_t.empty()) return done;
|
||||
std::string concept = concept_of(db, info, id);
|
||||
if (concept.empty()) return done;
|
||||
|
||||
const std::string dir = appmeta(id);
|
||||
json originals = read_originals(c, id);
|
||||
std::vector<std::string> replaced; // our older files, deleted once the database points away from them
|
||||
for (auto &[kind, s] : staged) {
|
||||
const std::string col = art_column(kind);
|
||||
std::string value = db.scalar_str("SELECT " + layout::q(col) + " FROM " + layout::q(info.concept_t) + " WHERE localConceptId=?", {concept});
|
||||
std::string path = layout::strip_query(value);
|
||||
if (path.empty() || path[0] != '/') continue;
|
||||
std::string base = path.substr(path.find_last_of('/') + 1);
|
||||
bool in_dir = path == dir + "/" + base, ours = in_dir && base.rfind("psgfx_", 0) == 0;
|
||||
if (in_dir && !ours) { // a real file in the art folder: replacing it in place works
|
||||
bool regular = false;
|
||||
for (auto &e : list_dir(c, dir)) if (e.value("name", "") == base && e.value("kind", "") == "file") regular = true;
|
||||
if (regular) continue;
|
||||
}
|
||||
Target t;
|
||||
if (!probe_art(read_head(c, path), t)) {
|
||||
t.dds = ends_with(lower(base), ".dds");
|
||||
t.w = kind == "icon" ? 512 : t.dds ? 3840 : 1920;
|
||||
t.h = kind == "icon" ? 512 : t.dds ? 2160 : 1080;
|
||||
}
|
||||
std::string stem = kind == "icon" ? "icon0" : "pic0";
|
||||
t.name = "psgfx_" + stem + "_" + now_stamp("%Y%m%d%H%M%S") + (t.dds ? ".dds" : ".png");
|
||||
c.upload(dir + "/" + t.name, encode_for(kind, s.src, t));
|
||||
log.push_back("Uploaded " + t.name + " (" + std::to_string(t.w) + "x" + std::to_string(t.h) + ")");
|
||||
if (!originals.contains(col)) originals[col] = value;
|
||||
if (ours) replaced.push_back(dir + "/" + base);
|
||||
size_t qp = value.find('?');
|
||||
set_art_column(db, info, id, concept, col, dir + "/" + t.name + (qp == std::string::npos ? "" : value.substr(qp)));
|
||||
done.insert(kind);
|
||||
}
|
||||
if (done.empty()) return done;
|
||||
std::string o = originals.dump(2);
|
||||
c.upload(dir + "/" + ORIGINALS, std::vector<uint8_t>(o.begin(), o.end()));
|
||||
upload_db(orig, db, "Before new art for " + id, log);
|
||||
for (auto &p : replaced) try { c.call(ps5::FS_DELETE, json{{"path", p}}.dump(), ps5::FS_DELETE_ACK); } catch (...) {}
|
||||
return done;
|
||||
}
|
||||
|
||||
// Points the database back at the game's own art. False if PSGFX never repointed it.
|
||||
static bool game_restore_db(ps5::Client &c, const std::string &id, json &log) {
|
||||
json originals = read_originals(c, id);
|
||||
if (originals.empty()) return false;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(g_layout_mu);
|
||||
auto orig = pull_db();
|
||||
layout::DB db(orig);
|
||||
auto info = layout::analyze(db, g_layout_user);
|
||||
std::string concept = concept_of(db, info, id);
|
||||
for (auto &[col, value] : originals.items())
|
||||
if (value.is_string()) set_art_column(db, info, id, concept, col, value.get<std::string>());
|
||||
upload_db(orig, db, "Before restoring art for " + id, log);
|
||||
}
|
||||
for (auto &e : list_dir(c, appmeta(id))) {
|
||||
std::string n = e.value("name", "");
|
||||
if (n.rfind("psgfx_", 0) == 0) try { c.call(ps5::FS_DELETE, json{{"path", appmeta(id) + "/" + n}}.dump(), ps5::FS_DELETE_ACK); } catch (...) {}
|
||||
}
|
||||
log.push_back("Pointed the home screen back at the game's own art");
|
||||
return true;
|
||||
}
|
||||
|
||||
static json do_apply(const std::string &id, const std::string &src, std::string kind_of_app) {
|
||||
if (mounted_image(src)) kind_of_app = "game";
|
||||
json log = json::array();
|
||||
std::map<std::string, Staged> staged;
|
||||
{ std::lock_guard<std::mutex> l(g_mu); staged = g_staged; }
|
||||
@@ -308,9 +629,11 @@ static json do_apply(const std::string &id, const std::string &src, const std::s
|
||||
auto c = client();
|
||||
|
||||
if (kind_of_app == "game") {
|
||||
int written = 0;
|
||||
auto via_db = game_apply_db(c, id, staged, log);
|
||||
int written = (int)via_db.size();
|
||||
std::vector<std::string> seen;
|
||||
for (auto &[kind, s] : staged) {
|
||||
if (via_db.count(kind)) continue;
|
||||
auto targets = game_targets(c, id, kind, log, seen);
|
||||
if (targets.empty()) log.push_back(std::string("This game has no ") + (kind == "icon" ? "icon" : "background") + " file this tool can replace.");
|
||||
for (auto &t : targets) {
|
||||
@@ -324,7 +647,9 @@ static json do_apply(const std::string &id, const std::string &src, const std::s
|
||||
if (!written) {
|
||||
std::string list; std::sort(seen.begin(), seen.end()); seen.erase(std::unique(seen.begin(), seen.end()), seen.end());
|
||||
for (auto &n : seen) list += (list.empty() ? "" : ", ") + n;
|
||||
throw ps5::Error("Nothing was changed. Files in this game's art folder: " + (list.empty() ? std::string("(none)") : list));
|
||||
std::string notes;
|
||||
for (auto &n : log) notes += " " + n.get<std::string>();
|
||||
throw ps5::Error("Nothing was changed. Files in this game's art folder: " + (list.empty() ? std::string("(none)") : list) + "." + notes);
|
||||
}
|
||||
log.push_back("Done. Restart the PS5 to see the new art.");
|
||||
} else {
|
||||
@@ -346,18 +671,20 @@ static json do_apply(const std::string &id, const std::string &src, const std::s
|
||||
return {{"ok", true}, {"log", log}};
|
||||
}
|
||||
|
||||
static json do_restore(const std::string &id, const std::string &src, const std::string &kind_of_app) {
|
||||
static json do_restore(const std::string &id, const std::string &src, std::string kind_of_app) {
|
||||
if (mounted_image(src)) kind_of_app = "game";
|
||||
json log = json::array();
|
||||
auto c = client();
|
||||
const std::string dir = kind_of_app == "game" ? appmeta(id) : src + "/sce_sys";
|
||||
if (kind_of_app != "game" && src.empty()) throw ps5::Error("This app's folder is unknown.");
|
||||
bool repointed = kind_of_app == "game" && game_restore_db(c, id, log);
|
||||
std::vector<std::string> names;
|
||||
for (auto &e : list_dir(c, dir)) {
|
||||
std::string n = e.value("name", "");
|
||||
if (ends_with(n, ".bak")) names.push_back(n.substr(0, n.size() - 4));
|
||||
else if (ends_with(n, ".added")) names.push_back(n.substr(0, n.size() - 6));
|
||||
}
|
||||
if (names.empty()) throw ps5::Error("Nothing to restore - this still has its original art.");
|
||||
if (names.empty() && !repointed) throw ps5::Error("Nothing to restore - this still has its original art.");
|
||||
for (auto &name : names) {
|
||||
const std::string path = dir + "/" + name;
|
||||
if (exists(c, dir, name + ".bak")) {
|
||||
@@ -379,6 +706,54 @@ static json do_restore(const std::string &id, const std::string &src, const std:
|
||||
return {{"ok", true}, {"log", log}};
|
||||
}
|
||||
|
||||
// Homebrew keeps its name in sce_sys/param.json, and re-registering (which Apply does) copies
|
||||
// it to the home screen. Update it there too, so changing art later doesn't undo a rename.
|
||||
// The first original is kept as param.json.orig (not .bak, so Restore original art leaves it).
|
||||
static void rename_homebrew_param(ps5::Client &c, const std::string &id, const std::string &src, const std::string &name, json &log) {
|
||||
const std::string dir = src + "/sce_sys";
|
||||
try {
|
||||
auto b = c.read_file(dir + "/param.json", 1u << 20);
|
||||
auto j = nlohmann::ordered_json::parse(b.begin(), b.end());
|
||||
int n = 0;
|
||||
if (j.contains("localizedParameters") && j["localizedParameters"].is_object())
|
||||
for (auto &[lang, v] : j["localizedParameters"].items())
|
||||
if (v.is_object() && v.contains("titleName")) { v["titleName"] = name; n++; }
|
||||
if (!n) { log.push_back("This app's param.json has no name to update; only the home screen was renamed."); return; }
|
||||
if (!exists(c, dir, "param.json.orig")) copy_file(c, dir + "/param.json", dir + "/param.json.orig", false);
|
||||
std::string out = j.dump(2);
|
||||
c.upload(dir + "/param.json", std::vector<uint8_t>(out.begin(), out.end()));
|
||||
for (auto &d : {appmeta(id) + "/param.json", "/user/app/" + id + "/sce_sys/param.json"})
|
||||
try { if (exists(c, d.substr(0, d.find_last_of('/')), "param.json")) copy_file(c, dir + "/param.json", d, true); } catch (...) {}
|
||||
log.push_back("Updated the name in the app's param.json");
|
||||
} catch (const std::exception &e) {
|
||||
log.push_back(std::string("Couldn't update the app's param.json (") + e.what() + "). Changing its art later may bring back the old name.");
|
||||
}
|
||||
}
|
||||
|
||||
// Renames a title on the home screen with the same database edit Home layout uses.
|
||||
static json do_rename(const std::string &id, const std::string &src, const std::string &kind_of_app, std::string name) {
|
||||
while (!name.empty() && isspace((unsigned char)name.back())) name.pop_back();
|
||||
while (!name.empty() && isspace((unsigned char)name.front())) name.erase(0, 1);
|
||||
if (name.empty()) throw ps5::Error("Enter a name.");
|
||||
json log = json::array();
|
||||
auto c = client();
|
||||
{
|
||||
std::lock_guard<std::mutex> l(g_layout_mu);
|
||||
auto orig = pull_db();
|
||||
layout::DB db(orig);
|
||||
auto info = layout::analyze(db, g_layout_user);
|
||||
const layout::Tile *t = info.find(id);
|
||||
if (!t) throw ps5::Error("This title isn't in the home screen's list, so it can't be renamed there.");
|
||||
std::vector<std::string> lines;
|
||||
layout::apply(db, info, json{{"edits", {{id, {{"name", name}}}}}}, lines);
|
||||
for (auto &x : lines) log.push_back(x);
|
||||
upload_db(orig, db, "Before renaming " + t->name, log);
|
||||
}
|
||||
if (kind_of_app == "homebrew" && !src.empty() && !mounted_image(src)) rename_homebrew_param(c, id, src, name, log);
|
||||
log.push_back("Done. Restart the PS5 to see the new name.");
|
||||
return {{"ok", true}, {"log", log}, {"name", name}};
|
||||
}
|
||||
|
||||
// ─────────────────────────────── tiny HTTP server ─────────────────────
|
||||
struct Req { std::string method, path, query; std::map<std::string, std::string> q; std::vector<uint8_t> body; };
|
||||
|
||||
@@ -492,6 +867,39 @@ static void handle(sock_t s, Req &r) {
|
||||
auto id = j.value("title_id", ""), src = j.value("src", ""), kind = j.value("kind", "homebrew");
|
||||
respond_json(s, r.path == "/api/apply" ? do_apply(id, src, kind) : do_restore(id, src, kind)); return;
|
||||
}
|
||||
if (r.path == "/api/rename" && r.method == "POST") {
|
||||
json j = json::parse(std::string(r.body.begin(), r.body.end()));
|
||||
respond_json(s, do_rename(j.value("title_id", ""), j.value("src", ""), j.value("kind", "homebrew"), j.value("name", ""))); return;
|
||||
}
|
||||
if (r.path == "/api/layout/load" && r.method == "POST") {
|
||||
json j = json::parse(std::string(r.body.begin(), r.body.end()));
|
||||
respond_json(s, layout_load(j.value("user", ""))); return;
|
||||
}
|
||||
if (r.path == "/api/layout/img") {
|
||||
auto b = tile_image(r.q["p"]);
|
||||
if (b.empty()) { respond(s, 404, "text/plain", "none", 4); return; }
|
||||
respond(s, 200, "image/png", b.data(), b.size()); return;
|
||||
}
|
||||
if (r.path == "/api/layout/apply" && r.method == "POST") { respond_json(s, layout_apply(json::parse(std::string(r.body.begin(), r.body.end())))); return; }
|
||||
if (r.path == "/api/layout/backups") { respond_json(s, layout_backups()); return; }
|
||||
if (r.path == "/api/layout/restore" && r.method == "POST") {
|
||||
json j = json::parse(std::string(r.body.begin(), r.body.end()));
|
||||
respond_json(s, layout_restore(j.value("id", ""))); return;
|
||||
}
|
||||
if (r.path == "/api/layout/presets") { respond_json(s, layout_presets()); return; }
|
||||
if (r.path == "/api/layout/preset/save" && r.method == "POST") {
|
||||
json j = json::parse(std::string(r.body.begin(), r.body.end()));
|
||||
std::string name = preset_file(j.value("name", ""));
|
||||
std::ofstream(data_path("presets") / fs::u8path(name + ".json")) << j.value("data", json::object()).dump(2);
|
||||
respond_json(s, {{"ok", true}, {"name", name}}); return;
|
||||
}
|
||||
if (r.path == "/api/layout/preset/load" && r.method == "POST") {
|
||||
json j = json::parse(std::string(r.body.begin(), r.body.end()));
|
||||
std::ifstream f(data_path("presets") / fs::u8path(preset_file(j.value("name", "")) + ".json"));
|
||||
if (!f) throw ps5::Error("Preset not found.");
|
||||
json d; f >> d;
|
||||
respond_json(s, {{"ok", true}, {"data", d}}); return;
|
||||
}
|
||||
respond(s, 404, "text/plain", "not found", 9);
|
||||
} catch (std::exception &e) {
|
||||
respond_json(s, {{"ok", false}, {"error", e.what()}}, 200);
|
||||
@@ -504,6 +912,7 @@ int main(int argc, char **argv) {
|
||||
char exe[MAX_PATH]; GetModuleFileNameA(nullptr, exe, MAX_PATH);
|
||||
std::string ep = exe; std::string exedir = ep.substr(0, ep.find_last_of("\\/") + 1);
|
||||
g_config_path = exedir + "psgfx.json"; g_legacy_config_path = exedir + "ps5-art-studio.json";
|
||||
g_data_dir = exedir + "PSGFX Layout";
|
||||
// Close any other running copy (e.g. an older version still open in its console window),
|
||||
// so the browser can only ever talk to this one.
|
||||
{
|
||||
|
||||
+5
-5
@@ -2,8 +2,8 @@
|
||||
IDI_ICON1 ICON "../assets/psgfx.ico"
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION 1,0,0,0
|
||||
PRODUCTVERSION 1,0,0,0
|
||||
FILEVERSION 1,1,0,0
|
||||
PRODUCTVERSION 1,1,0,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -11,10 +11,10 @@ BEGIN
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "FileDescription", "PSGFX - custom icons and backgrounds for your PS5"
|
||||
VALUE "FileVersion", "1.0.0"
|
||||
VALUE "FileDescription", "PSGFX - custom art and home-screen layout for your PS5"
|
||||
VALUE "FileVersion", "1.1.0"
|
||||
VALUE "ProductName", "PSGFX"
|
||||
VALUE "ProductVersion", "1.0.0"
|
||||
VALUE "ProductVersion", "1.1.0"
|
||||
VALUE "OriginalFilename", "PSGFX.exe"
|
||||
END
|
||||
END
|
||||
|
||||
+391
-7
@@ -76,6 +76,8 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
.stage-hint{position:absolute;right:2%;bottom:3%;font-size:12px;color:rgba(255,255,255,.75)}
|
||||
.staged-badge{position:absolute;top:3%;right:2%;padding:4px 10px;border-radius:999px;background:rgba(61,123,255,.85);font-size:12px;font-weight:600;display:none}
|
||||
|
||||
.namerow{display:grid;gap:6px;max-width:480px}
|
||||
.namerow label{font-size:12.5px;color:var(--muted)}
|
||||
.slots{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
|
||||
.slot{background:var(--glass);border:1px solid var(--line);border-radius:var(--r-md);padding:14px;display:flex;gap:12px;align-items:center;min-height:92px}
|
||||
.slot .thumb{width:64px;height:64px;border-radius:10px;background:var(--bg2) center/cover no-repeat;flex:none;border:1px solid var(--line)}
|
||||
@@ -94,6 +96,79 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
.log.ok{border-color:rgba(61,220,151,.4)} .log.bad{border-color:rgba(255,107,107,.5);color:#ffd0d0}
|
||||
.welcome{max-width:560px;color:var(--muted)}
|
||||
.welcome h1{color:var(--text);font-size:26px;letter-spacing:-.02em;margin:0 0 8px}
|
||||
/* ── header views ── */
|
||||
.views{display:flex;gap:4px;margin-left:8px;padding:3px;border-radius:999px;background:var(--glass);border:1px solid var(--line)}
|
||||
.views button{border:0;background:none;padding:6px 14px;border-radius:999px;color:var(--muted);cursor:pointer;font-weight:600;font-size:13px}
|
||||
.views button[aria-pressed="true"]{background:var(--glass-hi);color:var(--text)}
|
||||
|
||||
/* ── home layout ── */
|
||||
.lv{display:grid;grid-template-columns:1fr 320px;min-height:calc(100vh - 66px)}
|
||||
.lv-main{padding:24px;display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.lv-empty{max-width:560px;color:var(--muted)}
|
||||
.lv-empty h1{color:var(--text);font-size:26px;letter-spacing:-.02em;margin:0 0 8px}
|
||||
.lstage{position:relative;aspect-ratio:16/9;width:min(100%,calc((100vh - 470px)*16/9));min-width:min(100%,520px);margin:0 auto;border-radius:var(--r-lg);overflow:hidden;background:linear-gradient(135deg,#101b33,#060913);border:1px solid var(--line);box-shadow:0 30px 80px rgba(0,0,0,.5)}
|
||||
.lstage .bgimg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
.ltabs{position:absolute;left:4.5%;top:4%;display:flex;gap:clamp(14px,2.4vw,30px);font-size:clamp(11px,1.4vw,17px);color:rgba(255,255,255,.65)}
|
||||
.ltabs span{padding-bottom:3px;border-bottom:2px solid transparent;cursor:pointer}
|
||||
.ltabs span.on{color:#fff;border-color:#fff}
|
||||
.lrow{position:absolute;left:4.5%;right:0;top:14%;display:flex;gap:.9%;align-items:flex-start;overflow:hidden}
|
||||
.ltile{flex:none;width:6.2%;aspect-ratio:1;border-radius:8%;background:rgba(255,255,255,.12) center/cover;cursor:pointer;position:relative}
|
||||
.ltile.sel{width:12.5%;border-radius:7%;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 8px 30px rgba(0,0,0,.5)}
|
||||
.ltile i,.lcard .im i{position:absolute;inset:0;display:grid;place-items:center;font-style:normal;font-weight:600;font-size:clamp(8px,1vw,14px);color:rgba(255,255,255,.8)}
|
||||
.ltitle{position:absolute;left:4.5%;top:42%;font-size:clamp(14px,2.2vw,30px);font-weight:600;text-shadow:0 2px 12px rgba(0,0,0,.6);max-width:60%}
|
||||
.lsub{position:absolute;left:4.5%;top:calc(42% + clamp(20px,3vw,40px));font-size:clamp(10px,1vw,13px);color:rgba(255,255,255,.7)}
|
||||
.lbar{display:flex;align-items:center;gap:4px;flex-wrap:wrap;width:min(100%,calc((100vh - 470px)*16/9));min-width:min(100%,520px);margin:0 auto}
|
||||
.ltab{border:0;background:none;padding:7px 12px;border-radius:var(--r-sm);color:var(--muted);cursor:pointer;font-weight:600}
|
||||
.ltab[aria-pressed="true"]{background:var(--glass-hi);color:var(--text)}
|
||||
.ltab small{font-weight:400;color:var(--muted);margin-left:4px}
|
||||
.lsorts{margin-left:auto;display:flex;align-items:center;gap:2px}
|
||||
.lsorts .btn{padding:5px 10px;font-size:12px}
|
||||
.lsorts label{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted);margin-left:8px;cursor:pointer}
|
||||
.lstrip{display:flex;gap:16px;overflow-x:auto;padding:4px 2px 16px;width:min(100%,calc((100vh - 470px)*16/9));min-width:min(100%,520px);margin:0 auto}
|
||||
.lgrp{display:flex;flex-direction:column;gap:8px;flex:none}
|
||||
.lgrp + .lgrp{padding-left:16px;border-left:1px solid var(--line)}
|
||||
.lgrp>span{font-size:12px;color:var(--muted)}
|
||||
.lcards{display:flex;gap:10px}
|
||||
.lcard{flex:none;width:112px;padding:7px;border-radius:var(--r-md);border:1px solid transparent;background:var(--glass);cursor:pointer;position:relative;text-align:left}
|
||||
.lcard:hover{background:var(--glass-hi)}
|
||||
.lcard[aria-current="true"]{border-color:var(--blue-hi);background:var(--glass-hi)}
|
||||
.lcard.drag{opacity:.35}
|
||||
.lcard.over{box-shadow:-5px 0 0 -2px var(--blue-hi)}
|
||||
.lcard .im{aspect-ratio:1;border-radius:9px;background:var(--bg2) center/cover;position:relative}
|
||||
.lcard .nm{margin-top:6px;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lcard .bd{position:absolute;top:12px;right:12px;font-size:10.5px;font-weight:600;padding:2px 6px;border-radius:999px;background:var(--warn);color:#2a1800}
|
||||
.lempty{color:var(--muted);padding:20px;border:1px dashed var(--line);border-radius:var(--r-md);flex:1;text-align:center}
|
||||
.lwarn{width:min(100%,calc((100vh - 470px)*16/9));margin:0 auto;padding:10px 14px;border-radius:var(--r-md);background:rgba(255,181,71,.08);border:1px solid rgba(255,181,71,.3);color:#ffd9a0;font-size:13px}
|
||||
.lside{border-left:1px solid var(--line);padding:20px;display:flex;flex-direction:column;gap:18px}
|
||||
.lside h2{font-size:18px;margin:0;letter-spacing:-.01em}
|
||||
.lside .id{font-size:12px;color:var(--muted)}
|
||||
.lsec{display:grid;gap:8px}
|
||||
.lsec>label,.lsec>.lbl{font-size:12.5px;color:var(--muted)}
|
||||
.lseg{display:grid;grid-template-columns:repeat(3,1fr);padding:3px;border-radius:var(--r-sm);background:var(--glass);border:1px solid var(--line)}
|
||||
.lseg button{border:0;background:none;padding:7px 0;border-radius:6px;color:var(--muted);cursor:pointer}
|
||||
.lseg button[aria-pressed="true"]{background:var(--glass-hi);color:var(--text)}
|
||||
.ltxt{width:100%;padding:9px 11px;border-radius:var(--r-sm);border:1px solid var(--line);background:var(--glass)}
|
||||
.lnote{font-size:12px;color:var(--muted)}
|
||||
.linker{border:0;background:none;color:var(--blue-hi);padding:0;cursor:pointer;justify-self:start;font-size:13px}
|
||||
.lfoot{position:sticky;bottom:0;display:flex;align-items:center;gap:10px;padding:12px 20px;border-top:1px solid var(--line);background:rgba(6,9,19,.92);backdrop-filter:blur(8px);grid-column:1/-1}
|
||||
.lfoot .pend{color:var(--muted);display:flex;align-items:center;gap:8px}
|
||||
.lfoot .pend b{width:8px;height:8px;border-radius:50%;background:#4a5570}
|
||||
.lfoot .pend.has{color:var(--text)} .lfoot .pend.has b{background:var(--warn)}
|
||||
.lfoot .sp{flex:1}
|
||||
.scrim{position:fixed;inset:0;display:none;place-items:center;background:rgba(3,5,10,.7);z-index:20}
|
||||
.scrim.open{display:grid}
|
||||
.dlg{width:min(560px,92vw);max-height:84vh;overflow:auto;background:#0c1426;border:1px solid var(--line);border-radius:var(--r-lg);padding:22px}
|
||||
.dlg h3{margin:0 0 6px;font-size:18px}
|
||||
.dlg p{margin:0 0 12px;color:var(--muted)}
|
||||
.dlg .li{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:var(--r-sm);background:var(--glass);border:1px solid var(--line);margin-bottom:6px}
|
||||
.dlg .li div{flex:1;min-width:0}
|
||||
.dlg .li small{display:block;color:var(--muted)}
|
||||
.dlg .acts{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||
.dlg pre{white-space:pre-wrap;font-size:12px;background:var(--glass);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px;color:var(--muted);max-height:240px;overflow:auto}
|
||||
.dlg .chk{display:flex;align-items:center;gap:8px;margin:14px 0 8px;cursor:pointer}
|
||||
.busyv{position:fixed;inset:0;display:none;place-items:center;background:rgba(3,5,10,.55);z-index:30;color:var(--muted)}
|
||||
.busyv.open{display:grid}
|
||||
@media (max-width:900px){.lv{grid-template-columns:1fr}.lside{border-left:0;border-top:1px solid var(--line)}}
|
||||
.hidden{display:none!important}
|
||||
@media (max-width:900px){.layout{grid-template-columns:1fr}.rail{max-height:none;position:static;border-bottom:1px solid var(--line)}main{border-left:0}.slots{grid-template-columns:1fr}}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
@@ -101,7 +176,8 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="brand"><img src="/wordmark.png" alt="PSGFX"><span>1.0</span></div>
|
||||
<div class="brand"><img src="/wordmark.png" alt="PSGFX"><span>1.1</span></div>
|
||||
<nav class="views" aria-label="View"><button data-v="art" aria-pressed="true">Art</button><button data-v="layout" aria-pressed="false">Home layout</button></nav>
|
||||
<form class="connect" id="cf">
|
||||
<span class="dot" id="dot" aria-hidden="true"></span>
|
||||
<label for="ip">PS5 IP</label>
|
||||
@@ -137,6 +213,8 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
<div class="drop d-icon" data-kind="icon">Icon</div>
|
||||
</div>
|
||||
|
||||
<div class="namerow" style="margin-top:20px"><label for="aname">Name on the home screen</label><input class="ltxt" id="aname" maxlength="80" autocomplete="off"></div>
|
||||
|
||||
<div class="slots" style="margin-top:20px">
|
||||
<div class="slot" data-kind="icon"><div class="thumb"></div><div class="meta"><div class="t">Icon</div><div class="s">Square, 512 × 512 or larger</div><div class="row"><button class="btn pick">Choose image</button><button class="btn clear hidden">Remove</button></div></div></div>
|
||||
<div class="slot bgslot" data-kind="background"><div class="thumb"></div><div class="meta"><div class="t">Background</div><div class="s">16:9, ideally 3840 × 2160</div><div class="row"><button class="btn pick">Choose image</button><button class="btn clear hidden">Remove</button></div></div></div>
|
||||
@@ -151,6 +229,49 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<section class="lv hidden" id="lv" aria-label="Home layout">
|
||||
<div class="lv-main">
|
||||
<div class="lv-empty" id="lvEmpty">
|
||||
<h1>Arrange your home screen.</h1>
|
||||
<p>Hide tiles like the PlayStation Store, move apps between Games and Media, rename anything, and put your games in any order. Connect to your PS5 above to start. PSGFX backs up the home screen before every change.</p>
|
||||
</div>
|
||||
<div id="lvBody" class="hidden" style="display:contents">
|
||||
<div id="lvWarn"></div>
|
||||
<div class="lstage" id="lstage">
|
||||
<img class="bgimg hidden" id="lbg" alt="">
|
||||
<div class="shade" style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.45) 0%,transparent 30%,transparent 55%,rgba(0,0,0,.7) 100%)"></div>
|
||||
<div class="ltabs"><span data-w="games">Games</span><span data-w="media">Media</span></div>
|
||||
<div class="lrow" id="lrow"></div>
|
||||
<div class="ltitle" id="ltitle"></div>
|
||||
<div class="lsub" id="lsub"></div>
|
||||
</div>
|
||||
<div class="lbar">
|
||||
<button class="ltab" data-w="games">Games<small id="lcG"></small></button>
|
||||
<button class="ltab" data-w="media">Media<small id="lcM"></small></button>
|
||||
<button class="ltab" data-w="hidden">Hidden<small id="lcH"></small></button>
|
||||
<div class="lsorts" id="lsorts">
|
||||
<button class="btn" data-sort="az" title="Sort A to Z">A–Z</button>
|
||||
<button class="btn" data-sort="za" title="Sort Z to A">Z–A</button>
|
||||
<button class="btn" data-sort="recent" title="Back to recently played order">Recent</button>
|
||||
<label title="Keeps this order even after you play other games"><input type="checkbox" id="llock"> Lock order</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lstrip" id="lstrip"></div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="lside" id="lside"><p class="lnote">Select a tile to change it.</p></aside>
|
||||
<footer class="lfoot">
|
||||
<div class="pend" id="lpend"><b></b><span>No changes</span></div>
|
||||
<span class="sp"></span>
|
||||
<button class="btn" id="lPresets">Presets</button>
|
||||
<button class="btn" id="lBackups">Backups</button>
|
||||
<button class="btn" id="lReload">Reload</button>
|
||||
<button class="btn" id="lDiscard" disabled>Discard</button>
|
||||
<button class="btn primary" id="lApply" disabled>Apply to PS5</button>
|
||||
</footer>
|
||||
</section>
|
||||
<div class="scrim" id="dlg"><div class="dlg" id="dlgBody"></div></div>
|
||||
<div class="busyv" id="busyv"><span id="busyT">Working…</span></div>
|
||||
<input type="file" id="file" accept="image/png,image/jpeg" hidden>
|
||||
|
||||
<script>
|
||||
@@ -174,6 +295,7 @@ $('#cf').addEventListener('submit', async e => {
|
||||
try {
|
||||
const j = await api('/api/connect', {method:'POST', body: JSON.stringify({ip})});
|
||||
st.apps = j.apps; setDot('ok'); renderApps();
|
||||
L.loaded = false; if (!$('#lv').classList.contains('hidden')) lload();
|
||||
} catch (err) {
|
||||
setDot(''); $('#apps').innerHTML = ''; const p=document.createElement('p'); p.className='empty'; p.textContent = err.message; $('#apps').appendChild(p);
|
||||
} finally { $('#cbtn').disabled = false; }
|
||||
@@ -225,6 +347,7 @@ async function select(a, btn){
|
||||
st.staged = {};
|
||||
$('#welcome').classList.add('hidden'); $('#editor').classList.remove('hidden'); $('#log').style.display='none';
|
||||
$('#tname').textContent = a.title_name || a.title_id;
|
||||
$('#aname').value = a.title_name || a.title_id;
|
||||
const game = a.kind === 'game';
|
||||
$('#anote').textContent = game ? 'Close the game first. The PS5 may put back its original art when the game updates.' : 'Close the app on your PS5 before applying.';
|
||||
refreshPreview(true);
|
||||
@@ -246,9 +369,15 @@ function refreshPreview(loadCurrent){
|
||||
else if (s) { sEl.textContent = 'Ready to apply'; sEl.classList.remove('warn'); }
|
||||
else { sEl.textContent = {icon:'Square, 512 × 512 or larger',background:'16:9, ideally 3840 × 2160'}[k]; sEl.classList.remove('warn'); }
|
||||
});
|
||||
const any = KINDS.some(k => st.staged[k]);
|
||||
updateApply();
|
||||
}
|
||||
const newName = () => { const v = $('#aname').value.trim(); return v && v !== (st.sel.title_name || st.sel.title_id) ? v : null; };
|
||||
function updateApply(){
|
||||
const any = KINDS.some(k => st.staged[k]) || !!newName();
|
||||
$('#apply').disabled = !any; $('#badge').style.display = any ? 'block' : 'none';
|
||||
}
|
||||
$('#aname').addEventListener('input', () => { $('#tname').textContent = $('#aname').value.trim() || st.sel.title_name || st.sel.title_id; updateApply(); });
|
||||
$('#aname').addEventListener('keydown', e => { if (e.key === 'Enter' && !$('#apply').disabled) $('#apply').click(); });
|
||||
|
||||
async function stage(kind, file){
|
||||
const sl = document.querySelector(`.slot[data-kind="${kind}"] .s`);
|
||||
@@ -287,12 +416,24 @@ stageEl.addEventListener('drop', e => {
|
||||
window.addEventListener('dragover', e => e.preventDefault()); window.addEventListener('drop', e => e.preventDefault());
|
||||
|
||||
$('#apply').onclick = async () => {
|
||||
const a = st.sel; $('#apply').disabled = true; showLog(['Converting and uploading to '+(a.title_name||a.title_id)+'…'], '');
|
||||
const a = st.sel, art = KINDS.some(k => st.staged[k]), name = newName(), lines = [];
|
||||
$('#apply').disabled = true; showLog([art ? 'Converting and uploading to '+(a.title_name||a.title_id)+'…' : 'Renaming '+(a.title_name||a.title_id)+'…'], '');
|
||||
const ids = {title_id:a.title_id, src:a.src, kind:a.kind};
|
||||
let artDone = false;
|
||||
try {
|
||||
const j = await api('/api/apply', {method:'POST', body: JSON.stringify({title_id:a.title_id, src:a.src, kind:a.kind})});
|
||||
showLog(j.log, 'ok'); st.staged = {}; refreshPreview(true);
|
||||
refreshRailIcon(a);
|
||||
} catch (err) { showLog(['Apply failed: '+err.message], 'bad'); $('#apply').disabled = false; }
|
||||
if (art) {
|
||||
const j = await api('/api/apply', {method:'POST', body: JSON.stringify(ids)});
|
||||
lines.push(...j.log); st.staged = {}; artDone = true; refreshRailIcon(a);
|
||||
}
|
||||
if (name) {
|
||||
const j = await api('/api/rename', {method:'POST', body: JSON.stringify({...ids, name})});
|
||||
lines.push(...j.log); a.title_name = j.name; L.loaded = false;
|
||||
const n = document.querySelector('.app[aria-current="true"] .n'); if (n) n.textContent = j.name;
|
||||
$('#aname').value = j.name; $('#tname').textContent = j.name;
|
||||
}
|
||||
showLog(lines, 'ok');
|
||||
} catch (err) { showLog([...lines, (art && !artDone ? 'Apply failed: ' : 'Rename failed: ')+err.message], 'bad'); }
|
||||
if (artDone) refreshPreview(true); else updateApply();
|
||||
};
|
||||
$('#restore').onclick = async () => {
|
||||
const a = st.sel; showLog(['Restoring original art…'], '');
|
||||
@@ -304,6 +445,249 @@ $('#restore').onclick = async () => {
|
||||
};
|
||||
function refreshRailIcon(a){ const im = document.querySelector('.app[aria-current="true"] .ic'); if (im) { im.classList.remove('none'); im.src = '/api/icon/'+encodeURIComponent(a.title_id)+'?t='+Date.now(); } }
|
||||
|
||||
// ─────────────────────────── Home layout ───────────────────────────
|
||||
const L = { info:null, edits:{}, order:[], base:[], dragged:false, w:'games', sel:null, focus:0, loaded:false, lastPreset:'' };
|
||||
const LIB = 'NPXS40071';
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const limg = p => p ? '/api/layout/img?p=' + encodeURIComponent(p) : '';
|
||||
const ltiles = () => L.info?.tiles ?? [];
|
||||
const lbyId = id => ltiles().find(t => t.id === id);
|
||||
function leff(t){
|
||||
const e = L.edits[t.id] || {};
|
||||
return {...t, where: e.where ?? t.where, name: e.name ?? t.name, changed: !!L.edits[t.id]};
|
||||
}
|
||||
function lchanges(){ return Object.keys(L.edits).length + (L.dragged ? 1 : 0); }
|
||||
function lgroups(w){
|
||||
const all = ltiles().map(leff);
|
||||
if (w === 'hidden') return [{key:'hidden', label:'Hidden from the home screen', list: all.filter(t => t.where === 'hidden')}];
|
||||
const sys = all.filter(t => t.kind === 'system' && t.where === w).sort((a,b) => (b.home - a.home) || (a.homeIdx - b.homeIdx) || (b.access - a.access));
|
||||
return [{key:'system', label:'System', list: sys},
|
||||
{key:'mine', label: w === 'games' ? 'Games' : 'Apps', list: L.order.map(id => leff(lbyId(id))).filter(t => t.where === w)}];
|
||||
}
|
||||
function lrowFor(w){
|
||||
const [sys, mine] = lgroups(w).map(g => g.list);
|
||||
if (w === 'media') return [...mine, ...sys];
|
||||
const lib = sys.filter(t => t.id === LIB), lead = sys.filter(t => t.home && t.id !== LIB), rest = sys.filter(t => !t.home && t.id !== LIB);
|
||||
return [...lead, ...mine, ...lib, ...rest];
|
||||
}
|
||||
const initials = n => esc(String(n).replace(/[^\p{L}\p{N}\s]/gu, '').split(/\s+/).filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase());
|
||||
function setImg(el, src, name){
|
||||
el.innerHTML = `<i>${initials(name)}</i>`;
|
||||
if (!src) return;
|
||||
const im = new Image(); im.onload = () => { el.style.backgroundImage = `url('${src}')`; el.innerHTML = ''; }; im.src = src;
|
||||
}
|
||||
|
||||
function lrender(){
|
||||
const has = !!L.info;
|
||||
$('#lvEmpty').classList.toggle('hidden', has); $('#lvBody').classList.toggle('hidden', !has);
|
||||
lfooter();
|
||||
if (!has) { $('#lside').innerHTML = '<p class="lnote">Select a tile to change it.</p>'; return; }
|
||||
$('#lvWarn').innerHTML = (L.info.warnings || []).map(w => `<div class="lwarn">${esc(w)}</div>`).join('');
|
||||
document.querySelectorAll('.ltab').forEach(b => b.setAttribute('aria-pressed', b.dataset.w === L.w));
|
||||
$('#lcG').textContent = lgroups('games').reduce((n,g) => n + g.list.length, 0);
|
||||
$('#lcM').textContent = lgroups('media').reduce((n,g) => n + g.list.length, 0);
|
||||
$('#lcH').textContent = lgroups('hidden')[0].list.length;
|
||||
$('#lsorts').classList.toggle('hidden', !L.info.canOrder || L.w === 'hidden');
|
||||
// strip
|
||||
const gs = lgroups(L.w);
|
||||
const strip = $('#lstrip');
|
||||
if (!gs.some(g => g.list.length)) strip.innerHTML = `<div class="lempty">${L.w === 'hidden' ? 'Nothing is hidden. Select a tile and choose Hidden to take it off the home screen.' : 'No tiles here.'}</div>`;
|
||||
else {
|
||||
strip.innerHTML = gs.filter(g => g.list.length).map(g => `<div class="lgrp"><span>${g.label}</span><div class="lcards">${g.list.map(t =>
|
||||
`<button class="lcard" data-id="${t.id}" data-g="${g.key}" draggable="${g.key === 'mine' && L.info.canOrder}" ${t.id === L.sel ? 'aria-current="true"' : ''}>
|
||||
<div class="im" data-src="${esc(limg(t.icon))}" data-n="${esc(t.name)}"></div>${t.changed ? '<span class="bd">Edited</span>' : ''}
|
||||
<div class="nm" title="${esc(t.name)}">${esc(t.name)}</div></button>`).join('')}</div></div>`).join('');
|
||||
strip.querySelectorAll('.im').forEach(el => setImg(el, el.dataset.src, el.dataset.n));
|
||||
strip.querySelector('[aria-current="true"]')?.scrollIntoView({block:'nearest', inline:'nearest'});
|
||||
}
|
||||
// preview
|
||||
const pw = L.w === 'hidden' ? 'games' : L.w;
|
||||
document.querySelectorAll('.ltabs span').forEach(s => s.classList.toggle('on', s.dataset.w === pw));
|
||||
const row = lrowFor(pw);
|
||||
if (L.sel) { const i = row.findIndex(t => t.id === L.sel); if (i >= 0) L.focus = i; }
|
||||
L.focus = Math.max(0, Math.min(L.focus, row.length - 1));
|
||||
$('#lrow').innerHTML = row.slice(0, 16).map((t,i) => `<div class="ltile ${i === L.focus ? 'sel' : ''}" data-id="${t.id}" data-src="${esc(limg(t.icon))}" data-n="${esc(t.name)}"></div>`).join('');
|
||||
$('#lrow').querySelectorAll('.ltile').forEach(el => setImg(el, el.dataset.src, el.dataset.n));
|
||||
const f = row[L.focus];
|
||||
$('#ltitle').textContent = f ? f.name : 'Nothing on this tab';
|
||||
$('#lsub').textContent = f && f.kind === 'system' ? 'System' : '';
|
||||
const bg = $('#lbg');
|
||||
if (f && f.bg) { bg.onload = () => bg.classList.remove('hidden'); bg.onerror = () => bg.classList.add('hidden'); bg.src = limg(f.bg); }
|
||||
else bg.classList.add('hidden');
|
||||
lside();
|
||||
}
|
||||
|
||||
function lside(){
|
||||
const box = $('#lside'), t0 = L.sel && lbyId(L.sel);
|
||||
if (!t0) { box.innerHTML = '<p class="lnote">Select a tile to change it.</p>'; return; }
|
||||
const t = leff(t0), e = L.edits[t.id] || {};
|
||||
const art = st.apps.find(a => a.title_id === t.id && a.editable);
|
||||
box.innerHTML = `<div><h2>${esc(t.name)}</h2><div class="id">${esc(t.id)}${t.kind === 'system' ? ' · system tile' : ''}</div></div>
|
||||
<div class="lsec"><span class="lbl">Shows on</span><div class="lseg">${['games','media','hidden'].map(v =>
|
||||
`<button data-where="${v}" aria-pressed="${t.where === v}">${v[0].toUpperCase() + v.slice(1)}</button>`).join('')}</div></div>
|
||||
<div class="lsec"><label for="lname">Name on the home screen</label><input class="ltxt" id="lname" value="${esc(t.name)}" maxlength="80">
|
||||
${e.name != null ? '<button class="linker" id="lname0">Use original name</button>' : ''}</div>
|
||||
${art ? '<div class="lsec"><span class="lbl">Icon and background</span><button class="btn" id="lart">Edit art</button></div>' : ''}
|
||||
${t.changed ? '<button class="linker" id="lundo" style="color:var(--bad)">Undo changes to this tile</button>' : ''}`;
|
||||
}
|
||||
|
||||
function lfooter(){
|
||||
const n = lchanges(), p = $('#lpend');
|
||||
p.classList.toggle('has', n > 0);
|
||||
p.querySelector('span').textContent = n ? `${n} unsaved change${n > 1 ? 's' : ''}` : 'No changes';
|
||||
$('#lApply').disabled = !n || !L.info; $('#lDiscard').disabled = !n;
|
||||
['#lPresets','#lReload'].forEach(s => $(s).disabled = !L.info);
|
||||
}
|
||||
|
||||
function lsetEdit(id, key, val){
|
||||
const t = lbyId(id), e = L.edits[id] ||= {};
|
||||
if (val === t[key] || val === '' || val == null) delete e[key]; else e[key] = val;
|
||||
if (!Object.keys(e).length) delete L.edits[id];
|
||||
lrender();
|
||||
}
|
||||
function lreset(){
|
||||
L.order = ltiles().filter(t => t.kind !== 'system').sort((a,b) => b.access - a.access).map(t => t.id);
|
||||
L.base = [...L.order]; L.edits = {}; L.dragged = false; $('#llock').checked = false;
|
||||
}
|
||||
|
||||
function busyv(on, t){ $('#busyv').classList.toggle('open', on); if (t) $('#busyT').textContent = t; }
|
||||
function dlg(html){ $('#dlgBody').innerHTML = html; $('#dlg').classList.add('open'); }
|
||||
function dlgClose(){ $('#dlg').classList.remove('open'); }
|
||||
$('#dlg').addEventListener('click', e => { if (e.target.id === 'dlg' || e.target.closest('[data-close]')) dlgClose(); });
|
||||
|
||||
async function lload(){
|
||||
busyv(true, 'Reading your home screen…');
|
||||
try { L.info = await api('/api/layout/load', {method:'POST', body: JSON.stringify({})}); lreset(); L.loaded = true; }
|
||||
catch (err) { L.info = null; $('#lvEmpty').innerHTML = `<h1>Couldn't read the home screen</h1><p>${esc(err.message)}</p>`; }
|
||||
busyv(false); lrender();
|
||||
}
|
||||
|
||||
// views
|
||||
function setView(v){
|
||||
document.querySelectorAll('.views button').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v));
|
||||
document.querySelector('.layout').classList.toggle('hidden', v !== 'art');
|
||||
$('#lv').classList.toggle('hidden', v !== 'layout');
|
||||
if (v === 'layout' && !L.loaded && $('#dot').classList.contains('ok')) lload();
|
||||
lrender();
|
||||
}
|
||||
document.querySelectorAll('.views button').forEach(b => b.onclick = () => setView(b.dataset.v));
|
||||
|
||||
// interactions
|
||||
document.querySelectorAll('.ltab').forEach(b => b.onclick = () => { L.w = b.dataset.w; L.focus = 0; lrender(); });
|
||||
document.querySelectorAll('.ltabs span').forEach(s => s.onclick = () => { L.w = s.dataset.w; L.focus = 0; L.sel = null; lrender(); });
|
||||
$('#lstrip').addEventListener('click', e => { const c = e.target.closest('.lcard'); if (c) { L.sel = c.dataset.id; lrender(); } });
|
||||
$('#lrow').addEventListener('click', e => { const c = e.target.closest('.ltile'); if (c) { L.sel = c.dataset.id; lrender(); } });
|
||||
document.addEventListener('keydown', e => {
|
||||
if ($('#lv').classList.contains('hidden') || !L.info || /INPUT|TEXTAREA/.test(document.activeElement.tagName) || $('#dlg').classList.contains('open')) return;
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
const row = lrowFor(L.w === 'hidden' ? 'games' : L.w); if (!row.length) return;
|
||||
L.focus = Math.max(0, Math.min(row.length - 1, L.focus + (e.key === 'ArrowRight' ? 1 : -1)));
|
||||
L.sel = row[L.focus].id; lrender(); e.preventDefault();
|
||||
});
|
||||
$('#lside').addEventListener('click', e => {
|
||||
const id = L.sel; if (!id) return;
|
||||
const w = e.target.closest('[data-where]'); if (w) return lsetEdit(id, 'where', w.dataset.where);
|
||||
if (e.target.closest('#lname0')) return lsetEdit(id, 'name', null);
|
||||
if (e.target.closest('#lundo')) { delete L.edits[id]; return lrender(); }
|
||||
if (e.target.closest('#lart')) {
|
||||
setView('art');
|
||||
const btn = [...document.querySelectorAll('.app')].find(b => b.querySelector('.i').textContent.startsWith(id));
|
||||
const a = st.apps.find(x => x.title_id === id);
|
||||
if (a && a.hidden && !st.showHidden) { st.showHidden = true; renderApps(); }
|
||||
const b2 = [...document.querySelectorAll('.app')].find(b => b.querySelector('.i').textContent.startsWith(id)) || btn;
|
||||
if (a && b2) select(a, b2);
|
||||
}
|
||||
});
|
||||
$('#lside').addEventListener('change', e => { if (e.target.id === 'lname') lsetEdit(L.sel, 'name', e.target.value.trim()); });
|
||||
$('#lside').addEventListener('keydown', e => { if (e.target.id === 'lname' && e.key === 'Enter') e.target.blur(); });
|
||||
|
||||
let ldrag = null;
|
||||
$('#lstrip').addEventListener('dragstart', e => { const c = e.target.closest('.lcard'); if (!c || c.dataset.g !== 'mine') return; ldrag = c.dataset.id; c.classList.add('drag'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); });
|
||||
$('#lstrip').addEventListener('dragend', () => { ldrag = null; document.querySelectorAll('.lcard').forEach(c => c.classList.remove('drag','over')); });
|
||||
$('#lstrip').addEventListener('dragover', e => { const c = e.target.closest('.lcard'); if (!ldrag || !c || c.dataset.g !== 'mine') return; e.preventDefault(); document.querySelectorAll('.lcard.over').forEach(x => x.classList.remove('over')); c.classList.add('over'); });
|
||||
$('#lstrip').addEventListener('drop', e => {
|
||||
e.preventDefault(); const c = e.target.closest('.lcard'); if (!ldrag || !c || c.dataset.g !== 'mine' || c.dataset.id === ldrag) return;
|
||||
L.order.splice(L.order.indexOf(ldrag), 1); L.order.splice(L.order.indexOf(c.dataset.id), 0, ldrag);
|
||||
L.dragged = true; L.sel = ldrag; lrender();
|
||||
});
|
||||
document.querySelectorAll('[data-sort]').forEach(b => b.onclick = () => {
|
||||
const k = b.dataset.sort;
|
||||
if (k === 'recent') { L.order = [...L.base]; L.dragged = false; return lrender(); }
|
||||
const key = id => leff(lbyId(id)).name.replace(/^[^\p{L}\p{N}]+/u, '').toLocaleLowerCase();
|
||||
L.order.sort((a,b) => key(a).localeCompare(key(b)) * (k === 'za' ? -1 : 1));
|
||||
L.dragged = true; lrender();
|
||||
});
|
||||
$('#lDiscard').onclick = () => { lreset(); lrender(); };
|
||||
$('#lReload').onclick = () => { if (lchanges() && !confirm('Reloading discards your unsaved changes. Continue?')) return; lload(); };
|
||||
|
||||
const presetData = () => ({edits: L.edits, order: L.order, dragged: L.dragged, lock: $('#llock').checked});
|
||||
function describe(e){
|
||||
const p = [];
|
||||
if (e.where) p.push(e.where === 'hidden' ? 'hide' : 'show on ' + e.where);
|
||||
if (e.name != null) p.push(`rename to “${e.name}”`);
|
||||
return p.join(', ');
|
||||
}
|
||||
$('#lApply').onclick = () => {
|
||||
const lines = Object.entries(L.edits).map(([id, e]) => `<div class="li"><div>${esc(lbyId(id).name)}<small>${esc(describe(e))}</small></div></div>`);
|
||||
if (L.dragged) lines.push(`<div class="li"><div>${$('#llock').checked ? 'Locked order' : 'New order'}<small>${esc(L.order.slice(0,6).map(id => leff(lbyId(id)).name).join(' → '))}${L.order.length > 6 ? ' …' : ''}</small></div></div>`);
|
||||
dlg(`<h3>Apply ${lchanges()} change${lchanges() > 1 ? 's' : ''} to your PS5?</h3>
|
||||
<p>PSGFX saves a backup of your current home screen first. Restart the PS5 afterwards to see the result.</p>
|
||||
${lines.join('')}
|
||||
<label class="chk"><input type="checkbox" id="dSave" checked> Also save this layout as a preset</label>
|
||||
<input class="ltxt" id="dName" value="${esc(L.lastPreset || 'My layout')}" maxlength="60" aria-label="Preset name">
|
||||
<p class="lnote" style="margin-top:6px">Re-apply it in one click after a firmware update or database rebuild resets your home screen.</p>
|
||||
<div class="acts"><button class="btn" data-close>Cancel</button><button class="btn primary" id="dGo">Apply changes</button></div>`);
|
||||
$('#dSave').onchange = e => $('#dName').disabled = !e.target.checked;
|
||||
$('#dGo').onclick = async () => {
|
||||
if ($('#dSave').checked) {
|
||||
const name = $('#dName').value.trim() || 'My layout';
|
||||
try { await api('/api/layout/preset/save', {method:'POST', body: JSON.stringify({name, data: presetData()})}); L.lastPreset = name; }
|
||||
catch (err) { if (!confirm(`Couldn't save the preset (${err.message}). Apply anyway?`)) return; }
|
||||
}
|
||||
dlgClose(); busyv(true, 'Backing up and applying…');
|
||||
const edits = {};
|
||||
for (const [id, e] of Object.entries(L.edits)) edits[id] = e;
|
||||
try {
|
||||
const j = await api('/api/layout/apply', {method:'POST', body: JSON.stringify({edits, recent: L.order, recentChanged: L.dragged, lock: $('#llock').checked})});
|
||||
L.info = j.layout; lreset(); lrender();
|
||||
dlg(`<h3>Changes applied</h3><p>Restart your PS5 to see the new home screen. If anything looks wrong, restore backup ${esc(j.backup)} from Backups.</p><pre>${esc(j.log.join('\n'))}</pre><div class="acts"><button class="btn primary" data-close>Done</button></div>`);
|
||||
} catch (err) {
|
||||
dlg(`<h3>Changes weren't applied</h3><p>${esc(err.message)}</p><div class="acts"><button class="btn primary" data-close>OK</button></div>`);
|
||||
}
|
||||
busyv(false);
|
||||
};
|
||||
};
|
||||
$('#lBackups').onclick = async () => {
|
||||
const j = await api('/api/layout/backups');
|
||||
dlg(`<h3>Home screen backups</h3><p>Saved in the "PSGFX Layout" folder next to PSGFX.exe, before every change, plus one of the very first home screen PSGFX read.</p>
|
||||
${j.backups.length ? j.backups.map(b => `<div class="li"><div>${esc(b.label)}<small>${esc(b.created)} · ${esc(b.ps5)}</small></div><button class="btn" data-restore="${esc(b.id)}" ${$('#dot').classList.contains('ok') ? '' : 'disabled'}>Restore</button></div>`).join('') : '<p class="lnote">No backups yet. One is made the first time you open Home layout.</p>'}
|
||||
<div class="acts"><button class="btn primary" data-close>Close</button></div>`);
|
||||
document.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => {
|
||||
if (!confirm('Upload this backup to your PS5? Your current home screen is replaced by it.')) return;
|
||||
dlgClose(); busyv(true, 'Restoring backup…');
|
||||
try { const r = await api('/api/layout/restore', {method:'POST', body: JSON.stringify({id: b.dataset.restore})}); L.info = r.layout; lreset(); lrender();
|
||||
dlg('<h3>Backup restored</h3><p>Restart your PS5 to load it.</p><div class="acts"><button class="btn primary" data-close>Done</button></div>'); }
|
||||
catch (err) { alert(err.message); }
|
||||
busyv(false);
|
||||
});
|
||||
};
|
||||
$('#lPresets').onclick = async () => {
|
||||
const j = await api('/api/layout/presets');
|
||||
dlg(`<h3>Layout presets</h3><p>Load one, check the preview, then Apply to PS5.</p>
|
||||
${j.presets.length ? j.presets.map(n => `<div class="li"><div>${esc(n)}</div><button class="btn" data-load="${esc(n)}">Load</button></div>`).join('') : '<p class="lnote">No presets yet. Tick "Also save this layout as a preset" when you apply.</p>'}
|
||||
<div class="acts"><button class="btn primary" data-close>Close</button></div>`);
|
||||
document.querySelectorAll('[data-load]').forEach(b => b.onclick = async () => {
|
||||
const p = (await api('/api/layout/preset/load', {method:'POST', body: JSON.stringify({name: b.dataset.load})})).data;
|
||||
lreset(); let n = 0;
|
||||
for (const [id, e] of Object.entries(p.edits || {})) if (lbyId(id)) { L.edits[id] = {...e}; n++; }
|
||||
if (p.dragged && p.order) { const known = p.order.filter(id => L.order.includes(id)); L.order = [...known, ...L.order.filter(id => !known.includes(id))]; L.dragged = true; }
|
||||
$('#llock').checked = !!p.lock; L.lastPreset = b.dataset.load;
|
||||
dlgClose(); lrender();
|
||||
dlg(`<h3>Preset loaded</h3><p>${n} tile${n === 1 ? '' : 's'} matched this console${p.dragged ? ', plus your order' : ''}. Check the preview, then Apply to PS5.</p><div class="acts"><button class="btn primary" data-close>OK</button></div>`);
|
||||
});
|
||||
};
|
||||
window.addEventListener('beforeunload', e => { if (lchanges()) { e.preventDefault(); e.returnValue = ''; } });
|
||||
|
||||
api('/api/config').then(j => { if (j.ip) { $('#ip').value = j.ip; $('#cf').requestSubmit(); } });
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -7,6 +7,7 @@ import base64, json, os, shutil, socket, struct, sys, threading
|
||||
ROOT = sys.argv[1]; MPORT = int(sys.argv[2]); XPORT = int(sys.argv[3])
|
||||
ALLOWED = ("/data/", "/user/", "/mnt/")
|
||||
txs = {}
|
||||
ftp_started = []
|
||||
|
||||
def P(p): return os.path.join(ROOT, p.lstrip("/"))
|
||||
def allowed(p): return p.startswith(ALLOWED) and ".." not in p
|
||||
@@ -30,6 +31,20 @@ def handle(s, t, body):
|
||||
if body[:1] == b"{":
|
||||
try: j = json.loads(body)
|
||||
except Exception: pass
|
||||
if t == 224: # FTP_START - the payload's built-in FTP server (needs: pip install pyftpdlib)
|
||||
port = int(j.get("port") or 2122)
|
||||
if not ftp_started:
|
||||
from pyftpdlib.authorizers import DummyAuthorizer
|
||||
from pyftpdlib.handlers import FTPHandler
|
||||
from pyftpdlib.servers import FTPServer
|
||||
import logging; logging.disable(logging.CRITICAL)
|
||||
auth = DummyAuthorizer(); auth.add_anonymous(ROOT, perm="elradfmwM")
|
||||
h = FTPHandler; h.authorizer = auth
|
||||
srv = FTPServer(("127.0.0.1", port), h)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
ftp_started.append(port)
|
||||
return send(s, 225, json.dumps({"ok": True, "port": port, "root": "/"}))
|
||||
return send(s, 225, json.dumps({"ok": False, "error": "already_running", "port": ftp_started[0]}))
|
||||
if t == 62: # APP_LIST_REGISTERED
|
||||
apps = []
|
||||
base = P("/user/app")
|
||||
|
||||
Reference in new issue
Block a user