Files
ps5upload/scripts/release/android-postinit-patch.sh
T
phantomptrandClaude Opus 5.5 0d254e5433 6.5.0: Collection, Move to, Android on Android 17, helper on FW 5.50, web UI pairing
New:
- Collection: every game in your folders (local or on a NAS, scanned in
  place), grouped with updates and DLC, what each PS5 has, one-click
  installs, install links, organize / de-duplicate / clean up. Replaces PS
  Game Library (with an importer).
- Send a game folder as one image (.ffpfsc / .ffpkg / .exfat); Convert
  writes .ffpkg and .ffpfs too.
- Files: Move to... (instant or cross-drive, with a fit check) and Set
  permissions (777).
- Offline PSN sign-in from Profile.

Fixed:
- Android 17: local network permission (6.x could not connect).
- FW 5.50: own build of the SDK startup code, so the helper, installer and
  elfldr start.
- Web UI: Send helper (pairs it); no "ready" while unpaired.
- Screenshots decode in a child process (Linux crash loop).
- Installs no longer start queued uploads.
- Cross-drive moves check the copy before removing the original.
- Finished installs show as done after a reload; engine-unreachable
  explained with Restart engine; Health starts the installer.
- Dev: make run-client renders on macOS 27.

Better: "declined" installs explain Sony's code and how far it got;
0x80B21104 explained; Connection failures readable; keep-awake for all
installs; Games cards. Translations, FAQ, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM5Gzyt1uxKyJiWZAgd1w9
2026-10-08 09:31:54 -07:00

336 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Post-`tauri android init` patches for the Android build. `gen/android`
# is gitignored and regenerated by `tauri android init`, so these MUST
# run AFTER init and BEFORE `tauri android build`. Idempotent. Invoked
# from publish.yml, engine-ci.yml, and the Makefile `android-init` target.
#
# Patches (each idempotent):
#
# 1) Loopback cleartext for the in-process engine.
# The mobile build links ps5upload-engine in-process and binds it to
# http://127.0.0.1:19113; the renderer fetches that URL like the
# desktop sidecar (engine_mobile.rs + client/src/api/engine.ts). Tauri
# sets android:usesCleartextTraffic=true for debug but FALSE for
# release, so a release APK silently blocks the loopback fetch and the
# UI shows the engine "red". We add a network-security-config that
# permits cleartext ONLY for 127.0.0.1 / localhost; everything else
# stays cleartext-forbidden. networkSecurityConfig takes precedence
# over the usesCleartextTraffic flag for those domains.
#
# 2) Explicit backup rules so user settings survive an uninstall/reinstall
# (cloud backup / device transfer). Settings already persist across
# normal restarts (app-private config dir) and in-place updates (now
# that releases share a stable signing key), but allowBackup + explicit
# include rules let Android restore them on a fresh reinstall too.
#
# Plus <uses-permission> injection: all-files storage access for the
# in-app picker, and Wi-Fi multicast/state permissions so mDNS console
# discovery can receive 224.0.0.251:5353 (see the perms list below).
# The MulticastLock itself is acquired via JNI at runtime — see
# local_fs.rs::acquire_multicast_lock, called from discover_ps5.
#
# 3) Pin WebView textZoom to 100% in MainActivity. Android WebView scales
# every font by the device's system **Font size** accessibility setting,
# so a user with a larger-than-default font sees text blown up — dense rows
# (Library, Upload) clip to the first few characters. Overriding the
# onWebViewCreate hook (exposed by WryActivity) to set textZoom = 100 makes
# the app render at its designed size regardless of that setting.
#
# NOTE: textZoom only counters **Font size**, NOT **Display size**. Display
# size changes the WebView's density and scales the WHOLE viewport (icons,
# padding, controls — not just text), which textZoom can't touch. That case
# is handled in-app by the user-adjustable "Text size" setting
# (client/src/state/uiScale.ts), which rescales the rem root and so resizes
# the entire UI. The two fixes are complementary.
set -euo pipefail
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
gen="$root/client/src-tauri/gen/android/app/src/main"
manifest="$gen/AndroidManifest.xml"
xmldir="$gen/res/xml"
if [ ! -f "$manifest" ]; then
echo "::error::AndroidManifest.xml not found at $manifest — run 'tauri android init' first" >&2
exit 1
fi
mkdir -p "$xmldir"
# --- (1) loopback-only cleartext --------------------------------------
cat >"$xmldir/network_security_config.xml" <<'XML'
<?xml version="1.0" encoding="utf-8"?>
<!-- Loopback-only cleartext: the in-process ps5upload-engine binds
127.0.0.1:19113 and the WebView fetches it over plain HTTP. All
other traffic stays cleartext-forbidden. -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
XML
echo "wrote $xmldir/network_security_config.xml"
# --- (2) backup rules -------------------------------------------------
# Android 12+ uses dataExtractionRules; older uses fullBackupContent.
# Include the whole app files domain (where settings.json lives) for both
# cloud backup and device-to-device transfer.
cat >"$xmldir/data_extraction_rules.xml" <<'XML'
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="." />
</cloud-backup>
<device-transfer>
<include domain="file" path="." />
</device-transfer>
</data-extraction-rules>
XML
echo "wrote $xmldir/data_extraction_rules.xml"
cat >"$xmldir/backup_rules.xml" <<'XML'
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="file" path="." />
</full-backup-content>
XML
echo "wrote $xmldir/backup_rules.xml"
# --- patch <application> attributes, idempotently ---------------------
python3 - "$manifest" <<'PY'
import re, sys
path = sys.argv[1]
src = open(path, encoding="utf-8").read()
wanted = {
"android:networkSecurityConfig": "@xml/network_security_config",
"android:allowBackup": "true",
"android:fullBackupContent": "@xml/backup_rules",
"android:dataExtractionRules": "@xml/data_extraction_rules",
}
inject = "".join(
f' {k}="{v}"' for k, v in wanted.items() if k not in src
)
if inject:
new = re.sub(r"(<application\b)", r"\1" + inject, src, count=1)
if new == src:
sys.exit("::error::could not find <application> tag to patch")
src = new
print("patched <application> with:" + inject)
else:
print("<application> already patched — skipping")
# All-files storage access so the in-app file/folder picker can read
# arbitrary game folders / .zips under /storage (the engine walks real FS
# paths; SAF content:// URIs are unusable). MANAGE_EXTERNAL_STORAGE is the
# API 30+ permission; the legacy READ/WRITE_EXTERNAL_STORAGE (maxSdk 29)
# cover older devices. Inserted before </application>'s closing, just
# after the opening <application ...> line, idempotently.
perms = [
'<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />',
'<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />',
'<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />',
# mDNS console discovery: receiving multicast (224.0.0.251:5353)
# requires CHANGE_WIFI_MULTICAST_STATE; without it the Wi-Fi stack
# filters multicast frames and discovery silently finds nothing.
# ACCESS_NETWORK_STATE / ACCESS_WIFI_STATE let the engine pick the
# right interface. The MulticastLock is acquired at runtime via JNI
# (see local_fs.rs::acquire_multicast_lock, called from discover_ps5).
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />',
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />',
'<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />',
]
missing = [p for p in perms if 'android.permission.' + p.split('android.permission.')[1].split('"')[0] not in src]
if missing:
block = "\n " + "\n ".join(missing)
new = re.sub(r"(<manifest\b[^>]*>)", r"\1" + block, src, count=1)
if new == src:
sys.exit("::error::could not find <manifest> tag to add permissions")
src = new
print("added permissions:", ", ".join(p.split('"')[1] for p in missing))
else:
print("permissions already present — skipping")
open(path, "w", encoding="utf-8").write(src)
PY
# --- (3) pin WebView textZoom in MainActivity ------------------------
# MainActivity.kt is the user-owned activity (not under generated/); we add
# an onWebViewCreate override that pins textZoom so the device font-scale
# doesn't enlarge the UI. Idempotent: skipped if the override already exists.
mainactivity="$(find "$gen/java" -name MainActivity.kt -print -quit 2>/dev/null || true)"
if [ -z "$mainactivity" ]; then
echo "::warning::MainActivity.kt not found under $gen/java — skipping textZoom patch" >&2
else
python3 - "$mainactivity" <<'PY'
import sys
path = sys.argv[1]
src = open(path, encoding="utf-8").read()
if "onWebViewCreate" in src:
print("MainActivity.kt already has onWebViewCreate — skipping")
sys.exit(0)
method = '''
// Pin WebView text zoom to 100% so the UI renders at its designed 18px base
// regardless of the device's system Font size / Display size accessibility
// setting (Android WebView otherwise multiplies every font by it, which on a
// larger-than-default font overflows the dense rem-based layout and clips
// e.g. Library/Upload rows to "PPS..."). textZoom affects only text size,
// not pinch-zoom.
override fun onWebViewCreate(webView: android.webkit.WebView) {
webView.settings.textZoom = 100
super.onWebViewCreate(webView)
}
'''
idx = src.rstrip().rfind("}")
if idx == -1:
sys.exit("::error::could not find class closing brace in MainActivity.kt")
src = src[:idx] + method + src[idx:]
open(path, "w", encoding="utf-8").write(src)
print("patched MainActivity.kt with onWebViewCreate textZoom=100")
PY
fi
# --- (4) hand the page the navigation-bar inset ----------------------
# The app draws edge-to-edge (enableEdgeToEdge, targetSdk 36), and the CSS pads
# the bottom nav with env(safe-area-inset-bottom). Android WebView does not
# report the navigation bar there: measured in the app on the emulator
# (Chrome 134), safe-area-inset-top was 49px but safe-area-inset-bottom was
# 0px, so the bottom tab bar sat under the gesture bar and its labels were
# covered. Expose the real inset through a JS interface the page reads.
#
# Never install an insets listener on the WebView: it replaces the WebView's
# own, and that is what computes the (correct) top inset. Idempotent via the
# interface name.
if [ -n "$mainactivity" ]; then
python3 - "$mainactivity" <<'PY'
import sys
path = sys.argv[1]
src = open(path, encoding="utf-8").read()
if "PS5UploadInsets" in src:
print("MainActivity.kt already exposes PS5UploadInsets — skipping")
sys.exit(0)
anchor = " super.onWebViewCreate(webView)\n"
if anchor not in src:
sys.exit("::error::onWebViewCreate not found in MainActivity.kt (step 3 must run first)")
hook = anchor + ''' // WebView reports env(safe-area-inset-bottom) as 0 for the navigation
// bar (measured: top 49px, bottom 0px), so give the page the real value.
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun navBottomPx(): Int = navInsetBottomPx
}, "PS5UploadInsets")
// Read the insets from the window root on each layout rather than
// installing an insets listener on the WebView. A listener REPLACES the
// WebView's own, which is what computes its safe-area-inset-top: the first
// version of this did that and the header slid under the status bar
// (measured top inset 49px -> 0px), even with the default handling
// re-invoked. A layout observer changes nothing about inset dispatch.
val root = window.decorView
root.viewTreeObserver.addOnGlobalLayoutListener {
val insets = androidx.core.view.ViewCompat.getRootWindowInsets(root)
?: return@addOnGlobalLayoutListener
val bottom = insets
.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())
.bottom
if (bottom != navInsetBottomPx) {
navInsetBottomPx = bottom
webView.evaluateJavascript(
"window.dispatchEvent(new Event('ps5upload-insets'))", null)
}
}
'''
src = src.replace(anchor, hook, 1)
field = '''
// Navigation-bar height in physical pixels, read by the page through
// PS5UploadInsets. Written on the UI thread, read on WebView's binder thread.
@Volatile private var navInsetBottomPx: Int = 0
'''
brace = src.index("{", src.index("class MainActivity"))
src = src[:brace + 1] + field + src[brace + 1:]
open(path, "w", encoding="utf-8").write(src)
print("patched MainActivity.kt to expose the navigation-bar inset")
PY
fi
# --- (5) Android 17 local network permission -------------------------
# From targetSdk 37 (which `tauri android init` now generates), Android 17
# blocks an app's connections to devices on the local network unless it holds
# ACCESS_LOCAL_NETWORK, a runtime permission. Without it every connection to the
# PS5 failed and the app said "Port 9021 is not open" while the phone's own
# shell reached the port (6.x on Android; 5.41.0 targeted 36 and was not
# affected). Declare it, ask for it at launch, and let the page check it and ask
# again through PS5UploadNet, so the Connection screen can name the real cause.
python3 - "$manifest" <<'PY'
import re, sys
path = sys.argv[1]
src = open(path, encoding="utf-8").read()
perm = '<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />'
if "android.permission.ACCESS_LOCAL_NETWORK" in src:
print("ACCESS_LOCAL_NETWORK already declared — skipping")
else:
new = re.sub(r"(<manifest\b[^>]*>)", r"\1\n " + perm, src, count=1)
if new == src:
sys.exit("::error::could not find <manifest> tag to add ACCESS_LOCAL_NETWORK")
open(path, "w", encoding="utf-8").write(new)
print("declared ACCESS_LOCAL_NETWORK")
PY
if [ -n "$mainactivity" ]; then
python3 - "$mainactivity" <<'PY'
import sys
path = sys.argv[1]
src = open(path, encoding="utf-8").read()
if "PS5UploadNet" in src:
print("MainActivity.kt already exposes PS5UploadNet — skipping")
sys.exit(0)
create_anchor = " super.onCreate(savedInstanceState)\n"
webview_anchor = " super.onWebViewCreate(webView)\n"
if create_anchor not in src or webview_anchor not in src:
sys.exit("::error::onCreate/onWebViewCreate not found in MainActivity.kt")
src = src.replace(create_anchor, create_anchor + " requestLocalNetwork()\n", 1)
src = src.replace(webview_anchor, webview_anchor + ''' // The page asks whether it may reach the local network, and asks again
// (or opens the app's settings once Android stops showing the prompt).
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun granted(): Boolean = localNetworkGranted()
@android.webkit.JavascriptInterface
fun request() { runOnUiThread { requestLocalNetwork(openSettings = true) } }
}, "PS5UploadNet")
''', 1)
methods = '''
// Android 17+ (targetSdk 37): connections to the PS5 need ACCESS_LOCAL_NETWORK.
private fun localNetworkGranted(): Boolean =
android.os.Build.VERSION.SDK_INT < 37 ||
checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") ==
android.content.pm.PackageManager.PERMISSION_GRANTED
private fun requestLocalNetwork(openSettings: Boolean = false) {
if (localNetworkGranted()) return
val perm = "android.permission.ACCESS_LOCAL_NETWORK"
// After a second "Don't allow" Android no longer shows the prompt, so a
// request from the page opens the app's settings instead.
if (openSettings && localNetworkAsked && !shouldShowRequestPermissionRationale(perm)) {
startActivity(
android.content.Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", packageName, null),
),
)
return
}
localNetworkAsked = true
requestPermissions(arrayOf(perm), 4217)
}
private var localNetworkAsked = false
'''
idx = src.rstrip().rfind("}")
src = src[:idx] + methods + src[idx:]
open(path, "w", encoding="utf-8").write(src)
print("patched MainActivity.kt to request ACCESS_LOCAL_NETWORK (PS5UploadNet)")
PY
fi
echo "android post-init patches applied"