6 Commits
Author SHA1 Message Date
Claude b7705fc110 Add Ko-fi support links
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zQD8DZVn9N7NJ6NHPLGg
2026-10-11 19:30:32 +00:00
Claude 4f634edcee New 2.0 banner and README header
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zQD8DZVn9N7NJ6NHPLGg
2026-10-11 19:29:16 +00:00
Claude ca13f15e48 Screenshots of the 2.0 interface
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zQD8DZVn9N7NJ6NHPLGg
2026-10-11 19:24:53 +00:00
Claude ccea007e2a Move the preset icons to the community library
The built-in preset icons are now community icons in elripalda/psgfx-community,
so the Preset icons row and the embedded copies are gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zQD8DZVn9N7NJ6NHPLGg
2026-10-11 19:20:47 +00:00
Claude aeb5d36771 Point community icons at ps5gfx.elripalda.com
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zQD8DZVn9N7NJ6NHPLGg
2026-10-11 19:07:22 +00:00
Claude 178c9cb550 PSGFX 2.0: new glass UI, guided connect, community icons
- Redesigned interface: larger, glass style with a chromatic rim, animated
  view/tab switches, search in the app list, Icon/Background/Music/Name tabs.
- Connecting moved into a centred pop-up with step-by-step instructions;
  the header shows a connection button instead of the IP field.
- Home layout preview: the selected tile keeps its white outline on every
  side, the row slides when the selection moves, Games/Media fade, and the
  background crossfades.
- Hiding or moving a tile off the current tab selects the next one, with Undo.
- Community icons: browse, use, share and report icons per game. The server
  (community-server/, PHP) checks uploads, saves them to a public GitHub repo
  with thumbnails and a site mirror, and has a moderation page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zQD8DZVn9N7NJ6NHPLGg
2026-10-11 18:51:54 +00:00
50 changed files with 1176 additions and 33088 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
ko_fi: ripalda
+31 -3
View File
@@ -3,7 +3,21 @@
</p>
<p align="center">
<a href="../../releases/latest"><b>Download PSGFX for Windows</b></a>
<a href="https://github.com/elripalda/psgfx/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/elripalda/psgfx?label=release&color=3b8cff"></a>
<img alt="Platform: Windows" src="https://img.shields.io/badge/platform-Windows-1f5fd6">
<img alt="For jailbroken PS5" src="https://img.shields.io/badge/for-jailbroken%20PS5-5cd3ff">
<a href="LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-a9a6ff"></a>
</p>
<p align="center">
<a href="https://github.com/elripalda/psgfx/releases/latest"><b>Download PSGFX 2.0</b></a> ·
<a href="#how-to-use">How to use</a> ·
<a href="#home-layout">Home layout</a> ·
<a href="#community-icons">Community icons</a>
</p>
<p align="center">
<a href="https://ko-fi.com/ripalda"><img alt="Support me on Ko-fi" src="https://img.shields.io/badge/Support%20me%20on-Ko--fi-ff5e5b?style=for-the-badge&logo=ko-fi&logoColor=white"></a>
</p>
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.
@@ -14,6 +28,8 @@ It works through [PS5 Upload](https://github.com/phantomptr/ps5upload): keep the
## Features
- **New look (2.0).** A redesigned, larger glass interface with smooth animations, and a home-screen preview that slides between tiles like the console does.
- **Community icons (new in 2.0).** Browse icons other PSGFX users made for the game you picked and use one in a click, or share yours. Shared icons are stored in a public GitHub repository and moderated.
- **Custom icons.** Use any PNG or JPEG. PSGFX crops it to a square and saves it at the console's native size.
- **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.
@@ -38,7 +54,7 @@ It works through [PS5 Upload](https://github.com/phantomptr/ps5upload): keep the
1. Download `PSGFX.exe` from the [latest release](../../releases/latest) and double-click it. PSGFX opens in your browser.
2. Open PS5 Upload and make sure it's connected to your PS5.
3. Enter your PS5's IP address (the same one PS5 Upload uses) and click **Connect**.
3. PSGFX asks for your PS5's IP address (the same one PS5 Upload uses). Enter it and click **Connect**. Next time it connects by itself.
4. Choose a homebrew app or an installed game from the list.
5. Drop an image onto the preview's tile (**icon**) or background (**background**), or use the **Choose image** buttons.
6. Close that app or game on the PS5, then click **Apply to PS5**.
@@ -49,12 +65,20 @@ To undo, select the app and click **Restore original**.
### 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.
2. Pick a tile in the strip under the preview. Choose **Games**, **Media** or **Hidden**, or type a new name. When a tile leaves the tab you're on, the next one is selected so you can keep going, and **Undo** brings it back.
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.
![Home layout with the console-style preview](docs/screenshot-layout.png)
PSGFX edits the console's home-screen database (`/system_data/priv/mms/app.db`) through PS5 Upload. It uploads the new database to `/data/psgfx` and has PS5 Upload's console shell copy it into place, then reads it back to check it. 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.
### Community icons
Pick an app or game: the **Icon** tab shows icons other people shared for it. Click one to use it, then **Apply to PS5**. To share yours, choose or drop an icon, click **Share your icon**, and accept the guidelines. It goes live right away. Use the ⚑ on an icon to report it.
The library is run by `community-server/` (a few PHP files on the PSGFX website that save icons to a public GitHub repository). See [community-server/SETUP.md](community-server/SETUP.md).
![Homebrew app with its current background](docs/screenshot-homebrew.png)
## Tips
@@ -105,3 +129,7 @@ If PS5 Upload's engine runs somewhere else (a NAS or Docker), add `"engine": "ho
PSGFX is free software under the [GNU General Public License v3.0 or later](LICENSE). Versions up to 1.2 were released under the MIT License; 1.3 moved to the GPL because it includes the ATRAC9 encoder from [PS5 AT9 Converter](https://github.com/blackbearreloaded/ps5-at9-converter), which is GPL-3.0-or-later.
PSGFX is an independent fan project. It is not affiliated with, endorsed by, or sponsored by Sony Interactive Entertainment. "PlayStation" and "PS5" are trademarks of Sony Interactive Entertainment Inc. Only use PSGFX with games you own.
## Support
PSGFX is free. If it made your PS5 look the way you want, you can buy me a coffee on [Ko-fi](https://ko-fi.com/ripalda). Thank you!
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

+1 -1
View File
@@ -9,7 +9,7 @@ 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 src/presets_embed.cpp src/lib/at9/at9_encoder.cpp src/lib/at9/at9_snd0.cpp src/lib/at9/at9_decode.cpp src/lib/at9/at9_tables.cpp"
SOURCES="src/main.cpp src/ui.cpp src/assets_embed.cpp src/lib/bc7enc.cpp src/lib/bc7decomp.cpp src/lib/at9/at9_encoder.cpp src/lib/at9/at9_snd0.cpp src/lib/at9/at9_decode.cpp src/lib/at9/at9_tables.cpp"
if [[ "${1:-}" == "linux" ]]; then
for f in src/lib/blake3.c src/lib/blake3_dispatch.c src/lib/blake3_portable.c; do
+3
View File
@@ -0,0 +1,3 @@
config.php
data/*.json
files/
+5
View File
@@ -0,0 +1,5 @@
# Only the four pages are public; the settings and helper files are not.
<FilesMatch "^(config|config\.sample|lib)\.php$">
Require all denied
</FilesMatch>
Options -Indexes
+73
View File
@@ -0,0 +1,73 @@
# Community icons: setting up the server
PSGFX 2.0 lets people share icons with each other. Uploads go to a small PHP script on your own
website, which checks each image and saves it to a public GitHub repository. Your GitHub key stays
on your website, so it never ships inside PSGFX.
**Cost:** nothing. It runs on the web hosting plan you already have (any plan with PHP 8,
including Hostinger's) and a free GitHub repository.
## 1. Make the icons repository on GitHub
1. Go to <https://github.com/new>.
2. Name it `psgfx-community`, set it to **Public**, and tick **Add a README file**.
3. Click **Create repository**.
## 2. Make a key that can only touch that repository
1. Go to <https://github.com/settings/personal-access-tokens/new>.
2. **Token name:** `psgfx-community upload`. **Expiration:** the longest it offers. Put a reminder in
your calendar to make a new one before it runs out (uploads stop working when it expires).
3. **Repository access:** *Only select repositories* → `psgfx-community`.
4. **Permissions** → **Repository permissions** → **Contents** → **Read and write**.
5. Click **Generate token** and copy it (it starts with `github_pat_`). You only see it once.
## 3. Put the script on your website (Hostinger)
1. In hPanel open **Websites** → your site → **File Manager**.
2. Open `public_html` and create a folder called `psgfx`.
3. Open `psgfx` and upload everything from `psgfx-community-server.zip`, then extract it there
(right-click the zip → **Extract**, into the current folder). Delete the zip afterwards.
4. Rename `config.sample.php` to `config.php`, open it, and fill in:
- `GITHUB_TOKEN`: the key from step 2.
- `ADMIN_PASSWORD`: a long password for the moderation page.
- `GITHUB_REPO` only if you used a different name than `elripalda/psgfx-community`.
5. Save.
## 4. Check it
Open `https://YOUR-DOMAIN/psgfx/list.php?title=PPSA99764` in your browser. You should see:
```
{"ok":true,"icons":[]}
```
Then send the address (`https://YOUR-DOMAIN/psgfx/`; PSGFX 2.0 is built for `https://ps5gfx.elripalda.com/`) so it can be built into PSGFX. Until then you can
try it yourself by adding `"community": "https://YOUR-DOMAIN/psgfx/"` to `psgfx.json` next to PSGFX.exe.
## Moderating
Open `https://YOUR-DOMAIN/psgfx/mod.php` and log in with `ADMIN_PASSWORD`.
- Every shared icon is listed, newest first. Reported ones come first.
- An icon is hidden automatically after 3 different people report it, until you **Keep** or **Delete** it.
- **Delete** removes it from GitHub and your site, so it disappears from PSGFX within a minute or two.
- **Delete + block** also stops whoever uploaded it from uploading again.
- You can also delete files straight on GitHub; PSGFX follows whatever is in the repository.
Limits per person are 10 uploads an hour and 30 a day. Change them in `config.php`.
## What's where
| File | What it does |
| --- | --- |
| `upload.php` | Receives an icon from PSGFX, checks it, makes a clean 512 × 512 PNG and a 128 × 128 thumbnail, saves both to GitHub, and keeps a copy in `files/` on your site. |
| `list.php?title=ID` | The icons for one game, newest first. |
| `index.php` | Every game and its icons in one file, for apps that show the whole library. |
| `report.php` | Takes a report about an icon. |
| `mod.php` | Your moderation page. |
| `data/` | Upload limits, reports and blocks. Not reachable from the web. |
| `files/` | Copies of every icon and thumbnail, served by your site. |
In the GitHub repository icons are saved as `icons/<title ID>/<time>_<hash>_<name>.png`, with the
thumbnail at the same name under `thumbs/`.
+22
View File
@@ -0,0 +1,22 @@
<?php
// PSGFX community icons - settings. Copy this file to config.php and fill it in.
// config.php holds your GitHub key: never share it or put it on GitHub.
// The GitHub repository the icons are saved to, as "owner/name". It must be public.
const GITHUB_REPO = 'elripalda/psgfx-community';
const GITHUB_BRANCH = 'main';
// A fine-grained personal access token with access to ONLY that repository,
// permission "Contents: Read and write". See SETUP.md.
const GITHUB_TOKEN = 'github_pat_PASTE_YOURS_HERE';
// Password for mod.php, where you review and delete icons. Pick a long one.
const ADMIN_PASSWORD = 'change-me-to-something-long';
// Limits per person (by IP address).
const UPLOADS_PER_HOUR = 10;
const UPLOADS_PER_DAY = 30;
// An icon is hidden after this many different people report it, until you look at it in mod.php.
const REPORTS_TO_HIDE = 3;
// Largest file accepted, in bytes.
const MAX_UPLOAD_BYTES = 6 * 1024 * 1024;
+1
View File
@@ -0,0 +1 @@
Require all denied
+14
View File
@@ -0,0 +1,14 @@
<?php
// GET index.php -> every game that has community icons, and its icons, in one file.
// Made for apps that show the whole library (for example on the PS5), so they don't have to ask per game.
// { "ok": true, "updated": <unix time>, "titles": { "PPSA12345": [ {id, url, thumb, mirror?, mirror_thumb?, by, time}, ... ] } }
require __DIR__ . '/lib.php';
cors();
$reports = data_read('reports');
$titles = [];
foreach (all_icons() as $i) {
if (is_hidden($reports, $i['id'])) continue;
$titles[$i['title']][] = icon_out($i);
}
ksort($titles);
out(['ok' => true, 'updated' => time(), 'titles' => (object)$titles]);
+127
View File
@@ -0,0 +1,127 @@
<?php
// Shared helpers for the PSGFX community icon server.
declare(strict_types=1);
if (!is_file(__DIR__ . '/config.php')) { http_response_code(500); exit('{"ok":false,"error":"The server is not set up yet (config.php is missing)."}'); }
require __DIR__ . '/config.php';
if (!defined('GITHUB_API')) define('GITHUB_API', 'https://api.github.com');
const DATA_DIR = __DIR__ . '/data';
const MIRROR_DIR = __DIR__ . '/files'; // copies of every icon and thumbnail, served by this site
const TREE_TTL = 90; // seconds the icon list is cached before asking GitHub again
// PSGFX runs on http://127.0.0.1 in the user's browser, so every answer allows any origin.
// Nothing here relies on cookies, so that is safe.
function cors(): void {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Max-Age: 86400');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(204); exit; }
}
function out(array $j, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($j, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function fail(string $msg, int $code = 400): void { out(['ok' => false, 'error' => $msg], $code); }
// Who is asking, as a salted hash: enough to rate-limit and block, without storing addresses.
function who(): string {
$ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
return substr(hash('sha256', $ip . '|' . GITHUB_TOKEN), 0, 20);
}
// Small JSON files in data/, updated under a lock so two requests can't overwrite each other.
function data_path(string $name): string {
if (!is_dir(DATA_DIR)) mkdir(DATA_DIR, 0750, true);
return DATA_DIR . '/' . $name . '.json';
}
function data_read(string $name): array {
$f = data_path($name);
if (!is_file($f)) return [];
$j = json_decode((string)file_get_contents($f), true);
return is_array($j) ? $j : [];
}
function data_update(string $name, callable $fn) {
$h = fopen(data_path($name), 'c+');
flock($h, LOCK_EX);
$j = json_decode((string)stream_get_contents($h), true);
if (!is_array($j)) $j = [];
$ret = $fn($j);
ftruncate($h, 0); rewind($h);
fwrite($h, json_encode($j, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
fflush($h); flock($h, LOCK_UN); fclose($h);
return $ret;
}
function gh(string $method, string $path, ?array $body = null): array {
$ch = curl_init(GITHUB_API . $path);
$hdr = ['Accept: application/vnd.github+json', 'User-Agent: psgfx-community', 'X-GitHub-Api-Version: 2022-11-28',
'Authorization: Bearer ' . GITHUB_TOKEN];
if ($body !== null) { $hdr[] = 'Content-Type: application/json'; curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); }
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $hdr, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
$res = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$j = is_string($res) ? json_decode($res, true) : null;
return [$code, is_array($j) ? $j : []];
}
function valid_title(string $t): bool { return (bool)preg_match('/^[A-Z]{4}\d{5}$/', $t); }
function raw_url(string $path): string { return 'https://raw.githubusercontent.com/' . GITHUB_REPO . '/' . GITHUB_BRANCH . '/' . $path; }
// Every icon in the repository: icons/<TITLE ID>/<time>_<hash>_<name>.png. GitHub is the source of truth,
// so an icon you delete on GitHub disappears from PSGFX too. Cached for TREE_TTL seconds.
function all_icons(bool $fresh = false): array {
$cache = data_path('tree-cache');
if (!$fresh && is_file($cache) && time() - filemtime($cache) < TREE_TTL) {
$j = json_decode((string)file_get_contents($cache), true);
if (is_array($j)) return $j;
}
[$code, $j] = gh('GET', '/repos/' . GITHUB_REPO . '/git/trees/' . GITHUB_BRANCH . '?recursive=1');
if ($code === 404 || $code === 409) $j = ['tree' => []]; // empty repository: no icons yet
elseif ($code !== 200) {
if (is_file($cache)) return json_decode((string)file_get_contents($cache), true) ?: [];
fail('GitHub is not answering right now. Try again in a minute.', 502);
}
$icons = [];
foreach ($j['tree'] ?? [] as $e) {
if (($e['type'] ?? '') !== 'blob') continue;
if (!preg_match('#^icons/([A-Z]{4}\d{5})/((\d+)_([0-9a-f]{8})_?([^/]*))\.png$#', $e['path'], $m)) continue;
$icons[] = ['id' => $e['path'], 'title' => $m[1], 'time' => (int)$m[3], 'by' => str_replace('-', ' ', $m[5]), 'sha' => $e['sha']];
}
usort($icons, fn($a, $b) => $b['time'] <=> $a['time']);
file_put_contents($cache, json_encode($icons), LOCK_EX);
return $icons;
}
function forget_icons(): void { @unlink(data_path('tree-cache')); }
// Reports: { "<icon path>": {"by": [who...], "ok": bool} }. "ok" means you reviewed it and kept it.
function is_hidden(array $reports, string $id): bool {
$r = $reports[$id] ?? null;
return $r && empty($r['ok']) && count($r['by'] ?? []) >= REPORTS_TO_HIDE;
}
// This site's own address, for links into files/. Uses whichever of http/https the request came in on.
function site_base(): string {
$https = ($_SERVER['HTTPS'] ?? '') !== '' && $_SERVER['HTTPS'] !== 'off' || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
$dir = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $dir . '/';
}
function mirror_put(string $path, string $bytes): void {
$f = MIRROR_DIR . '/' . $path;
if (!is_dir(dirname($f))) mkdir(dirname($f), 0755, true);
file_put_contents($f, $bytes);
}
function mirror_delete(string $path): void {
@unlink(MIRROR_DIR . '/' . $path);
@unlink(MIRROR_DIR . '/thumbs/' . substr($path, 6));
}
// What PSGFX and other apps get for one icon. "url"/"thumb" are on GitHub; "mirror"/"mirror_thumb" are on this site.
function icon_out(array $i): array {
$t = 'thumbs/' . substr($i['id'], 6);
$o = ['id' => $i['id'], 'url' => raw_url($i['id']), 'thumb' => raw_url($t), 'by' => $i['by'], 'time' => $i['time']];
if (is_file(MIRROR_DIR . '/' . $i['id'])) { $o['mirror'] = site_base() . 'files/' . $i['id']; $o['mirror_thumb'] = site_base() . 'files/' . $t; }
return $o;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
// GET list.php?title=PPSA12345 -> the community icons for that game, newest first.
require __DIR__ . '/lib.php';
cors();
$title = strtoupper((string)($_GET['title'] ?? ''));
if (!valid_title($title)) fail('Unknown title ID.');
$reports = data_read('reports');
$list = [];
foreach (all_icons() as $i) {
if ($i['title'] !== $title || is_hidden($reports, $i['id'])) continue;
$list[] = icon_out($i);
}
out(['ok' => true, 'icons' => $list]);
+90
View File
@@ -0,0 +1,90 @@
<?php
// mod.php - your moderation page. Log in with ADMIN_PASSWORD from config.php.
// Reported icons come first. Delete removes an icon from GitHub (and this site), so it's gone from PSGFX.
require __DIR__ . '/lib.php';
session_start();
header('Cache-Control: no-store');
header('X-Frame-Options: DENY');
$h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES);
if (isset($_POST['password'])) {
if (hash_equals(ADMIN_PASSWORD, (string)$_POST['password']) && ADMIN_PASSWORD !== 'change-me-to-something-long') {
session_regenerate_id(true); $_SESSION['mod'] = true; $_SESSION['csrf'] = bin2hex(random_bytes(16));
} else { sleep(2); $err = 'Wrong password.'; }
}
if (isset($_GET['logout'])) { $_SESSION = []; session_destroy(); header('Location: mod.php'); exit; }
$msg = '';
if (!empty($_SESSION['mod']) && isset($_POST['act']) && hash_equals($_SESSION['csrf'], (string)($_POST['csrf'] ?? ''))) {
$id = (string)($_POST['id'] ?? '');
if (preg_match('#^icons/[A-Z]{4}\d{5}/[^/]+\.png$#', $id)) {
if ($_POST['act'] === 'keep') {
data_update('reports', function (array &$r) use ($id) { $r[$id] = ['by' => [], 'ok' => true]; });
$msg = 'Kept. It is visible again and reports were cleared.';
} else {
$deleted = 0;
foreach ([$id, 'thumbs/' . substr($id, 6)] as $p) {
[$c, $j] = gh('GET', '/repos/' . GITHUB_REPO . '/contents/' . $p . '?ref=' . GITHUB_BRANCH);
if ($c !== 200 || empty($j['sha'])) continue;
[$c2] = gh('DELETE', '/repos/' . GITHUB_REPO . '/contents/' . $p, ['message' => "Remove $p", 'sha' => $j['sha'], 'branch' => GITHUB_BRANCH]);
if ($c2 === 200) $deleted++;
}
mirror_delete($id);
data_update('reports', function (array &$r) use ($id) { unset($r[$id]); });
if ($_POST['act'] === 'block') {
$owner = data_read('owners')[$id] ?? null;
if ($owner) data_update('blocked', function (array &$b) use ($owner) { if (!in_array($owner, $b, true)) $b[] = $owner; });
$msg = $owner ? 'Deleted, and that person can no longer upload.' : 'Deleted. (Who uploaded it is unknown, so nobody was blocked.)';
} else $msg = 'Deleted.';
forget_icons();
}
}
}
?><!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>PSGFX community - moderation</title>
<style>
body{margin:0;font:15px/1.5 system-ui,"Segoe UI",sans-serif;background:#070b16;color:#e8eefb}
.wrap{max-width:1100px;margin:0 auto;padding:24px 16px}
h1{font-size:22px;margin:0 0 4px}.m{color:#8c98ae}
form.login{max-width:340px;margin:12vh auto;display:grid;gap:10px}
input[type=password]{padding:10px 12px;border-radius:10px;border:1px solid #2a3552;background:#0f1628;color:inherit;font:inherit}
button{padding:7px 12px;border-radius:9px;border:1px solid #2a3552;background:#16203a;color:inherit;font:inherit;cursor:pointer}
button.p{background:#3d7bff;border-color:transparent}button.d{background:#5a1d26;border-color:#7a2a35}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-top:18px}
.c{background:#0f1628;border:1px solid #1f2a45;border-radius:14px;padding:10px}
.c.rep{border-color:#a0553a}.c img{width:100%;aspect-ratio:1;border-radius:10px;background:#1a2440;display:block}
.c .t{font-weight:600;margin-top:8px}.c .s{color:#8c98ae;font-size:13px}.c .r{color:#ffb547;font-size:13px}
.c form{display:inline}.acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.msg{background:#13304a;border:1px solid #2f6bff;padding:8px 12px;border-radius:10px;margin-top:12px}
</style></head><body><div class="wrap">
<?php if (empty($_SESSION['mod'])): ?>
<form class="login" method="post"><h1>PSGFX moderation</h1>
<?php if (ADMIN_PASSWORD === 'change-me-to-something-long'): ?><p class="m">Set ADMIN_PASSWORD in config.php first.</p><?php endif; ?>
<?php if (!empty($err)): ?><p class="r"><?= $h($err) ?></p><?php endif; ?>
<input type="password" name="password" placeholder="Password" autofocus><button class="p">Log in</button></form>
<?php else:
$icons = all_icons(true); $reports = data_read('reports');
$n = fn($i) => count($reports[$i['id']]['by'] ?? []);
usort($icons, fn($a, $b) => ($n($b) <=> $n($a)) ?: ($b['time'] <=> $a['time']));
$csrf = $_SESSION['csrf'];
?>
<h1>Community icons</h1>
<p class="m"><?= count($icons) ?> icons. Reported ones come first; at <?= REPORTS_TO_HIDE ?> reports an icon is hidden until you keep or delete it. <a href="?logout" style="color:#5b90ff">Log out</a></p>
<?php if ($msg): ?><div class="msg"><?= $h($msg) ?></div><?php endif; ?>
<div class="grid">
<?php foreach (array_slice($icons, 0, 300) as $i): $r = $n($i); $hidden = is_hidden($reports, $i['id']);
$src = is_file(MIRROR_DIR . '/' . $i['id']) ? 'files/' . $i['id'] : raw_url($i['id']); ?>
<div class="c<?= $r ? ' rep' : '' ?>"><img src="<?= $h($src) ?>" alt="" loading="lazy">
<div class="t"><?= $h($i['title']) ?></div>
<div class="s"><?= $h($i['by'] !== '' ? $i['by'] : 'Anonymous') ?> · <?= $h(date('Y-m-d H:i', $i['time'])) ?></div>
<?php if ($r): ?><div class="r"><?= $r ?> report<?= $r > 1 ? 's' : '' ?><?= $hidden ? ' · hidden' : '' ?></div><?php endif; ?>
<div class="acts">
<?php foreach (($r ? ['keep' => 'Keep'] : []) + ['delete' => 'Delete', 'block' => 'Delete + block'] as $act => $label): ?>
<form method="post" <?= $act !== 'keep' ? 'onsubmit="return confirm(\'Delete this icon?\')"' : '' ?>><input type="hidden" name="csrf" value="<?= $h($csrf) ?>"><input type="hidden" name="id" value="<?= $h($i['id']) ?>"><button name="act" value="<?= $act ?>" class="<?= $act === 'keep' ? 'p' : 'd' ?>"><?= $label ?></button></form>
<?php endforeach; ?>
</div></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div></body></html>
+14
View File
@@ -0,0 +1,14 @@
<?php
// POST report.php id=<icon path> -> one report per person per icon.
require __DIR__ . '/lib.php';
cors();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') fail('Use POST.', 405);
$id = (string)($_POST['id'] ?? '');
if (!preg_match('#^icons/[A-Z]{4}\d{5}/[^/]+\.png$#', $id)) fail('Unknown icon.');
$me = who();
data_update('reports', function (array &$r) use ($id, $me) {
$e = $r[$id] ?? ['by' => [], 'ok' => false];
if (!in_array($me, $e['by'], true)) $e['by'][] = $me;
$r[$id] = $e;
});
out(['ok' => true]);
+69
View File
@@ -0,0 +1,69 @@
<?php
// POST upload.php (multipart): title, name (optional), agree=1, image (PNG or JPEG file).
// Checks the image, turns it into a clean 512 x 512 PNG, and saves it to GitHub at
// icons/<title>/<time>_<hash>_<name>.png, plus a 128 x 128 copy under thumbs/, and keeps a copy of both
// on this site (files/) for consoles that can only use plain http. It shows up in PSGFX straight away.
require __DIR__ . '/lib.php';
cors();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') fail('Use POST.', 405);
if (($_POST['agree'] ?? '') !== '1') fail('Please accept the community guidelines first.');
$title = strtoupper((string)($_POST['title'] ?? ''));
if (!valid_title($title)) fail('Unknown title ID.');
$name = preg_replace('/[^A-Za-z0-9 ._]/', '', (string)($_POST['name'] ?? ''));
$name = trim(substr(preg_replace('/\s+/', ' ', $name), 0, 24));
$f = $_FILES['image'] ?? null;
if (!$f || $f['error'] !== UPLOAD_ERR_OK) fail('No image was received.');
if ($f['size'] > MAX_UPLOAD_BYTES) fail('That image is too large.');
$me = who();
if (in_array($me, data_read('blocked'), true)) fail('Uploads from you are turned off.', 403);
// Rate limit: drop timestamps older than a day, then count.
$now = time();
$allowed = data_update('uploads', function (array &$u) use ($me, $now) {
foreach ($u as $k => $times) { $u[$k] = array_values(array_filter($times, fn($t) => $t > $now - 86400)); if (!$u[$k]) unset($u[$k]); }
$mine = $u[$me] ?? [];
$hour = count(array_filter($mine, fn($t) => $t > $now - 3600));
if ($hour >= UPLOADS_PER_HOUR || count($mine) >= UPLOADS_PER_DAY) return false;
$u[$me][] = $now;
return true;
});
if (!$allowed) fail("You've shared a lot of icons recently. Try again later.", 429);
// Decode and re-encode, so only real pixels reach GitHub (nothing hidden inside the file).
$info = @getimagesize($f['tmp_name']);
if (!$info || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG], true)) fail("That file isn't a PNG or JPEG image.");
if ($info[0] < 128 || $info[1] < 128) fail('That image is too small. Use one at least 256 x 256.');
if ($info[0] * $info[1] > 40000000) fail('That image is too large.');
$src = $info[2] === IMAGETYPE_PNG ? @imagecreatefrompng($f['tmp_name']) : @imagecreatefromjpeg($f['tmp_name']);
if (!$src) fail("That image couldn't be read.");
$w = imagesx($src); $h = imagesy($src); $side = min($w, $h);
$dst = imagecreatetruecolor(512, 512);
imagealphablending($dst, false); imagesavealpha($dst, true);
imagefill($dst, 0, 0, imagecolorallocatealpha($dst, 0, 0, 0, 127));
imagecopyresampled($dst, $src, 0, 0, intdiv($w - $side, 2), intdiv($h - $side, 2), 512, 512, $side, $side);
ob_start(); imagepng($dst, null, 9); $png = (string)ob_get_clean();
$th = imagecreatetruecolor(128, 128);
imagealphablending($th, false); imagesavealpha($th, true);
imagecopyresampled($th, $dst, 0, 0, 0, 0, 128, 128, 512, 512);
ob_start(); imagepng($th, null, 9); $thumb = (string)ob_get_clean();
imagedestroy($src); imagedestroy($dst); imagedestroy($th);
$hash = substr(sha1($png), 0, 8);
foreach (all_icons() as $i) if ($i['title'] === $title && str_contains($i['id'], '_' . $hash)) fail('That icon is already shared for this game.');
$slug = str_replace(' ', '-', $name);
$path = "icons/$title/{$now}_{$hash}" . ($slug !== '' ? "_$slug" : '') . '.png';
[$code, $j] = gh('PUT', '/repos/' . GITHUB_REPO . '/contents/' . $path, [
'message' => "Add icon for $title" . ($name !== '' ? " by $name" : ''),
'content' => base64_encode($png),
'branch' => GITHUB_BRANCH,
]);
if ($code !== 201 && $code !== 200) fail("Couldn't save the icon to GitHub (error $code). Try again later.", 502);
// The small version for PS5 apps and quick lists. If this one fails, the mirror still has it.
$tpath = 'thumbs/' . substr($path, 6);
gh('PUT', '/repos/' . GITHUB_REPO . '/contents/' . $tpath, ['message' => "Thumbnail for $path", 'content' => base64_encode($thumb), 'branch' => GITHUB_BRANCH]);
mirror_put($path, $png); mirror_put($tpath, $thumb);
data_update('owners', function (array &$o) use ($path, $me) { $o[$path] = $me; });
forget_icons();
out(['ok' => true, 'icon' => icon_out(['id' => $path, 'title' => $title, 'by' => $name, 'time' => $now])]);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 561 KiB

+8 -2
View File
@@ -1,4 +1,4 @@
PSGFX 1.3
PSGFX 2.0
Custom icons and backgrounds for your PS5 homebrew and games, and your home-screen layout.
BEFORE YOU START
@@ -11,13 +11,19 @@ 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.
2. PSGFX asks for your PS5's IP address. Type it and click Connect.
(Next time it connects by itself. Click the connection button at the top to change it.)
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.
COMMUNITY ICONS (new in 2.0)
- Pick an app or game: the Icon tab shows icons other people shared for it.
Click one to use it, then Apply to PS5.
- To share yours, choose an icon, click "Share your icon" and accept the guidelines.
MUSIC (new in 1.3)
- Drop an MP3, WAV or FLAC on the preview, or use "Choose song".
- Set where it starts and how long it runs, listen to it, then Apply.
+19 -16
View File
@@ -11,7 +11,6 @@
#include "ps5client.h"
#include "layout.h"
#include "lib/at9/at9.h"
#include "presets.h"
#include "lib/json.hpp"
#include <atomic>
@@ -29,7 +28,11 @@
#include <shellapi.h>
#include <tlhelp32.h>
#endif
#define STUDIO_VERSION "1.3"
#define STUDIO_VERSION "2.0"
// Community icons server (community-server/ in this repo). psgfx.json "community" overrides it; empty hides the feature.
#ifndef PSGFX_COMMUNITY
#define PSGFX_COMMUNITY "https://ps5gfx.elripalda.com/"
#endif
using json = nlohmann::json;
@@ -51,6 +54,7 @@ struct Staged { // the user's image, decoded; converted per targ
static std::mutex g_mu;
static std::string g_ip;
static std::string g_engine; // PS5 Upload's engine, "host:port"; empty = 127.0.0.1:19113
static std::string g_community = PSGFX_COMMUNITY, g_community_set; // the server, and the psgfx.json override if any
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";
@@ -61,12 +65,17 @@ static void load_config() {
std::ifstream f(g_config_path);
if (!f && !g_legacy_config_path.empty()) f.open(g_legacy_config_path);
if (!f) return;
try { json j; f >> j; g_ip = j.value("ip", ""); g_engine = j.value("engine", ""); } catch (...) {}
try {
json j; f >> j; g_ip = j.value("ip", ""); g_engine = j.value("engine", "");
g_community_set = j.value("community", "");
if (!g_community_set.empty()) g_community = g_community_set;
} catch (...) {}
}
static void save_config() {
std::ofstream f(g_config_path);
json j = {{"ip", g_ip}};
if (!g_engine.empty()) j["engine"] = g_engine;
if (!g_community_set.empty()) j["community"] = g_community_set;
f << j.dump(2);
}
@@ -978,7 +987,7 @@ static void handle(sock_t s, Req &r) {
else if (r.path == "/f/sora700.woff2") { d = sora700; n = sora700_len; }
if (d) { respond(s, 200, "font/woff2", d, n); return; }
}
if (r.path == "/api/config") { std::lock_guard<std::mutex> l(g_mu); respond_json(s, {{"ip", g_ip}}); return; }
if (r.path == "/api/config") { std::lock_guard<std::mutex> l(g_mu); respond_json(s, {{"ip", g_ip}, {"community", g_community}, {"version", STUDIO_VERSION}}); return; }
if (r.path == "/api/connect" && r.method == "POST") {
json j = json::parse(std::string(r.body.begin(), r.body.end()));
{ std::lock_guard<std::mutex> l(g_mu); g_ip = j.value("ip", ""); g_icon_cache.clear(); }
@@ -1000,18 +1009,6 @@ static void handle(sock_t s, Req &r) {
}
if (r.path.rfind("/api/stage/", 0) == 0 && r.method == "POST") { respond_json(s, stage(r.path.substr(11), r.body)); return; }
if (r.path.rfind("/api/unstage/", 0) == 0) { std::lock_guard<std::mutex> l(g_mu); g_staged.erase(r.path.substr(13)); respond_json(s, {{"ok", true}}); return; }
if (r.path.rfind("/api/presets/", 0) == 0) { // /api/presets/<title id> -> the built-in icons for it
std::string id = r.path.substr(13);
json list = json::array();
for (size_t i = 0; i < PRESETS_COUNT; i++) if (id == PRESETS[i].title_id) list.push_back({{"i", i}, {"app", PRESETS[i].app}});
respond_json(s, {{"ok", true}, {"presets", list}}); return;
}
if (r.path.rfind("/api/preset/", 0) == 0) { // /api/preset/<index>: the image; POST stages it as the icon
size_t i = (size_t)strtoull(r.path.c_str() + 12, nullptr, 10);
if (i >= PRESETS_COUNT) { respond(s, 404, "text/plain", "none", 4); return; }
if (r.method == "POST") { respond_json(s, stage("icon", std::vector<uint8_t>(PRESETS[i].data, PRESETS[i].data + PRESETS[i].len))); return; }
respond(s, 200, "image/png", PRESETS[i].data, PRESETS[i].len); return;
}
if (r.path == "/api/music/clip" && r.method == "POST") { respond_json(s, music_clip(json::parse(std::string(r.body.begin(), r.body.end())))); return; }
if (r.path == "/api/staged/music") {
std::vector<uint8_t> b;
@@ -1019,6 +1016,12 @@ static void handle(sock_t s, Req &r) {
if (b.empty()) { respond(s, 404, "text/plain", "none", 4); return; }
respond(s, 200, "audio/wav", b.data(), b.size()); return;
}
if (r.path == "/api/share/icon") { // the staged icon at full size, for sharing with the community
std::vector<uint8_t> b;
{ std::lock_guard<std::mutex> l(g_mu); auto it = g_staged.find("icon"); if (it != g_staged.end()) b = img::to_png(img::cover(it->second.src, 512, 512)); }
if (b.empty()) { respond(s, 404, "text/plain", "none", 4); return; }
respond(s, 200, "image/png", b.data(), b.size()); return;
}
if (r.path.rfind("/api/staged/", 0) == 0) {
std::vector<uint8_t> b;
{ std::lock_guard<std::mutex> l(g_mu); auto it = g_staged.find(r.path.substr(12)); if (it != g_staged.end()) b = it->second.preview_png; }
-7
View File
@@ -1,7 +0,0 @@
// Preset icons built into PSGFX, by title ID (assets/presets, embedded by tools/embed_presets.py).
#pragma once
#include <cstddef>
struct Preset { const char *app; const char *title_id; const unsigned char *data; size_t len; };
extern const Preset PRESETS[];
extern const size_t PRESETS_COUNT;
-32695
View File
File diff suppressed because it is too large. Load diff
+685 -334
View File
File diff suppressed because it is too large. Load diff
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
"""Regenerate src/presets_embed.cpp from assets/presets/<App name>/<TITLE ID>/<n>.png (preset icons).
Run from the repository root: python3 tools/embed_presets.py"""
import os, re
root = "assets/presets"
items = []
for app in sorted(os.listdir(root)):
for tid in sorted(os.listdir(os.path.join(root, app))):
d = os.path.join(root, app, tid)
if not os.path.isdir(d) or not re.fullmatch(r"[A-Z]{4}\d{5}", tid): continue
names = sorted((n for n in os.listdir(d) if n.lower().endswith(".png")),
key=lambda n: (int(m.group()) if (m := re.match(r"\d+", n)) else 1 << 30, n))
for n in names: items.append((app, tid, os.path.join(d, n)))
out = ["// Generated by tools/embed_presets.py - do not edit.",
"#include <cstddef>", '#include "presets.h"']
for i, (_, _, path) in enumerate(items):
data = open(path, "rb").read()
out.append(f"static const unsigned char p{i}[] = {{")
for j in range(0, len(data), 24):
out.append(" " + ",".join(str(b) for b in data[j:j + 24]) + ",")
out.append("};")
out.append("extern const Preset PRESETS[] = {")
for i, (app, tid, _) in enumerate(items):
out.append(f' {{"{app}", "{tid}", p{i}, sizeof p{i}}},')
out.append("};")
out.append(f"extern const size_t PRESETS_COUNT = {len(items)};")
open("src/presets_embed.cpp", "w").write("\n".join(out) + "\n")
print(f"wrote src/presets_embed.cpp ({len(items)} icons)")