diff --git a/client/src/App.tsx b/client/src/App.tsx
index 59e15eef..b5446bf4 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -6,6 +6,7 @@ import {
useConnectionStore,
} from "./state/connection";
import { Navigate, Route, Routes, useLocation, type Location } from "react-router";
+import { payloadCardLink } from "./screens/Payloads/payloadTabs";
import { lazyWithReload } from "./lib/lazyWithReload";
import AppShell from "./layout/AppShell";
@@ -396,16 +397,25 @@ function AppRoutes({ location }: { location: Location }) {
}>
}
/>
- } />
- } />
- } />
+ {/* Integrations were tabs (and before that screens); they are cards on "On your
+ PS5" now, and Ports sits with Processes. Old links open the card. */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{/* The wizard's whole point is step 2: download the payload ELFs to
this machine and send them to the console over a raw socket.
Neither is possible from a browser (the Payloads screen there has
diff --git a/client/src/api/hearbridge.test.ts b/client/src/api/hearbridge.test.ts
new file mode 100644
index 00000000..81c95e4f
--- /dev/null
+++ b/client/src/api/hearbridge.test.ts
@@ -0,0 +1,104 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { hbAct, hbOverview, parseHbStatus, parseSaved, parseScanned } from "./hearbridge";
+
+// A /api/status body as HearBridge's status_json writes it (token already removed by the
+// engine), trimmed to the fields the app reads plus a few it ignores.
+const FULL = {
+ version: "1.9.0",
+ connected: 1,
+ state: "streaming",
+ device: "WH-1000XM4",
+ gain_pct: 140,
+ muted: 0,
+ codec: "SBC",
+ latency: { target_ms: 120, estimate_ms: 185, capture_ms: 10 },
+ lat_extra: { backoff_ms: 0, normal_ms: 120, auto: 1, auto_ms: 110 },
+ battery: { status: "ok", level: 3, pct: 70, src: "hf", none: 0 },
+ link: { score: 88, rssi: -54, lq: 255, drops_min: 0 },
+ night: { on: 1, db10: 60 },
+ game: { avail: 1, id: "PPSA01234", name: "Astro Bot", profile: 1, active: 1 },
+ events: ["Connected", "Streaming"],
+ chip: { vid: "0e8d", vendor: "MediaTek" },
+};
+
+describe("HearBridge status", () => {
+ it("reads every field the card and panel show", () => {
+ expect(parseHbStatus(FULL)).toEqual({
+ version: "1.9.0",
+ state: "streaming",
+ device: "WH-1000XM4",
+ gainPct: 140,
+ muted: false,
+ codec: "SBC",
+ latencyTargetMs: 120,
+ latencyEstimateMs: 185,
+ latencyAuto: true,
+ batteryPct: 70,
+ linkScore: 88,
+ night: true,
+ gameName: "Astro Bot",
+ gameProfile: true,
+ events: ["Connected", "Streaming"],
+ });
+ });
+
+ it("leaves out what an older HearBridge doesn't send, never guessing", () => {
+ const s = parseHbStatus({ version: "0.3", state: "disconnected" });
+ expect(s.gainPct).toBeNull();
+ expect(s.muted).toBeNull();
+ expect(s.latencyAuto).toBeNull();
+ expect(s.batteryPct).toBeNull();
+ expect(s.events).toEqual([]);
+ // -1 is HearBridge's "unknown".
+ expect(parseHbStatus({ battery: { pct: -1 }, link: { score: -1 } }).batteryPct).toBeNull();
+ expect(parseHbStatus(null).version).toBe("");
+ expect(parseHbStatus({ events: [1, "x", null] }).events).toEqual(["x"]);
+ });
+
+ it("reads saved and scanned headsets, dropping malformed rows", () => {
+ expect(
+ parseSaved({ devices: [{ addr: "AA:BB:CC:DD:EE:FF", name: "Buds", kind: "audio", current: 1 }, { name: "no addr" }] }),
+ ).toEqual([{ addr: "AA:BB:CC:DD:EE:FF", name: "Buds", kind: "audio", current: true }]);
+ expect(
+ parseScanned({ version: 1, devices: [{ index: 2, addr: "11:22:33:44:55:66", name: "Pods", rssi: null }, { addr: "x" }] }),
+ ).toEqual([{ index: 2, addr: "11:22:33:44:55:66", name: "Pods", kind: "", rssi: null }]);
+ expect(parseSaved("garbage")).toEqual([]);
+ });
+});
+
+describe("HearBridge through the engine", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("reports a console without HearBridge as absent, not an error", async () => {
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true, present: false, error: "no answer" })));
+ vi.stubGlobal("fetch", fetchMock);
+ await expect(hbOverview("192.168.1.5")).resolves.toEqual({ present: false, error: "no answer" });
+ expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("/api/ps5/hearbridge/status?host=192.168.1.5");
+ });
+
+ it("posts one change with the console and returns the new status", async () => {
+ const fetchMock = vi.fn(
+ async () => new Response(JSON.stringify({ ok: true, result: { ...FULL, gain_pct: 200 } })),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const s = await hbAct("192.168.1.5:9120", { action: "volume", pct: 200 });
+ expect(s?.gainPct).toBe(200);
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toContain("/api/ps5/hearbridge/action");
+ expect(JSON.parse(String(init.body))).toEqual({ host: "192.168.1.5", action: "volume", pct: 200 });
+ });
+
+ it("returns no status for an action HearBridge answers with {ok:1}", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true, result: { ok: 1 } }))));
+ await expect(hbAct("10.0.0.2", { action: "reconnect" })).resolves.toBeNull();
+ });
+
+ it("surfaces the engine's error", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(JSON.stringify({ error: "HearBridge refused" }), { status: 502 })),
+ );
+ await expect(hbAct("10.0.0.2", { action: "stop" })).rejects.toThrow("HearBridge refused");
+ });
+});
diff --git a/client/src/api/hearbridge.ts b/client/src/api/hearbridge.ts
new file mode 100644
index 00000000..fbe04075
--- /dev/null
+++ b/client/src/api/hearbridge.ts
@@ -0,0 +1,168 @@
+// The console's HearBridge (Bluetooth headset audio; its own web page on :8090..8095)
+// through the engine's /api/ps5/hearbridge routes. HearBridge sends no CORS headers, so
+// the app never calls it directly; the engine finds its port, holds its token and passes
+// only the changes below. Plain fetches against the engine origin, so the desktop app, the
+// browser build and the Docker build take the same path.
+
+import { getEngineUrl } from "../state/engine";
+import { hostOf } from "../lib/addr";
+
+/** HearBridge's status, read tolerantly: an older or newer HearBridge may leave any field
+ * out, and a missing field is null rather than a guess. */
+export interface HbStatus {
+ version: string;
+ /** "streaming", "connecting", "scanning", "reconnecting", "disconnected", "error", … */
+ state: string;
+ /** The headset's name, when one is connected or being connected. */
+ device: string;
+ /** Audio volume boost, 0..500 %. */
+ gainPct: number | null;
+ muted: boolean | null;
+ codec: string;
+ latencyTargetMs: number | null;
+ latencyEstimateMs: number | null;
+ latencyAuto: boolean | null;
+ batteryPct: number | null;
+ /** Link quality 0..100. */
+ linkScore: number | null;
+ night: boolean | null;
+ /** The running game HearBridge has a profile for, if any. */
+ gameName: string;
+ gameProfile: boolean;
+ events: string[];
+}
+
+export interface HbSavedDevice {
+ addr: string;
+ name: string;
+ kind: string;
+ current: boolean;
+}
+
+export interface HbScannedDevice {
+ index: number;
+ addr: string;
+ name: string;
+ kind: string;
+ rssi: number | null;
+}
+
+export type HbOverview =
+ | { present: true; port: number; status: HbStatus }
+ | { present: false; error: string };
+
+type Obj = Record;
+const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {});
+const str = (v: unknown): string => (typeof v === "string" ? v : "");
+const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
+const flag = (v: unknown): boolean | null =>
+ typeof v === "boolean" ? v : typeof v === "number" ? v !== 0 : null;
+
+export function parseHbStatus(raw: unknown): HbStatus {
+ const s = obj(raw);
+ const latency = obj(s.latency);
+ const battery = obj(s.battery);
+ const link = obj(s.link);
+ const game = obj(s.game);
+ const pct = num(battery.pct);
+ const score = num(link.score);
+ return {
+ version: str(s.version),
+ state: str(s.state),
+ device: str(s.device),
+ gainPct: num(s.gain_pct),
+ muted: flag(s.muted),
+ codec: str(s.codec),
+ latencyTargetMs: num(latency.target_ms),
+ latencyEstimateMs: num(latency.estimate_ms),
+ latencyAuto: flag(obj(s.lat_extra).auto),
+ // -1 is HearBridge's "not known".
+ batteryPct: pct !== null && pct >= 0 ? pct : null,
+ linkScore: score !== null && score >= 0 ? score : null,
+ night: flag(obj(s.night).on),
+ gameName: str(game.name),
+ gameProfile: flag(game.profile) === true,
+ events: Array.isArray(s.events) ? s.events.filter((e): e is string => typeof e === "string") : [],
+ };
+}
+
+export function parseSaved(raw: unknown): HbSavedDevice[] {
+ const list = obj(raw).devices;
+ if (!Array.isArray(list)) return [];
+ return list
+ .map(obj)
+ .filter((d) => str(d.addr))
+ .map((d) => ({ addr: str(d.addr), name: str(d.name), kind: str(d.kind), current: flag(d.current) === true }));
+}
+
+export function parseScanned(raw: unknown): HbScannedDevice[] {
+ const list = obj(raw).devices;
+ if (!Array.isArray(list)) return [];
+ return list
+ .map(obj)
+ .filter((d) => num(d.index) !== null)
+ .map((d) => ({
+ index: num(d.index) as number,
+ addr: str(d.addr),
+ name: str(d.name),
+ kind: str(d.kind),
+ rssi: num(d.rssi),
+ }));
+}
+
+async function read(res: Response): Promise {
+ const body = obj(await res.json().catch(() => ({})));
+ if (!res.ok) throw new Error(str(body.error) || `request failed (${res.status})`);
+ return body;
+}
+
+const q = (host: string) => `host=${encodeURIComponent(hostOf(host))}`;
+
+/** Whether HearBridge answers on `host`, on which port, and its status. */
+export async function hbOverview(host: string): Promise {
+ const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/status?${q(host)}`, {
+ signal: AbortSignal.timeout(20_000),
+ });
+ const w = await read(res);
+ if (!w.present) return { present: false, error: str(w.error) || "not running" };
+ return { present: true, port: num(w.port) ?? 8090, status: parseHbStatus(w.status) };
+}
+
+export async function hbSaved(host: string): Promise {
+ const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/saved?${q(host)}`, {
+ signal: AbortSignal.timeout(20_000),
+ });
+ return parseSaved(await read(res));
+}
+
+export async function hbScanned(host: string): Promise {
+ const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/devices?${q(host)}`, {
+ signal: AbortSignal.timeout(20_000),
+ });
+ return parseScanned(await read(res));
+}
+
+export type HbAction =
+ | { action: "volume"; pct: number }
+ | { action: "mute"; on: boolean }
+ | { action: "night"; on: boolean }
+ | { action: "latency"; auto: boolean }
+ | { action: "latency"; ms: number }
+ | { action: "select"; addr: string }
+ | { action: "select"; index: number }
+ | { action: "reconnect" }
+ | { action: "scan" }
+ | { action: "stop" };
+
+/** Make one change. Returns HearBridge's new status when its answer carries one. */
+export async function hbAct(host: string, a: HbAction): Promise {
+ const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/action`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ host: hostOf(host), ...a }),
+ signal: AbortSignal.timeout(20_000),
+ });
+ const w = await read(res);
+ const result = obj(w.result);
+ return typeof result.version === "string" ? parseHbStatus(result) : null;
+}
diff --git a/client/src/components/CommandPalette.tsx b/client/src/components/CommandPalette.tsx
index 7c430c38..43609bfd 100644
--- a/client/src/components/CommandPalette.tsx
+++ b/client/src/components/CommandPalette.tsx
@@ -37,7 +37,8 @@ const NAV_KEYWORDS: Record = {
"/volumes": ["disk", "drives"],
"/files": ["browse", "file system"],
"/console": ["hardware", "temps", "power"],
- "/payloads": ["homebrew catalog", "payload library", "kstuff", "shadowmount", "etahen"],
+ "/payloads": ["homebrew catalog", "payload library", "kstuff", "shadowmount", "etahen", "nanodns", "hearbridge", "payload manager"],
+ "/processes": ["ports", "task manager", "kill"],
"/tasks": ["transfer log", "jobs"],
"/bug-report": ["report", "problem", "issue", "discord", "github"],
"/shell": ["terminal"],
diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts
index ac80b643..3d70f8d2 100644
--- a/client/src/i18n/locales/ar.ts
+++ b/client/src/i18n/locales/ar.ts
@@ -771,15 +771,12 @@ payloads_download: "تنزيل {version}",
payloads_load_failed: "تعذّر تحميل الفهرس",
payloads_loading: "جارٍ تحميل الفهرس…",
payloads_no_host: "لم يُحدَّد مضيف PS5. اتصل أولًا من شاشة الاتصال.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "الحمولات التي يحتفظ بها Payload Manager (itsPLK) على PS5: شغّلها أو احذفها، واختر أيها يُحمّل عند الإقلاع وبأي ترتيب. احفظ حمولة من الكتالوج هناك من تبويب الكتالوج.",
payloads_pm_store: "حفظ في Payload Manager",
payloads_pm_store_tooltip: "يبقي هذا الملف على PS5 داخل Payload Manager، لتشغيله من هناك أو تحميله عند الإقلاع.",
payloads_pm_store_confirm_title: "استبدال مجلد \"{folder}\" في Payload Manager؟",
payloads_pm_store_confirm_same: "لدى Payload Manager {name} بالفعل. الحفظ يستبدله ويحذف أي ملف آخر في ذلك المجلد.",
payloads_pm_store_confirm_other: "لدى Payload Manager مجلد \"{folder}\" بالفعل. الحفظ فيه يحذف الملفات الأخرى هناك (نسخة أقدم عادةً).",
payloads_pm_stored_title: "تم حفظ {name} في Payload Manager",
-payloads_pm_stored_body: "موجود في مجلد \"{folder}\" في Payload Manager. تبويب Payload Manager يشغّله أو يحمّله عند الإقلاع.",
payloads_pm_store_failed: "تعذر حفظ {id} في Payload Manager",
pm_launched: "تم تشغيل {name}",
pm_launched_body: "تحقق من الإشعار على PS5.",
@@ -2478,10 +2475,6 @@ helper_mismatch_go: "إعادة تحميل المساعد",
"fpkg.checkDrm": "أن تكون قيمة DRM للحزمة قياسية. القيمة المجانية أو القابلة للترقية تجعل الجهاز يقفل اللعبة؛ تُعاد كتابتها داخل الحزمة فقط، ولا يُمسّ ملفك أبدًا.",
"payloads_essentials_title": "موصى بها مع ps5upload",
"payloads_essentials_body": "إذا كنت تُعدّ جهازًا لأول مرة، فهذه الثلاث تغطي معظم ما يتوقعه ps5upload: kstuff-lite (رقع النواة التي يعتمد عليها معظم البرامج المنزلية)، وShadowMount+ (ربط صور الألعاب من التخزين)، وnanoDNS (إبعاد الجهاز عن مسار تحديثات Sony). كل ما عدا ذلك في هذه القائمة اختياري.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "افحص حالة ShadowMount+ وصور الألعاب المربوطة والإعدادات والتشخيص على جهاز PS5 المحدد.",
-"payloads_description_nanodns": "اضبط حمولة nanoDNS، وتحقق من الإصدار قيد التشغيل، وطبّق عمليات ترحيل آمنة للإعدادات.",
"navigation_history": "عرض السجل",
"navigation_back": "رجوع",
"navigation_forward": "تقدّم",
@@ -3932,6 +3925,70 @@ upload_phase_settling_rate: "ملف/ث",
upload_status_resuming: "جارٍ الاستئناف: {done} من {total} ملف موجودة بالفعل ({have})؛ {send} للإرسال.",
volumes_kept_by_console: "{kept} يحتفظ بها الجهاز لاستخدامه الخاص",
payloads_tab_ports: "المنافذ",
+processes_and_ports: "العمليات والمنافذ",
+payloads_tab_ps5: "على جهاز PS5",
+payloads_description_ps5: "الحمولات التي تعمل أو المثبتة على PS5 المحدد. افتح صفحة الويب الخاصة بالحمولة أو حدّثها أو أوقفها أو أدرها.",
+payloads_pm_stored_body_v2: "إنه في مجلد \"{folder}\" الخاص بـ Payload Manager. يشغّله Payload Manager في تبويب «على جهاز PS5» أو يحمّله عند الإقلاع.",
+hub_checking: "جارٍ التحقق مما على جهاز PS5 هذا…",
+hub_count: "{n} على جهاز PS5 هذا",
+hub_error: "تعذّرت قراءة ما يعمل على PS5",
+hub_empty_title: "لم يُعثر على شيء على جهاز PS5 هذا",
+hub_empty_body: "لا توجد حمولة من الكتالوج تعمل أو مثبتة على هذا الجهاز. تجدها في الكتالوج، ويحمّل «إرسال» أي ملف حمولة.",
+hub_empty_body_web: "لا توجد حمولة معروفة تعمل أو مثبتة على هذا الجهاز.",
+hub_state_running: "يعمل",
+hub_state_installed: "مثبّت",
+hub_state_unknown: "غير موجود",
+hub_running_detail: "{name} · المعرّف {pid} · {mb} م.ب",
+hub_installed_detail: "موجود على الجهاز، ولا يعمل.",
+hub_unknown_detail: "غير موجود على جهاز PS5 هذا.",
+hub_not_catalogued: "غير موجود في الكتالوج",
+hub_open_page: "فتح صفحة الويب",
+hub_update: "التحديث إلى {version}",
+hub_update_confirm_title: "تحديث {name} إلى {version}؟",
+hub_update_confirm_body: "ينزّل {version} ويرسله إلى PS5. تحقّق من الإشعار على PS5.",
+hub_updated: "تم إرسال {name} {version}",
+hub_update_failed: "تعذّر تحديث {name}",
+hub_stop: "إيقاف",
+hub_manage: "إدارة",
+hub_pm_summary: "الحمولات المحفوظة: {n}",
+hub_smp_summary: "صور الألعاب المركّبة: {n}",
+hb_no_answer: "لم يستجب HearBridge",
+hb_checking: "جارٍ قراءة حالة HearBridge…",
+hb_state_streaming: "يبث الصوت",
+hb_state_connecting: "جارٍ الاتصال",
+hb_state_reconnecting: "جارٍ إعادة الاتصال",
+hb_state_scanning: "جارٍ البحث",
+hb_state_waiting: "في انتظار سماعة",
+hb_state_error: "خطأ",
+hb_battery: "البطارية {pct}%",
+hb_link: "جودة الاتصال {score}%",
+hb_latency: "تأخير {ms} مللي ثانية",
+hb_muted: "مكتوم",
+hb_volume: "مستوى الصوت {pct}%",
+hb_game: "الملف الشخصي: {name}",
+hb_scan_confirm_title: "البحث عن سماعات؟",
+hb_scan_confirm_body: "يوقف البحث صوت السماعة أثناء تشغيله (نحو 12 ثانية).",
+hb_scan: "البحث عن سماعات",
+hb_open_page: "فتح صفحة HearBridge",
+hb_action_failed: "لم يقبل HearBridge هذا التغيير",
+hb_volume_label: "مستوى الصوت",
+hb_mute: "كتم الصوت",
+hb_latency_label: "زمن التأخير",
+hb_latency_auto: "تلقائي",
+hb_night: "الوضع الليلي",
+hb_night_hint: "انفجارات أهدأ وأصوات أوضح.",
+hb_headsets: "السماعات",
+hb_reconnect: "إعادة الاتصال",
+hb_current: "قيد الاستخدام",
+hb_switch: "التبديل إلى هذه",
+hb_saved_empty: "لا توجد سماعات محفوظة بعد. ابحث عن واحدة أو أقرنها من صفحة HearBridge.",
+hb_scan_found: "عُثر عليها في آخر بحث:",
+hb_scan_empty: "لم يُعثر على شيء. ضع السماعة في وضع الإقران وابحث مرة أخرى.",
+hb_connect: "اتصال",
+hb_events: "الأحداث الأخيرة",
+hb_page_hint: "إقران سماعة جديدة والمعادل والنسخ الاحتياطية وكل الإعدادات الأخرى موجودة في صفحة HearBridge نفسها.",
+hb_stop_title: "إيقاف HearBridge؟",
+hb_stop_body: "يُغلق HearBridge وتنقطع السماعة. أرسله مرة أخرى لتشغيله.",
payloads_description_ports: "المنافذ المستخدمة على PS5 وما يستمع عادةً على كل منها، لتعرف أي حمولة تعمل على أي منفذ. افتح التي لها صفحة ويب في متصفحك.",
"ports.scan": "افحص مجددًا",
"ports.scanning": "جارٍ فحص كل المنافذ على PS5…",
diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts
index 1d1609e7..5763b12c 100644
--- a/client/src/i18n/locales/bn.ts
+++ b/client/src/i18n/locales/bn.ts
@@ -771,15 +771,12 @@ payloads_download: "{version} ডাউনলোড করুন",
payloads_load_failed: "ক্যাটালগ লোড করা যায়নি",
payloads_loading: "ক্যাটালগ লোড হচ্ছে…",
payloads_no_host: "কোনো PS5 হোস্ট নির্ধারিত নেই। আগে সংযোগ স্ক্রিনে গিয়ে যুক্ত হোন।",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager (itsPLK) PS5-এ যে পেলোডগুলো রাখে: চালান বা মুছুন, আর বেছে নিন বুটে কোনগুলো কোন ক্রমে লোড হবে। ক্যাটালগের পেলোড ক্যাটালগ ট্যাব থেকে সেখানে সংরক্ষণ করুন।",
payloads_pm_store: "Payload Manager-এ সংরক্ষণ",
payloads_pm_store_tooltip: "এই ফাইলটি PS5-এ Payload Manager-এ রাখে, যাতে সেখান থেকে চালানো বা বুটে লোড করা যায়।",
payloads_pm_store_confirm_title: "Payload Manager-এর \"{folder}\" ফোল্ডার প্রতিস্থাপন করবেন?",
payloads_pm_store_confirm_same: "Payload Manager-এ {name} আগে থেকেই আছে। সংরক্ষণ করলে সেটি প্রতিস্থাপিত হবে এবং ওই ফোল্ডারের অন্য ফাইল মুছে যাবে।",
payloads_pm_store_confirm_other: "Payload Manager-এ \"{folder}\" ফোল্ডার আগে থেকেই আছে। সেখানে সংরক্ষণ করলে অন্য ফাইলগুলো (সাধারণত পুরোনো সংস্করণ) মুছে যাবে।",
payloads_pm_stored_title: "{name} Payload Manager-এ সংরক্ষিত হয়েছে",
-payloads_pm_stored_body: "এটি Payload Manager-এর \"{folder}\" ফোল্ডারে আছে। Payload Manager ট্যাব এটি চালায় বা বুটে লোড করে।",
payloads_pm_store_failed: "{id} Payload Manager-এ সংরক্ষণ করা যায়নি",
pm_launched: "{name} চালু হয়েছে",
pm_launched_body: "PS5-এ বিজ্ঞপ্তি দেখুন।",
@@ -2478,10 +2475,6 @@ helper_mismatch_go: "হেল্পার লোড করুন",
"fpkg.checkDrm": "প্যাকেজের DRM মান স্ট্যান্ডার্ড কিনা। বিনামূল্যের বা আপগ্রেডযোগ্য মান থাকলে কনসোল টাইটেল লক করে; এটি কেবল প্যাকেজে পুনর্লিখিত হয়, আপনার নিজের ফাইল কখনো ছোঁয়া হয় না।",
"payloads_essentials_title": "ps5upload-এর সাথে প্রস্তাবিত",
"payloads_essentials_body": "প্রথমবার কনসোল সেট আপ করলে, এই তিনটি ps5upload যা আশা করে তার বেশিরভাগ পূরণ করে: kstuff-lite (বেশিরভাগ হোমব্রু যে কার্নেল প্যাচের ওপর নির্ভর করে), ShadowMount+ (স্টোরেজ থেকে গেম ইমেজ মাউন্ট) এবং nanoDNS (কনসোলকে Sony-র আপডেট পথ থেকে দূরে রাখে)। তালিকার বাকি সবকিছু ঐচ্ছিক।",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "নির্বাচিত PS5-এ ShadowMount+-এর অবস্থা, মাউন্ট করা গেম ইমেজ, কনফিগারেশন ও ডায়াগনস্টিক দেখুন।",
-"payloads_description_nanodns": "nanoDNS payload কনফিগার করুন, চলমান সংস্করণ যাচাই করুন, এবং নিরাপদ কনফিগ মাইগ্রেশন প্রয়োগ করুন।",
"navigation_history": "ইতিহাস দেখুন",
"navigation_back": "পিছনে",
"navigation_forward": "সামনে",
@@ -3932,6 +3925,70 @@ upload_phase_settling_rate: "ফাইল/সে",
upload_status_resuming: "আবার শুরু হচ্ছে: {total}টির মধ্যে {done}টি ফাইল আগে থেকেই আছে ({have}); {send} পাঠানো বাকি।",
volumes_kept_by_console: "{kept} কনসোল নিজের ব্যবহারের জন্য রেখেছে",
payloads_tab_ports: "পোর্ট",
+processes_and_ports: "প্রসেস ও পোর্ট",
+payloads_tab_ps5: "আপনার PS5-এ",
+payloads_description_ps5: "নির্বাচিত PS5-এ চলমান বা ইনস্টল করা পেলোডগুলো। কোনো পেলোডের ওয়েব পেজ খুলুন, আপডেট করুন, বন্ধ করুন বা পরিচালনা করুন।",
+payloads_pm_stored_body_v2: "এটি Payload Manager-এর \"{folder}\" ফোল্ডারে আছে। \"আপনার PS5-এ\" ট্যাবের Payload Manager এটি চালু করে বা বুটের সময় লোড করে।",
+hub_checking: "এই PS5-এ কী আছে দেখা হচ্ছে…",
+hub_count: "এই PS5-এ {n}টি",
+hub_error: "PS5-এ কী চলছে তা পড়া যায়নি",
+hub_empty_title: "এই PS5-এ কিছু পাওয়া যায়নি",
+hub_empty_body: "ক্যাটালগের কোনো পেলোড এই কনসোলে চলছে না বা ইনস্টল নেই। ক্যাটালগে সেগুলো আছে, আর Send যেকোনো পেলোড ফাইল লোড করে।",
+hub_empty_body_web: "এই কনসোলে কোনো পরিচিত পেলোড চলছে না বা ইনস্টল নেই।",
+hub_state_running: "চলছে",
+hub_state_installed: "ইনস্টল করা",
+hub_state_unknown: "পাওয়া যায়নি",
+hub_running_detail: "{name} · প্রসেস {pid} · {mb} MB",
+hub_installed_detail: "কনসোলে আছে, চলছে না।",
+hub_unknown_detail: "এই PS5-এ পাওয়া যায়নি।",
+hub_not_catalogued: "ক্যাটালগে নেই",
+hub_open_page: "ওয়েব পেজ খুলুন",
+hub_update: "{version}-এ আপডেট করুন",
+hub_update_confirm_title: "{name} কে {version}-এ আপডেট করবেন?",
+hub_update_confirm_body: "{version} ডাউনলোড করে PS5-এ পাঠায়। PS5-এ এর নোটিফিকেশন দেখুন।",
+hub_updated: "{name} {version} পাঠানো হয়েছে",
+hub_update_failed: "{name} আপডেট করা যায়নি",
+hub_stop: "বন্ধ করুন",
+hub_manage: "পরিচালনা",
+hub_pm_summary: "সংরক্ষিত পেলোড: {n}",
+hub_smp_summary: "মাউন্ট করা গেম ইমেজ: {n}",
+hb_no_answer: "HearBridge সাড়া দেয়নি",
+hb_checking: "HearBridge-এর অবস্থা পড়া হচ্ছে…",
+hb_state_streaming: "স্ট্রিম হচ্ছে",
+hb_state_connecting: "সংযোগ হচ্ছে",
+hb_state_reconnecting: "আবার সংযোগ হচ্ছে",
+hb_state_scanning: "স্ক্যান হচ্ছে",
+hb_state_waiting: "হেডসেটের অপেক্ষায়",
+hb_state_error: "ত্রুটি",
+hb_battery: "ব্যাটারি {pct}%",
+hb_link: "সংযোগ {score}%",
+hb_latency: "{ms} ms বিলম্ব",
+hb_muted: "মিউট",
+hb_volume: "ভলিউম {pct}%",
+hb_game: "প্রোফাইল: {name}",
+hb_scan_confirm_title: "হেডসেট খুঁজবেন?",
+hb_scan_confirm_body: "স্ক্যান চলার সময় হেডসেটের অডিও বন্ধ থাকে (প্রায় ১২ সেকেন্ড)।",
+hb_scan: "হেডসেট খুঁজুন",
+hb_open_page: "HearBridge পেজ খুলুন",
+hb_action_failed: "HearBridge এই পরিবর্তনটি নেয়নি",
+hb_volume_label: "ভলিউম",
+hb_mute: "মিউট",
+hb_latency_label: "লেটেন্সি",
+hb_latency_auto: "স্বয়ংক্রিয়",
+hb_night: "নাইট মোড",
+hb_night_hint: "বিস্ফোরণ কম জোরে, কণ্ঠস্বর আরও স্পষ্ট।",
+hb_headsets: "হেডসেট",
+hb_reconnect: "আবার সংযোগ",
+hb_current: "ব্যবহৃত হচ্ছে",
+hb_switch: "এটিতে বদলান",
+hb_saved_empty: "এখনও কোনো সংরক্ষিত হেডসেট নেই। একটি খুঁজুন, অথবা HearBridge-এর পেজে পেয়ার করুন।",
+hb_scan_found: "শেষ স্ক্যানে পাওয়া গেছে:",
+hb_scan_empty: "কিছু পাওয়া যায়নি। হেডসেটটি পেয়ারিং মোডে রেখে আবার স্ক্যান করুন।",
+hb_connect: "সংযোগ করুন",
+hb_events: "সাম্প্রতিক ঘটনা",
+hb_page_hint: "নতুন হেডসেট পেয়ার করা, ইকুয়ালাইজার, ব্যাকআপ ও অন্য সব সেটিং HearBridge-এর নিজস্ব পেজে আছে।",
+hb_stop_title: "HearBridge বন্ধ করবেন?",
+hb_stop_body: "HearBridge বন্ধ হবে এবং হেডসেটের সংযোগ বিচ্ছিন্ন হবে। চালু করতে আবার পাঠান।",
payloads_description_ports: "PS5-এ ব্যবহৃত পোর্ট এবং প্রতিটিতে সাধারণত কী শোনে, যাতে বোঝা যায় কোন পেলোড কোথায়। যেগুলোর ওয়েব পেজ আছে সেগুলো ব্রাউজারে খুলুন।",
"ports.scan": "আবার স্ক্যান করুন",
"ports.scanning": "PS5-এর প্রতিটি পোর্ট পরীক্ষা করা হচ্ছে…",
diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts
index 12566eae..ae13d5ed 100644
--- a/client/src/i18n/locales/de.ts
+++ b/client/src/i18n/locales/de.ts
@@ -772,15 +772,12 @@ payloads_download: "{version} herunterladen",
payloads_load_failed: "Katalog konnte nicht geladen werden",
payloads_loading: "Katalog wird geladen…",
payloads_no_host: "Kein PS5-Host gesetzt. Verbinde dich zuerst im Verbindungsbildschirm.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Die vom Payload Manager (itsPLK) auf der PS5 gespeicherten Payloads: starten oder löschen und festlegen, welche er beim Hochfahren in welcher Reihenfolge lädt. Einen Katalog-Payload legst du im Tab „Katalog“ dort ab.",
payloads_pm_store: "Im Payload Manager ablegen",
payloads_pm_store_tooltip: "Diese Datei im Payload Manager auf der PS5 behalten, damit sie dort gestartet oder beim Hochfahren geladen werden kann.",
payloads_pm_store_confirm_title: "Ordner „{folder}“ des Payload Managers ersetzen?",
payloads_pm_store_confirm_same: "Der Payload Manager hat {name} bereits. Ablegen ersetzt die Datei und entfernt alle anderen Dateien in diesem Ordner.",
payloads_pm_store_confirm_other: "Der Payload Manager hat bereits einen Ordner „{folder}“. Ablegen entfernt die anderen Dateien darin (meist eine ältere Version).",
payloads_pm_stored_title: "{name} im Payload Manager abgelegt",
-payloads_pm_stored_body: "Liegt im Ordner „{folder}“ des Payload Managers. Der Tab „Payload Manager“ startet ihn oder lädt ihn beim Hochfahren.",
payloads_pm_store_failed: "{id} konnte nicht im Payload Manager abgelegt werden",
pm_launched: "{name} gestartet",
pm_launched_body: "Achte auf die Benachrichtigung auf der PS5.",
@@ -2486,10 +2483,6 @@ helper_mismatch_go: "Helfer neu laden",
"fpkg.checkDrm": "Dass der DRM-Wert des Pakets Standard ist. Ein kostenloser oder upgradefähiger Wert lässt die Konsole den Titel sperren; er wird nur im Paket umgeschrieben, deine eigene Datei wird nie verändert.",
"payloads_essentials_title": "Empfohlen zusammen mit ps5upload",
"payloads_essentials_body": "Wenn du eine Konsole zum ersten Mal einrichtest, decken diese drei das meiste ab, was ps5upload erwartet: kstuff-lite (Kernel-Patches, auf die die meiste Homebrew angewiesen ist), ShadowMount+ (Spiel-Images aus dem Speicher einhängen) und nanoDNS (hält die Konsole von Sonys Update-Pfad fern). Alles andere in dieser Liste ist optional.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Status von ShadowMount+, eingehängte Spiel-Images, Konfiguration und Diagnose auf der gewählten PS5 prüfen.",
-"payloads_description_nanodns": "Das nanoDNS-Payload konfigurieren, seine laufende Version prüfen und sichere Konfigurationsmigrationen anwenden.",
"navigation_history": "Verlauf anzeigen",
"navigation_back": "Zurück",
"navigation_forward": "Vorwärts",
@@ -3940,6 +3933,70 @@ upload_phase_settling_rate: "Dateien/s",
upload_status_resuming: "Wird fortgesetzt: {done} von {total} Dateien bereits vorhanden ({have}); {send} zu senden.",
volumes_kept_by_console: "{kept} von der Konsole für den eigenen Gebrauch reserviert",
payloads_tab_ports: "Ports",
+processes_and_ports: "Prozesse & Ports",
+payloads_tab_ps5: "Auf deiner PS5",
+payloads_description_ps5: "Die Payloads, die auf der ausgewählten PS5 laufen oder installiert sind. Öffne die Webseite eines Payloads, aktualisiere, stoppe oder verwalte ihn.",
+payloads_pm_stored_body_v2: "Er liegt im Ordner „{folder}“ von Payload Manager. Payload Manager im Tab „Auf deiner PS5“ startet ihn oder lädt ihn beim Booten.",
+hub_checking: "Prüfe, was auf dieser PS5 ist…",
+hub_count: "{n} auf dieser PS5",
+hub_error: "Konnte nicht lesen, was auf der PS5 läuft",
+hub_empty_title: "Nichts auf dieser PS5 gefunden",
+hub_empty_body: "Kein Payload aus dem Katalog läuft auf dieser Konsole oder ist dort installiert. Der Katalog hat sie, und Senden lädt jede Payload-Datei.",
+hub_empty_body_web: "Auf dieser Konsole läuft kein bekannter Payload und keiner ist installiert.",
+hub_state_running: "Läuft",
+hub_state_installed: "Installiert",
+hub_state_unknown: "Nicht gefunden",
+hub_running_detail: "{name} · PID {pid} · {mb} MB",
+hub_installed_detail: "Auf der Konsole, läuft nicht.",
+hub_unknown_detail: "Auf dieser PS5 nicht gefunden.",
+hub_not_catalogued: "Nicht im Katalog",
+hub_open_page: "Webseite öffnen",
+hub_update: "Auf {version} aktualisieren",
+hub_update_confirm_title: "{name} auf {version} aktualisieren?",
+hub_update_confirm_body: "Lädt {version} herunter und sendet es an die PS5. Achte auf die Benachrichtigung auf der PS5.",
+hub_updated: "{name} {version} gesendet",
+hub_update_failed: "{name} konnte nicht aktualisiert werden",
+hub_stop: "Stoppen",
+hub_manage: "Verwalten",
+hub_pm_summary: "Gespeicherte Payloads: {n}",
+hub_smp_summary: "Eingehängte Spiel-Images: {n}",
+hb_no_answer: "HearBridge hat nicht geantwortet",
+hb_checking: "HearBridge-Status wird gelesen…",
+hb_state_streaming: "Überträgt",
+hb_state_connecting: "Verbindet",
+hb_state_reconnecting: "Verbindet erneut",
+hb_state_scanning: "Sucht",
+hb_state_waiting: "Wartet auf ein Headset",
+hb_state_error: "Fehler",
+hb_battery: "Akku {pct} %",
+hb_link: "Verbindung {score} %",
+hb_latency: "{ms} ms Verzögerung",
+hb_muted: "Stumm",
+hb_volume: "Lautstärke {pct} %",
+hb_game: "Profil: {name}",
+hb_scan_confirm_title: "Nach Headsets suchen?",
+hb_scan_confirm_body: "Während der Suche (etwa 12 Sekunden) ist der Ton zum Headset unterbrochen.",
+hb_scan: "Nach Headsets suchen",
+hb_open_page: "HearBridge-Seite öffnen",
+hb_action_failed: "HearBridge hat die Änderung nicht übernommen",
+hb_volume_label: "Lautstärke",
+hb_mute: "Stumm",
+hb_latency_label: "Latenz",
+hb_latency_auto: "Automatisch",
+hb_night: "Nachtmodus",
+hb_night_hint: "Leisere Explosionen, klarere Stimmen.",
+hb_headsets: "Headsets",
+hb_reconnect: "Neu verbinden",
+hb_current: "In Verwendung",
+hb_switch: "Zu diesem wechseln",
+hb_saved_empty: "Noch keine gespeicherten Headsets. Such nach einem oder kopple es auf der HearBridge-Seite.",
+hb_scan_found: "Bei der letzten Suche gefunden:",
+hb_scan_empty: "Nichts gefunden. Versetz das Headset in den Kopplungsmodus und such erneut.",
+hb_connect: "Verbinden",
+hb_events: "Letzte Ereignisse",
+hb_page_hint: "Ein neues Headset koppeln, der Equalizer, Sicherungen und alle anderen Einstellungen sind auf der eigenen Seite von HearBridge.",
+hb_stop_title: "HearBridge beenden?",
+hb_stop_body: "HearBridge wird beendet und das Headset getrennt. Sende es erneut, um es zu starten.",
payloads_description_ports: "Die belegten Ports auf der PS5 und was dort üblicherweise lauscht, damit du siehst, welche Payload wo läuft. Ports mit Webseite kannst du im Browser öffnen.",
"ports.scan": "Erneut prüfen",
"ports.scanning": "Alle Ports der PS5 werden geprüft…",
diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts
index 12bfdfb4..a4acec93 100644
--- a/client/src/i18n/locales/en.ts
+++ b/client/src/i18n/locales/en.ts
@@ -1256,10 +1256,6 @@ payloads_custom_remove_short: "Remove",
payloads_description_catalog_v2: "Curated third-party PS5 homebrew payloads. Check for the latest release, download once, then send to your PS5 with one click. Versions cache locally so you can also put them on a USB payload stick.",
payloads_description_send_v2: "Send any PS5 payload file — .elf, .bin, .js, .lua, or .jar (kstuff, custom homebrew loaders, browser-stage exploits, plugin scripts, BD-JB JARs) — to your PS5. Same flow as the Connection screen, just pointed at a file you choose. The port follows the file type (.elf/.bin 9021, .jar 9025, .lua 9026, .js 50000); change it only if your loader listens elsewhere.",
payloads_tab_catalog: "Catalog",
-payloads_tab_shadowmount: "ShadowMount+",
-payloads_tab_nanodns: "nanoDNS",
-payloads_description_shadowmount: "Inspect ShadowMount+ status, mounted game images, configuration, and diagnostics on the selected PS5.",
-payloads_description_nanodns: "Configure the nanoDNS payload, verify its running version, and apply safe config migrations.",
navigation_history: "View history",
navigation_back: "Back",
navigation_forward: "Forward",
@@ -1269,15 +1265,12 @@ payloads_load_failed: "Could not load catalogue",
payloads_inventory_failed: "Could not read local payload cache",
payloads_loading: "Loading catalogue…",
payloads_no_host: "No PS5 host set. Connect on the Connection screen first.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "The payloads kept on the PS5 by Payload Manager (itsPLK): launch or delete them, and choose which ones it loads at boot and in what order. Store a catalogue payload there from the Catalog tab.",
payloads_pm_store: "Store in Payload Manager",
payloads_pm_store_tooltip: "Keep this file on the PS5 in Payload Manager, so it can be launched there or loaded at boot.",
payloads_pm_store_confirm_title: "Replace Payload Manager's \"{folder}\" folder?",
payloads_pm_store_confirm_same: "Payload Manager already has {name}. Storing replaces it, and removes any other file in that folder.",
payloads_pm_store_confirm_other: "Payload Manager already has a \"{folder}\" folder. Storing into it removes the other files there (an older version, usually).",
payloads_pm_stored_title: "Stored {name} in Payload Manager",
-payloads_pm_stored_body: "It is in Payload Manager's \"{folder}\" folder. The Payload Manager tab launches it or loads it at boot.",
payloads_pm_store_failed: "Couldn't store {id} in Payload Manager",
pm_launched: "Launched {name}",
pm_launched_body: "Check the PS5 for its notification.",
@@ -4255,6 +4248,70 @@ queue_route_direct: "Straight between the consoles",
queue_route_beta: "Beta",
queue_route_relay: "Through this computer",
payloads_tab_ports: "Ports",
+processes_and_ports: "Processes & ports",
+payloads_tab_ps5: "On your PS5",
+payloads_description_ps5: "The payloads running or installed on the selected PS5. Open a payload's web page, update it, stop it, or manage it.",
+payloads_pm_stored_body_v2: "It is in Payload Manager's \"{folder}\" folder. Payload Manager on the On your PS5 tab launches it or loads it at boot.",
+hub_checking: "Checking what is on this PS5…",
+hub_count: "{n} on this PS5",
+hub_error: "Couldn't read what is running on the PS5",
+hub_empty_title: "Nothing found on this PS5",
+hub_empty_body: "No payload from the catalogue is running or installed on this console. Catalog has them, and Send loads any payload file.",
+hub_empty_body_web: "No known payload is running or installed on this console.",
+hub_state_running: "Running",
+hub_state_installed: "Installed",
+hub_state_unknown: "Not found",
+hub_running_detail: "{name} · pid {pid} · {mb} MB",
+hub_installed_detail: "On the console, not running.",
+hub_unknown_detail: "Not found on this PS5.",
+hub_not_catalogued: "Not in the catalogue",
+hub_open_page: "Open web page",
+hub_update: "Update to {version}",
+hub_update_confirm_title: "Update {name} to {version}?",
+hub_update_confirm_body: "Downloads {version} and sends it to the PS5. Check the PS5 for its notification.",
+hub_updated: "Sent {name} {version}",
+hub_update_failed: "Couldn't update {name}",
+hub_stop: "Stop",
+hub_manage: "Manage",
+hub_pm_summary: "Payloads stored: {n}",
+hub_smp_summary: "Game images mounted: {n}",
+hb_no_answer: "HearBridge didn't answer",
+hb_checking: "Reading HearBridge's status…",
+hb_state_streaming: "Streaming",
+hb_state_connecting: "Connecting",
+hb_state_reconnecting: "Reconnecting",
+hb_state_scanning: "Scanning",
+hb_state_waiting: "Waiting for a headset",
+hb_state_error: "Error",
+hb_battery: "Battery {pct}%",
+hb_link: "Link {score}%",
+hb_latency: "{ms} ms delay",
+hb_muted: "Muted",
+hb_volume: "Volume {pct}%",
+hb_game: "Profile: {name}",
+hb_scan_confirm_title: "Scan for headsets?",
+hb_scan_confirm_body: "Scanning stops the headset's audio while it runs (about 12 seconds).",
+hb_scan: "Scan for headsets",
+hb_open_page: "Open HearBridge page",
+hb_action_failed: "HearBridge didn't take that change",
+hb_volume_label: "Volume",
+hb_mute: "Mute",
+hb_latency_label: "Latency",
+hb_latency_auto: "Automatic",
+hb_night: "Night mode",
+hb_night_hint: "Quieter explosions, clearer voices.",
+hb_headsets: "Headsets",
+hb_reconnect: "Reconnect",
+hb_current: "In use",
+hb_switch: "Switch to this one",
+hb_saved_empty: "No saved headsets yet. Scan for one, or pair it on HearBridge's page.",
+hb_scan_found: "Found in the last scan:",
+hb_scan_empty: "Nothing found. Put the headset in pairing mode and scan again.",
+hb_connect: "Connect",
+hb_events: "Recent events",
+hb_page_hint: "Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.",
+hb_stop_title: "Stop HearBridge?",
+hb_stop_body: "HearBridge closes and the headset disconnects. Send it again to start it.",
payloads_description_ports: "The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.",
"ports.scan": "Scan again",
"ports.scanning": "Checking every port on the PS5…",
diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts
index a68adb6e..c027680a 100644
--- a/client/src/i18n/locales/es.ts
+++ b/client/src/i18n/locales/es.ts
@@ -771,15 +771,12 @@ payloads_download: "Descargar {version}",
payloads_load_failed: "No se pudo cargar el catálogo",
payloads_loading: "Cargando catálogo…",
payloads_no_host: "No hay host de PS5 configurado. Conéctate primero en la pantalla Conexión.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Los payloads que Payload Manager (itsPLK) guarda en la PS5: lánzalos o bórralos y elige cuáles carga al arrancar y en qué orden. Guarda un payload del catálogo allí desde la pestaña Catálogo.",
payloads_pm_store: "Guardar en Payload Manager",
payloads_pm_store_tooltip: "Conserva este archivo en la PS5 dentro de Payload Manager para lanzarlo allí o cargarlo al arrancar.",
payloads_pm_store_confirm_title: "¿Reemplazar la carpeta \"{folder}\" de Payload Manager?",
payloads_pm_store_confirm_same: "Payload Manager ya tiene {name}. Guardarlo lo reemplaza y elimina cualquier otro archivo de esa carpeta.",
payloads_pm_store_confirm_other: "Payload Manager ya tiene una carpeta \"{folder}\". Guardar ahí elimina los demás archivos (normalmente una versión anterior).",
payloads_pm_stored_title: "{name} guardado en Payload Manager",
-payloads_pm_stored_body: "Está en la carpeta \"{folder}\" de Payload Manager. La pestaña Payload Manager lo lanza o lo carga al arrancar.",
payloads_pm_store_failed: "No se pudo guardar {id} en Payload Manager",
pm_launched: "{name} lanzado",
pm_launched_body: "Mira la notificación en la PS5.",
@@ -2428,10 +2425,6 @@ helper_mismatch_go: "Recargar asistente",
"fpkg.checkDrm": "Que el valor DRM del paquete sea estándar. Un valor gratuito o actualizable hace que la consola bloquee el título; solo se reescribe en el paquete y tu propio archivo nunca se modifica.",
"payloads_essentials_title": "Recomendados junto a ps5upload",
"payloads_essentials_body": "Si configuras una consola por primera vez, estos tres cubren casi todo lo que ps5upload espera encontrar: kstuff-lite (parches del kernel de los que depende la mayoría del homebrew), ShadowMount+ (montar imágenes de juegos desde el almacenamiento) y nanoDNS (mantener la consola fuera de las actualizaciones de Sony). Todo lo demás de esta lista es opcional.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Consulta el estado de ShadowMount+, las imágenes de juegos montadas, la configuración y el diagnóstico en la PS5 seleccionada.",
-"payloads_description_nanodns": "Configura el payload nanoDNS, comprueba la versión en ejecución y aplica migraciones de configuración seguras.",
"navigation_history": "Ver historial",
"navigation_back": "Atrás",
"navigation_forward": "Adelante",
@@ -3882,6 +3875,70 @@ upload_phase_settling_rate: "archivos/s",
upload_status_resuming: "Reanudando: {done} de {total} archivos ya presentes ({have}); {send} por enviar.",
volumes_kept_by_console: "{kept} reservados por la consola para su propio uso",
payloads_tab_ports: "Puertos",
+processes_and_ports: "Procesos y puertos",
+payloads_tab_ps5: "En tu PS5",
+payloads_description_ps5: "Los payloads que se ejecutan o están instalados en la PS5 seleccionada. Abre la página web de un payload, actualízalo, detenlo o gestiónalo.",
+payloads_pm_stored_body_v2: "Está en la carpeta \"{folder}\" de Payload Manager. Payload Manager, en la pestaña En tu PS5, lo inicia o lo carga al arrancar.",
+hub_checking: "Comprobando qué hay en esta PS5…",
+hub_count: "{n} en esta PS5",
+hub_error: "No se pudo leer qué se está ejecutando en la PS5",
+hub_empty_title: "No se encontró nada en esta PS5",
+hub_empty_body: "Ningún payload del catálogo se está ejecutando ni está instalado en esta consola. El Catálogo los tiene y Enviar carga cualquier archivo de payload.",
+hub_empty_body_web: "Ningún payload conocido se está ejecutando ni está instalado en esta consola.",
+hub_state_running: "En ejecución",
+hub_state_installed: "Instalado",
+hub_state_unknown: "No encontrado",
+hub_running_detail: "{name} · proceso {pid} · {mb} MB",
+hub_installed_detail: "En la consola, sin ejecutarse.",
+hub_unknown_detail: "No se encontró en esta PS5.",
+hub_not_catalogued: "No está en el catálogo",
+hub_open_page: "Abrir página web",
+hub_update: "Actualizar a {version}",
+hub_update_confirm_title: "¿Actualizar {name} a {version}?",
+hub_update_confirm_body: "Descarga {version} y lo envía a la PS5. Revisa la notificación en la PS5.",
+hub_updated: "{name} {version} enviado",
+hub_update_failed: "No se pudo actualizar {name}",
+hub_stop: "Detener",
+hub_manage: "Gestionar",
+hub_pm_summary: "Payloads guardados: {n}",
+hub_smp_summary: "Imágenes de juego montadas: {n}",
+hb_no_answer: "HearBridge no respondió",
+hb_checking: "Leyendo el estado de HearBridge…",
+hb_state_streaming: "Transmitiendo",
+hb_state_connecting: "Conectando",
+hb_state_reconnecting: "Reconectando",
+hb_state_scanning: "Buscando",
+hb_state_waiting: "Esperando unos auriculares",
+hb_state_error: "Error",
+hb_battery: "Batería {pct} %",
+hb_link: "Enlace {score} %",
+hb_latency: "{ms} ms de retardo",
+hb_muted: "Silenciado",
+hb_volume: "Volumen {pct} %",
+hb_game: "Perfil: {name}",
+hb_scan_confirm_title: "¿Buscar auriculares?",
+hb_scan_confirm_body: "La búsqueda corta el audio de los auriculares mientras dura (unos 12 segundos).",
+hb_scan: "Buscar auriculares",
+hb_open_page: "Abrir la página de HearBridge",
+hb_action_failed: "HearBridge no aceptó ese cambio",
+hb_volume_label: "Volumen",
+hb_mute: "Silenciar",
+hb_latency_label: "Latencia",
+hb_latency_auto: "Automática",
+hb_night: "Modo nocturno",
+hb_night_hint: "Explosiones más suaves, voces más claras.",
+hb_headsets: "Auriculares",
+hb_reconnect: "Reconectar",
+hb_current: "En uso",
+hb_switch: "Cambiar a estos",
+hb_saved_empty: "Aún no hay auriculares guardados. Busca unos o emparéjalos en la página de HearBridge.",
+hb_scan_found: "Encontrados en la última búsqueda:",
+hb_scan_empty: "No se encontró nada. Pon los auriculares en modo de emparejamiento y busca de nuevo.",
+hb_connect: "Conectar",
+hb_events: "Eventos recientes",
+hb_page_hint: "Emparejar unos auriculares nuevos, el ecualizador, las copias de seguridad y el resto de ajustes están en la propia página de HearBridge.",
+hb_stop_title: "¿Detener HearBridge?",
+hb_stop_body: "HearBridge se cierra y los auriculares se desconectan. Envíalo de nuevo para iniciarlo.",
payloads_description_ports: "Los puertos en uso en la PS5 y lo que suele escuchar en cada uno, para saber qué payload está en cada sitio. Abre en el navegador los que tienen página web.",
"ports.scan": "Volver a comprobar",
"ports.scanning": "Comprobando todos los puertos de la PS5…",
diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts
index 275c0189..8c5dd565 100644
--- a/client/src/i18n/locales/fa.ts
+++ b/client/src/i18n/locales/fa.ts
@@ -1169,10 +1169,6 @@ payloads_custom_badge: "سفارشی",
payloads_custom_remove: "حذف این مخزن سفارشی",
payloads_custom_remove_short: "حذف",
payloads_tab_catalog: "کاتالوگ",
-payloads_tab_shadowmount: "ShadowMount+",
-payloads_tab_nanodns: "nanoDNS",
-payloads_description_shadowmount: "بررسی وضعیت ShadowMount+، تصاویر بازی متصل، پیکربندی و عیبیابی روی PS5 انتخابشده.",
-payloads_description_nanodns: "پیکربندی payload nanoDNS، تأیید نسخهی در حال اجرا و اعمال مهاجرتهای پیکربندی ایمن.",
navigation_history: "مشاهده تاریخچه",
navigation_back: "عقب",
navigation_forward: "جلو",
@@ -1182,15 +1178,12 @@ payloads_load_failed: "بارگذاری کاتالوگ ناموفق بود",
payloads_inventory_failed: "خواندن کش payload محلی ناموفق بود",
payloads_loading: "در حال بارگذاری کاتالوگ…",
payloads_no_host: "هیچ میزبان PS5 تنظیم نشده. ابتدا در صفحهی Connection متصل شوید.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "پیلودهایی که Payload Manager (itsPLK) روی PS5 نگه میدارد: اجرا یا حذفشان کنید و انتخاب کنید کدامها هنگام بوت و با چه ترتیبی بارگذاری شوند. پیلود کاتالوگ را از برگهٔ کاتالوگ آنجا ذخیره کنید.",
payloads_pm_store: "ذخیره در Payload Manager",
payloads_pm_store_tooltip: "این فایل را روی PS5 در Payload Manager نگه میدارد تا از آنجا اجرا یا هنگام بوت بارگذاری شود.",
payloads_pm_store_confirm_title: "پوشهٔ «{folder}» در Payload Manager جایگزین شود؟",
payloads_pm_store_confirm_same: "Payload Manager از قبل {name} را دارد. ذخیره آن را جایگزین میکند و فایلهای دیگر آن پوشه را حذف میکند.",
payloads_pm_store_confirm_other: "Payload Manager از قبل پوشهٔ «{folder}» را دارد. ذخیره در آن فایلهای دیگر آنجا را حذف میکند (معمولاً نسخهٔ قدیمیتر).",
payloads_pm_stored_title: "{name} در Payload Manager ذخیره شد",
-payloads_pm_stored_body: "در پوشهٔ «{folder}» در Payload Manager است. برگهٔ Payload Manager آن را اجرا یا هنگام بوت بارگذاری میکند.",
payloads_pm_store_failed: "ذخیرهٔ {id} در Payload Manager ممکن نشد",
pm_launched: "{name} اجرا شد",
pm_launched_body: "اعلان را روی PS5 ببینید.",
@@ -4233,6 +4226,70 @@ upload_phase_settling_eta: "حدود {time} مانده",
upload_phase_settling_rate: "فایل/ثانیه",
volumes_kept_by_console: "{kept} را کنسول برای استفادهی خودش نگه داشته",
payloads_tab_ports: "پورتها",
+processes_and_ports: "پردازشها و پورتها",
+payloads_tab_ps5: "روی PS5 شما",
+payloads_description_ps5: "پیلودهایی که روی PS5 انتخابشده اجرا میشوند یا نصب شدهاند. صفحهٔ وب یک پیلود را باز کنید، آن را بهروز کنید، متوقف کنید یا مدیریت کنید.",
+payloads_pm_stored_body_v2: "در پوشهٔ «{folder}» در Payload Manager قرار دارد. Payload Manager در زبانهٔ «روی PS5 شما» آن را اجرا میکند یا هنگام راهاندازی بارگذاری میکند.",
+hub_checking: "در حال بررسی محتوای این PS5…",
+hub_count: "{n} مورد روی این PS5",
+hub_error: "خواندن آنچه روی PS5 اجرا میشود ممکن نشد",
+hub_empty_title: "چیزی روی این PS5 پیدا نشد",
+hub_empty_body: "هیچ پیلودی از کاتالوگ روی این کنسول اجرا نمیشود یا نصب نیست. کاتالوگ آنها را دارد و «ارسال» هر فایل پیلودی را بارگذاری میکند.",
+hub_empty_body_web: "هیچ پیلود شناختهشدهای روی این کنسول اجرا نمیشود یا نصب نیست.",
+hub_state_running: "در حال اجرا",
+hub_state_installed: "نصبشده",
+hub_state_unknown: "پیدا نشد",
+hub_running_detail: "{name} · شناسه {pid} · {mb} مگابایت",
+hub_installed_detail: "روی کنسول هست، اجرا نمیشود.",
+hub_unknown_detail: "روی این PS5 پیدا نشد.",
+hub_not_catalogued: "در کاتالوگ نیست",
+hub_open_page: "باز کردن صفحهٔ وب",
+hub_update: "بهروزرسانی به {version}",
+hub_update_confirm_title: "{name} به {version} بهروز شود؟",
+hub_update_confirm_body: "{version} را دانلود و به PS5 ارسال میکند. اعلان آن را روی PS5 بررسی کنید.",
+hub_updated: "{name} {version} ارسال شد",
+hub_update_failed: "بهروزرسانی {name} ممکن نشد",
+hub_stop: "توقف",
+hub_manage: "مدیریت",
+hub_pm_summary: "پیلودهای ذخیرهشده: {n}",
+hub_smp_summary: "ایمیجهای بازی مونتشده: {n}",
+hb_no_answer: "HearBridge پاسخ نداد",
+hb_checking: "در حال خواندن وضعیت HearBridge…",
+hb_state_streaming: "در حال پخش",
+hb_state_connecting: "در حال اتصال",
+hb_state_reconnecting: "در حال اتصال دوباره",
+hb_state_scanning: "در حال جستوجو",
+hb_state_waiting: "در انتظار هدست",
+hb_state_error: "خطا",
+hb_battery: "باتری {pct}٪",
+hb_link: "کیفیت اتصال {score}٪",
+hb_latency: "تأخیر {ms} میلیثانیه",
+hb_muted: "بیصدا",
+hb_volume: "صدا {pct}٪",
+hb_game: "نمایه: {name}",
+hb_scan_confirm_title: "هدستها جستوجو شوند؟",
+hb_scan_confirm_body: "در طول جستوجو (حدود ۱۲ ثانیه) صدای هدست قطع میشود.",
+hb_scan: "جستوجوی هدست",
+hb_open_page: "باز کردن صفحهٔ HearBridge",
+hb_action_failed: "HearBridge این تغییر را نپذیرفت",
+hb_volume_label: "صدا",
+hb_mute: "بیصدا",
+hb_latency_label: "تأخیر",
+hb_latency_auto: "خودکار",
+hb_night: "حالت شب",
+hb_night_hint: "انفجارهای آرامتر، صداهای واضحتر.",
+hb_headsets: "هدستها",
+hb_reconnect: "اتصال دوباره",
+hb_current: "در حال استفاده",
+hb_switch: "رفتن به این",
+hb_saved_empty: "هنوز هدستی ذخیره نشده است. یکی را جستوجو کنید یا در صفحهٔ HearBridge جفت کنید.",
+hb_scan_found: "در آخرین جستوجو پیدا شد:",
+hb_scan_empty: "چیزی پیدا نشد. هدست را در حالت جفتسازی بگذارید و دوباره جستوجو کنید.",
+hb_connect: "اتصال",
+hb_events: "رویدادهای اخیر",
+hb_page_hint: "جفت کردن هدست جدید، اکولایزر، پشتیبانها و همهٔ تنظیمات دیگر در صفحهٔ خود HearBridge هستند.",
+hb_stop_title: "HearBridge متوقف شود؟",
+hb_stop_body: "HearBridge بسته میشود و اتصال هدست قطع میشود. برای اجرا دوباره آن را ارسال کنید.",
payloads_description_ports: "پورتهای در حال استفاده روی PS5 و آنچه معمولاً روی هرکدام گوش میدهد، تا بدانید کدام payload کجاست. آنهایی که صفحهٔ وب دارند را در مرورگر باز کنید.",
"ports.scan": "بررسی دوباره",
"ports.scanning": "در حال بررسی همهٔ پورتهای PS5…",
diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts
index 358d5df2..10edc36a 100644
--- a/client/src/i18n/locales/fr.ts
+++ b/client/src/i18n/locales/fr.ts
@@ -771,15 +771,12 @@ payloads_download: "Télécharger {version}",
payloads_load_failed: "Catalogue indisponible",
payloads_loading: "Chargement du catalogue…",
payloads_no_host: "Aucun hôte PS5 défini. Connecte-toi d'abord depuis l'écran Connexion.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Les payloads conservés sur la PS5 par Payload Manager (itsPLK) : lancez-les ou supprimez-les, et choisissez lesquels il charge au démarrage et dans quel ordre. Enregistrez-y un payload du catalogue depuis l'onglet Catalogue.",
payloads_pm_store: "Enregistrer dans Payload Manager",
payloads_pm_store_tooltip: "Conserve ce fichier sur la PS5 dans Payload Manager, pour le lancer depuis là ou le charger au démarrage.",
payloads_pm_store_confirm_title: "Remplacer le dossier « {folder} » de Payload Manager ?",
payloads_pm_store_confirm_same: "Payload Manager a déjà {name}. L'enregistrer le remplace et supprime tout autre fichier de ce dossier.",
payloads_pm_store_confirm_other: "Payload Manager a déjà un dossier « {folder} ». Y enregistrer supprime les autres fichiers (généralement une version plus ancienne).",
payloads_pm_stored_title: "{name} enregistré dans Payload Manager",
-payloads_pm_stored_body: "Il est dans le dossier « {folder} » de Payload Manager. L'onglet Payload Manager le lance ou le charge au démarrage.",
payloads_pm_store_failed: "Impossible d'enregistrer {id} dans Payload Manager",
pm_launched: "{name} lancé",
pm_launched_body: "Vérifiez la notification sur la PS5.",
@@ -2500,10 +2497,6 @@ helper_mismatch_go: "Recharger l'assistant",
"fpkg.checkDrm": "Que la valeur DRM du paquet est standard. Une valeur gratuite ou évolutive fait verrouiller le titre par la console ; elle n’est réécrite que dans le paquet, et ton propre fichier n’est jamais modifié.",
"payloads_essentials_title": "Recommandés avec ps5upload",
"payloads_essentials_body": "Si tu configures une console pour la première fois, ces trois-là couvrent l’essentiel de ce qu’attend ps5upload : kstuff-lite (les patches noyau dont dépend la plupart des homebrews), ShadowMount+ (monter des images de jeux depuis le stockage) et nanoDNS (tenir la console à l’écart des mises à jour de Sony). Tout le reste de cette liste est facultatif.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Consulte l’état de ShadowMount+, les images de jeux montées, la configuration et les diagnostics sur la PS5 sélectionnée.",
-"payloads_description_nanodns": "Configure le payload nanoDNS, vérifie la version en cours et applique des migrations de configuration sûres.",
"navigation_history": "Voir l’historique",
"navigation_back": "Retour",
"navigation_forward": "Suivant",
@@ -3950,6 +3943,70 @@ upload_phase_settling_rate: "fichiers/s",
upload_status_resuming: "Reprise : {done} fichiers sur {total} déjà présents ({have}) ; {send} à envoyer.",
volumes_kept_by_console: "{kept} réservés par la console pour son propre usage",
payloads_tab_ports: "Ports",
+processes_and_ports: "Processus et ports",
+payloads_tab_ps5: "Sur ta PS5",
+payloads_description_ps5: "Les payloads en cours d'exécution ou installés sur la PS5 sélectionnée. Ouvre la page web d'un payload, mets-le à jour, arrête-le ou gère-le.",
+payloads_pm_stored_body_v2: "Il se trouve dans le dossier « {folder} » de Payload Manager. Payload Manager, dans l'onglet Sur ta PS5, le lance ou le charge au démarrage.",
+hub_checking: "Vérification de ce qui se trouve sur cette PS5…",
+hub_count: "{n} sur cette PS5",
+hub_error: "Impossible de lire ce qui tourne sur la PS5",
+hub_empty_title: "Rien trouvé sur cette PS5",
+hub_empty_body: "Aucun payload du catalogue ne tourne ni n'est installé sur cette console. Le Catalogue les propose, et Envoyer charge n'importe quel fichier de payload.",
+hub_empty_body_web: "Aucun payload connu ne tourne ni n'est installé sur cette console.",
+hub_state_running: "En cours",
+hub_state_installed: "Installé",
+hub_state_unknown: "Introuvable",
+hub_running_detail: "{name} · pid {pid} · {mb} Mo",
+hub_installed_detail: "Sur la console, à l'arrêt.",
+hub_unknown_detail: "Introuvable sur cette PS5.",
+hub_not_catalogued: "Absent du catalogue",
+hub_open_page: "Ouvrir la page web",
+hub_update: "Mettre à jour vers {version}",
+hub_update_confirm_title: "Mettre à jour {name} vers {version} ?",
+hub_update_confirm_body: "Télécharge {version} et l'envoie à la PS5. Surveille la notification sur la PS5.",
+hub_updated: "{name} {version} envoyé",
+hub_update_failed: "Impossible de mettre à jour {name}",
+hub_stop: "Arrêter",
+hub_manage: "Gérer",
+hub_pm_summary: "Payloads stockés : {n}",
+hub_smp_summary: "Images de jeu montées : {n}",
+hb_no_answer: "HearBridge n'a pas répondu",
+hb_checking: "Lecture de l'état de HearBridge…",
+hb_state_streaming: "Diffusion",
+hb_state_connecting: "Connexion",
+hb_state_reconnecting: "Reconnexion",
+hb_state_scanning: "Recherche",
+hb_state_waiting: "En attente d'un casque",
+hb_state_error: "Erreur",
+hb_battery: "Batterie {pct} %",
+hb_link: "Liaison {score} %",
+hb_latency: "{ms} ms de délai",
+hb_muted: "Muet",
+hb_volume: "Volume {pct} %",
+hb_game: "Profil : {name}",
+hb_scan_confirm_title: "Rechercher des casques ?",
+hb_scan_confirm_body: "La recherche coupe le son du casque pendant qu'elle dure (environ 12 secondes).",
+hb_scan: "Rechercher des casques",
+hb_open_page: "Ouvrir la page HearBridge",
+hb_action_failed: "HearBridge n'a pas accepté ce changement",
+hb_volume_label: "Volume",
+hb_mute: "Couper le son",
+hb_latency_label: "Latence",
+hb_latency_auto: "Automatique",
+hb_night: "Mode nuit",
+hb_night_hint: "Explosions plus douces, voix plus claires.",
+hb_headsets: "Casques",
+hb_reconnect: "Reconnecter",
+hb_current: "Utilisé",
+hb_switch: "Passer à celui-ci",
+hb_saved_empty: "Aucun casque enregistré pour l'instant. Lance une recherche ou associe-le sur la page HearBridge.",
+hb_scan_found: "Trouvés lors de la dernière recherche :",
+hb_scan_empty: "Rien trouvé. Mets le casque en mode d'association et relance la recherche.",
+hb_connect: "Connecter",
+hb_events: "Événements récents",
+hb_page_hint: "L'association d'un nouveau casque, l'égaliseur, les sauvegardes et tous les autres réglages sont sur la page de HearBridge.",
+hb_stop_title: "Arrêter HearBridge ?",
+hb_stop_body: "HearBridge se ferme et le casque se déconnecte. Renvoie-le pour le relancer.",
payloads_description_ports: "Les ports utilisés sur la PS5 et ce qui y écoute habituellement, pour savoir quel payload est où. Ouvrez dans le navigateur ceux qui ont une page web.",
"ports.scan": "Analyser à nouveau",
"ports.scanning": "Vérification de tous les ports de la PS5…",
diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts
index 444a24fc..cdaaaab4 100644
--- a/client/src/i18n/locales/hi.ts
+++ b/client/src/i18n/locales/hi.ts
@@ -772,15 +772,12 @@ payloads_download: "{version} डाउनलोड करें",
payloads_load_failed: "कैटलॉग लोड नहीं हो सका",
payloads_loading: "कैटलॉग लोड हो रहा है…",
payloads_no_host: "कोई PS5 होस्ट सेट नहीं है। पहले कनेक्शन स्क्रीन पर जुड़ें।",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager (itsPLK) द्वारा PS5 पर रखे गए पेलोड: उन्हें चलाएँ या हटाएँ, और चुनें कि बूट पर कौन से किस क्रम में लोड हों। कैटलॉग का पेलोड कैटलॉग टैब से वहाँ सहेजें।",
payloads_pm_store: "Payload Manager में सहेजें",
payloads_pm_store_tooltip: "यह फ़ाइल PS5 पर Payload Manager में रखी जाती है, ताकि वहाँ से चलाई या बूट पर लोड की जा सके।",
payloads_pm_store_confirm_title: "Payload Manager का \"{folder}\" फ़ोल्डर बदलें?",
payloads_pm_store_confirm_same: "Payload Manager में {name} पहले से है। सहेजने पर वह बदल जाएगा और उस फ़ोल्डर की अन्य फ़ाइलें हट जाएँगी।",
payloads_pm_store_confirm_other: "Payload Manager में \"{folder}\" फ़ोल्डर पहले से है। उसमें सहेजने पर वहाँ की अन्य फ़ाइलें (आमतौर पर पुराना संस्करण) हट जाएँगी।",
payloads_pm_stored_title: "{name} Payload Manager में सहेजा गया",
-payloads_pm_stored_body: "यह Payload Manager के \"{folder}\" फ़ोल्डर में है। Payload Manager टैब इसे चलाता है या बूट पर लोड करता है।",
payloads_pm_store_failed: "{id} को Payload Manager में सहेजा नहीं जा सका",
pm_launched: "{name} चलाया गया",
pm_launched_body: "PS5 पर सूचना देखें।",
@@ -2498,10 +2495,6 @@ helper_mismatch_go: "हेल्पर फिर लोड करें",
"fpkg.checkDrm": "कि पैकेज का DRM मान मानक है। मुफ़्त या अपग्रेड-योग्य मान से कंसोल टाइटल को लॉक कर देता है; इसे केवल पैकेज में दोबारा लिखा जाता है, आपकी अपनी फ़ाइल को कभी नहीं छुआ जाता।",
"payloads_essentials_title": "ps5upload के साथ सुझाए गए",
"payloads_essentials_body": "अगर आप पहली बार कंसोल सेट कर रहे हैं, तो ये तीन वह सब कवर करते हैं जिसकी ps5upload को ज़्यादातर ज़रूरत होती है: kstuff-lite (ज़्यादातर होमब्रू जिन कर्नेल पैच पर निर्भर है), ShadowMount+ (स्टोरेज से गेम इमेज माउंट करना) और nanoDNS (कंसोल को Sony के अपडेट रास्ते से दूर रखना)। इस सूची में बाकी सब वैकल्पिक है।",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "चुने गए PS5 पर ShadowMount+ की स्थिति, माउंट की गई गेम इमेज, कॉन्फ़िगरेशन और डायग्नोस्टिक्स देखें।",
-"payloads_description_nanodns": "nanoDNS payload कॉन्फ़िगर करें, चल रहा संस्करण सत्यापित करें, और सुरक्षित कॉन्फ़िग माइग्रेशन लागू करें।",
"navigation_history": "इतिहास देखें",
"navigation_back": "पीछे",
"navigation_forward": "आगे",
@@ -3948,6 +3941,70 @@ upload_phase_settling_rate: "फ़ाइलें/से",
upload_status_resuming: "फिर से शुरू हो रहा है: {total} में से {done} फ़ाइलें पहले से मौजूद हैं ({have}); {send} भेजना बाकी।",
volumes_kept_by_console: "{kept} कंसोल ने अपने उपयोग के लिए रखा है",
payloads_tab_ports: "पोर्ट",
+processes_and_ports: "प्रोसेस और पोर्ट",
+payloads_tab_ps5: "आपके PS5 पर",
+payloads_description_ps5: "चुने गए PS5 पर चल रहे या इंस्टॉल किए गए पेलोड। किसी पेलोड का वेब पेज खोलें, उसे अपडेट करें, रोकें या प्रबंधित करें।",
+payloads_pm_stored_body_v2: "यह Payload Manager के \"{folder}\" फ़ोल्डर में है। \"आपके PS5 पर\" टैब का Payload Manager इसे चलाता है या बूट पर लोड करता है।",
+hub_checking: "इस PS5 पर क्या है, जाँचा जा रहा है…",
+hub_count: "इस PS5 पर {n}",
+hub_error: "PS5 पर क्या चल रहा है, यह पढ़ा नहीं जा सका",
+hub_empty_title: "इस PS5 पर कुछ नहीं मिला",
+hub_empty_body: "कैटलॉग का कोई पेलोड इस कंसोल पर नहीं चल रहा या इंस्टॉल नहीं है। कैटलॉग में वे हैं, और Send कोई भी पेलोड फ़ाइल लोड करता है।",
+hub_empty_body_web: "इस कंसोल पर कोई ज्ञात पेलोड नहीं चल रहा या इंस्टॉल नहीं है।",
+hub_state_running: "चल रहा है",
+hub_state_installed: "इंस्टॉल है",
+hub_state_unknown: "नहीं मिला",
+hub_running_detail: "{name} · प्रोसेस {pid} · {mb} MB",
+hub_installed_detail: "कंसोल पर है, चल नहीं रहा।",
+hub_unknown_detail: "इस PS5 पर नहीं मिला।",
+hub_not_catalogued: "कैटलॉग में नहीं",
+hub_open_page: "वेब पेज खोलें",
+hub_update: "{version} पर अपडेट करें",
+hub_update_confirm_title: "{name} को {version} पर अपडेट करें?",
+hub_update_confirm_body: "{version} डाउनलोड करके PS5 पर भेजता है। PS5 पर उसकी सूचना देखें।",
+hub_updated: "{name} {version} भेजा गया",
+hub_update_failed: "{name} अपडेट नहीं हो सका",
+hub_stop: "रोकें",
+hub_manage: "प्रबंधित करें",
+hub_pm_summary: "सहेजे गए पेलोड: {n}",
+hub_smp_summary: "माउंट की गई गेम इमेज: {n}",
+hb_no_answer: "HearBridge ने जवाब नहीं दिया",
+hb_checking: "HearBridge की स्थिति पढ़ी जा रही है…",
+hb_state_streaming: "स्ट्रीम हो रहा है",
+hb_state_connecting: "कनेक्ट हो रहा है",
+hb_state_reconnecting: "फिर से कनेक्ट हो रहा है",
+hb_state_scanning: "स्कैन हो रहा है",
+hb_state_waiting: "हेडसेट का इंतज़ार",
+hb_state_error: "त्रुटि",
+hb_battery: "बैटरी {pct}%",
+hb_link: "कनेक्शन {score}%",
+hb_latency: "{ms} ms देरी",
+hb_muted: "म्यूट",
+hb_volume: "वॉल्यूम {pct}%",
+hb_game: "प्रोफ़ाइल: {name}",
+hb_scan_confirm_title: "हेडसेट खोजें?",
+hb_scan_confirm_body: "स्कैन चलने तक (लगभग 12 सेकंड) हेडसेट की आवाज़ रुकी रहती है।",
+hb_scan: "हेडसेट खोजें",
+hb_open_page: "HearBridge पेज खोलें",
+hb_action_failed: "HearBridge ने यह बदलाव नहीं लिया",
+hb_volume_label: "वॉल्यूम",
+hb_mute: "म्यूट",
+hb_latency_label: "लेटेंसी",
+hb_latency_auto: "स्वचालित",
+hb_night: "नाइट मोड",
+hb_night_hint: "धीमे धमाके, साफ़ आवाज़ें।",
+hb_headsets: "हेडसेट",
+hb_reconnect: "फिर से कनेक्ट करें",
+hb_current: "उपयोग में",
+hb_switch: "इस पर स्विच करें",
+hb_saved_empty: "अभी कोई सहेजा हुआ हेडसेट नहीं है। कोई खोजें, या HearBridge के पेज पर पेयर करें।",
+hb_scan_found: "पिछले स्कैन में मिले:",
+hb_scan_empty: "कुछ नहीं मिला। हेडसेट को पेयरिंग मोड में रखकर फिर से स्कैन करें।",
+hb_connect: "कनेक्ट करें",
+hb_events: "हाल की घटनाएँ",
+hb_page_hint: "नया हेडसेट पेयर करना, इक्वलाइज़र, बैकअप और बाकी सभी सेटिंग HearBridge के अपने पेज पर हैं।",
+hb_stop_title: "HearBridge रोकें?",
+hb_stop_body: "HearBridge बंद हो जाएगा और हेडसेट डिस्कनेक्ट हो जाएगा। शुरू करने के लिए इसे फिर भेजें।",
payloads_description_ports: "PS5 पर उपयोग में पोर्ट और हर एक पर आमतौर पर क्या सुनता है, ताकि पता चले कौन-सा पेलोड कहाँ है। जिनका वेब पेज है उन्हें ब्राउज़र में खोलें।",
"ports.scan": "फिर से स्कैन करें",
"ports.scanning": "PS5 के हर पोर्ट की जाँच हो रही है…",
diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts
index 7c62cd24..65857672 100644
--- a/client/src/i18n/locales/hu.ts
+++ b/client/src/i18n/locales/hu.ts
@@ -1105,10 +1105,6 @@ payloads_custom_badge: "Egyedi",
payloads_custom_remove: "Ennek az egyedi repónak az eltávolítása",
payloads_custom_remove_short: "Eltávolítás",
payloads_tab_catalog: "Katalógus",
-payloads_tab_shadowmount: "ShadowMount+",
-payloads_tab_nanodns: "nanoDNS",
-payloads_description_shadowmount: "A ShadowMount+ állapotának, csatolt játékképeinek, konfigurációjának és diagnosztikájának vizsgálata a kiválasztott PS5-ön.",
-payloads_description_nanodns: "A nanoDNS payload konfigurálása, futó verziójának ellenőrzése, és biztonságos konfig-migrációk alkalmazása.",
navigation_history: "Előzmények megtekintése",
navigation_back: "Vissza",
navigation_forward: "Előre",
@@ -1118,15 +1114,12 @@ payloads_load_failed: "Nem sikerült betölteni a katalógust",
payloads_inventory_failed: "Nem sikerült beolvasni a helyi payload gyorsítótárat",
payloads_loading: "Katalógus betöltése…",
payloads_no_host: "Nincs beállítva PS5 host. Csatlakozz először a Kapcsolat képernyőn.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "A Payload Manager (itsPLK) által a PS5-ön tárolt payloadok: indítsd vagy töröld őket, és válaszd ki, melyeket tölti be indításkor és milyen sorrendben. Katalógusbeli payloadot a Katalógus fülről menthetsz oda.",
payloads_pm_store: "Mentés a Payload Managerbe",
payloads_pm_store_tooltip: "Ez a fájl a PS5-ön marad a Payload Managerben, hogy onnan indítható vagy indításkor betölthető legyen.",
payloads_pm_store_confirm_title: "Lecseréled a Payload Manager „{folder}” mappáját?",
payloads_pm_store_confirm_same: "A Payload Managerben már van {name}. A mentés lecseréli, és eltávolítja a mappa többi fájlját.",
payloads_pm_store_confirm_other: "A Payload Managerben már van „{folder}” mappa. Az oda mentés eltávolítja a többi ottani fájlt (általában egy régebbi verziót).",
payloads_pm_stored_title: "{name} mentve a Payload Managerbe",
-payloads_pm_stored_body: "A Payload Manager „{folder}” mappájában van. A Payload Manager fül indítja vagy indításkor betölti.",
payloads_pm_store_failed: "Nem sikerült menteni: {id} a Payload Managerbe",
pm_launched: "{name} elindítva",
pm_launched_body: "Nézd meg az értesítést a PS5-ön.",
@@ -4247,6 +4240,70 @@ upload_phase_settling_rate: "fájl/s",
upload_status_resuming: "Folytatás: {done} / {total} fájl már megvan ({have}); {send} küldendő.",
volumes_kept_by_console: "{kept} a konzol saját használatára fenntartva",
payloads_tab_ports: "Portok",
+processes_and_ports: "Folyamatok és portok",
+payloads_tab_ps5: "A PS5-ödön",
+payloads_description_ps5: "A kiválasztott PS5-ön futó vagy telepített payloadok. Nyisd meg egy payload weboldalát, frissítsd, állítsd le vagy kezeld.",
+payloads_pm_stored_body_v2: "A Payload Manager „{folder}” mappájában van. A „A PS5-ödön” lapon a Payload Manager elindítja, vagy bekapcsoláskor betölti.",
+hub_checking: "A PS5 tartalmának ellenőrzése…",
+hub_count: "{n} ezen a PS5-ön",
+hub_error: "Nem sikerült kiolvasni, mi fut a PS5-ön",
+hub_empty_title: "Semmi sem található ezen a PS5-ön",
+hub_empty_body: "A katalógusból egyetlen payload sem fut és nincs telepítve ezen a konzolon. A Katalógusban megtalálod őket, a Küldés pedig bármilyen payload fájlt betölt.",
+hub_empty_body_web: "Ezen a konzolon nem fut és nincs telepítve ismert payload.",
+hub_state_running: "Fut",
+hub_state_installed: "Telepítve",
+hub_state_unknown: "Nem található",
+hub_running_detail: "{name} · folyamat {pid} · {mb} MB",
+hub_installed_detail: "A konzolon van, nem fut.",
+hub_unknown_detail: "Nem található ezen a PS5-ön.",
+hub_not_catalogued: "Nincs a katalógusban",
+hub_open_page: "Weboldal megnyitása",
+hub_update: "Frissítés erre: {version}",
+hub_update_confirm_title: "Frissíted a(z) {name} payloadot erre: {version}?",
+hub_update_confirm_body: "Letölti a(z) {version} verziót, és elküldi a PS5-re. Figyeld az értesítést a PS5-ön.",
+hub_updated: "{name} {version} elküldve",
+hub_update_failed: "Nem sikerült frissíteni: {name}",
+hub_stop: "Leállítás",
+hub_manage: "Kezelés",
+hub_pm_summary: "Tárolt payloadok: {n}",
+hub_smp_summary: "Csatolt játéklemezképek: {n}",
+hb_no_answer: "A HearBridge nem válaszolt",
+hb_checking: "A HearBridge állapotának lekérése…",
+hb_state_streaming: "Közvetít",
+hb_state_connecting: "Csatlakozik",
+hb_state_reconnecting: "Újracsatlakozik",
+hb_state_scanning: "Keres",
+hb_state_waiting: "Fejhallgatóra vár",
+hb_state_error: "Hiba",
+hb_battery: "Akkumulátor {pct}%",
+hb_link: "Kapcsolat {score}%",
+hb_latency: "{ms} ms késleltetés",
+hb_muted: "Némítva",
+hb_volume: "Hangerő {pct}%",
+hb_game: "Profil: {name}",
+hb_scan_confirm_title: "Fejhallgatók keresése?",
+hb_scan_confirm_body: "A keresés idejére (kb. 12 másodperc) a fejhallgató hangja leáll.",
+hb_scan: "Fejhallgatók keresése",
+hb_open_page: "HearBridge oldal megnyitása",
+hb_action_failed: "A HearBridge nem fogadta el a módosítást",
+hb_volume_label: "Hangerő",
+hb_mute: "Némítás",
+hb_latency_label: "Késleltetés",
+hb_latency_auto: "Automatikus",
+hb_night: "Éjszakai mód",
+hb_night_hint: "Halkabb robbanások, tisztább beszéd.",
+hb_headsets: "Fejhallgatók",
+hb_reconnect: "Újracsatlakozás",
+hb_current: "Használatban",
+hb_switch: "Váltás erre",
+hb_saved_empty: "Még nincs mentett fejhallgató. Keress egyet, vagy párosítsd a HearBridge oldalán.",
+hb_scan_found: "A legutóbbi keresés találatai:",
+hb_scan_empty: "Nincs találat. Tedd a fejhallgatót párosítási módba, és keress újra.",
+hb_connect: "Csatlakozás",
+hb_events: "Legutóbbi események",
+hb_page_hint: "Új fejhallgató párosítása, a hangszínszabályzó, a mentések és minden más beállítás a HearBridge saját oldalán van.",
+hb_stop_title: "Leállítod a HearBridge-et?",
+hb_stop_body: "A HearBridge bezárul, a fejhallgató lecsatlakozik. Az indításhoz küldd el újra.",
payloads_description_ports: "A PS5-ön használt portok és hogy mi szokott rajtuk figyelni, így látod, melyik payload hol fut. A weboldallal rendelkezőket megnyithatod a böngészőben.",
"ports.scan": "Újraellenőrzés",
"ports.scanning": "A PS5 összes portjának ellenőrzése…",
diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts
index b08994e8..8ae2af09 100644
--- a/client/src/i18n/locales/id.ts
+++ b/client/src/i18n/locales/id.ts
@@ -771,15 +771,12 @@ payloads_download: "Unduh {version}",
payloads_load_failed: "Tidak bisa memuat katalog",
payloads_loading: "Memuat katalog…",
payloads_no_host: "Host PS5 belum diatur. Sambungkan dulu di layar Koneksi.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload yang disimpan di PS5 oleh Payload Manager (itsPLK): jalankan atau hapus, dan pilih mana yang dimuat saat boot serta urutannya. Simpan payload katalog ke sana dari tab Katalog.",
payloads_pm_store: "Simpan di Payload Manager",
payloads_pm_store_tooltip: "Simpan file ini di PS5 dalam Payload Manager agar bisa dijalankan dari sana atau dimuat saat boot.",
payloads_pm_store_confirm_title: "Ganti folder \"{folder}\" di Payload Manager?",
payloads_pm_store_confirm_same: "Payload Manager sudah punya {name}. Menyimpan akan menggantinya dan menghapus file lain di folder itu.",
payloads_pm_store_confirm_other: "Payload Manager sudah punya folder \"{folder}\". Menyimpan ke sana menghapus file lain di dalamnya (biasanya versi lama).",
payloads_pm_stored_title: "{name} disimpan di Payload Manager",
-payloads_pm_stored_body: "Ada di folder \"{folder}\" Payload Manager. Tab Payload Manager menjalankannya atau memuatnya saat boot.",
payloads_pm_store_failed: "Tidak dapat menyimpan {id} di Payload Manager",
pm_launched: "{name} dijalankan",
pm_launched_body: "Periksa notifikasi di PS5.",
@@ -2491,10 +2488,6 @@ helper_mismatch_go: "Muat ulang helper",
"fpkg.checkDrm": "Bahwa nilai DRM paket standar. Nilai gratis atau bisa-ditingkatkan membuat konsol mengunci judul; nilainya hanya ditulis ulang di paket, dan berkasmu sendiri tidak pernah diubah.",
"payloads_essentials_title": "Disarankan bersama ps5upload",
"payloads_essentials_body": "Jika kamu menyiapkan konsol untuk pertama kali, ketiga ini mencakup sebagian besar yang diharapkan ps5upload: kstuff-lite (patch kernel yang diandalkan kebanyakan homebrew), ShadowMount+ (mount image game dari penyimpanan), dan nanoDNS (menjauhkan konsol dari jalur pembaruan Sony). Selebihnya di daftar ini opsional.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Periksa status ShadowMount+, image game yang di-mount, konfigurasi, dan diagnostik di PS5 yang dipilih.",
-"payloads_description_nanodns": "Konfigurasikan payload nanoDNS, verifikasi versi yang berjalan, dan terapkan migrasi konfigurasi yang aman.",
"navigation_history": "Lihat riwayat",
"navigation_back": "Kembali",
"navigation_forward": "Maju",
@@ -3941,6 +3934,70 @@ upload_phase_settling_rate: "file/dtk",
upload_status_resuming: "Melanjutkan: {done} dari {total} file sudah ada ({have}); {send} akan dikirim.",
volumes_kept_by_console: "{kept} disimpan konsol untuk keperluannya sendiri",
payloads_tab_ports: "Port",
+processes_and_ports: "Proses & port",
+payloads_tab_ps5: "Di PS5 Anda",
+payloads_description_ps5: "Payload yang berjalan atau terpasang di PS5 yang dipilih. Buka halaman web payload, perbarui, hentikan, atau kelola.",
+payloads_pm_stored_body_v2: "Ada di folder \"{folder}\" milik Payload Manager. Payload Manager di tab Di PS5 Anda menjalankannya atau memuatnya saat boot.",
+hub_checking: "Memeriksa isi PS5 ini…",
+hub_count: "{n} di PS5 ini",
+hub_error: "Tidak dapat membaca apa yang berjalan di PS5",
+hub_empty_title: "Tidak ada yang ditemukan di PS5 ini",
+hub_empty_body: "Tidak ada payload dari katalog yang berjalan atau terpasang di konsol ini. Katalog menyediakannya, dan Kirim memuat file payload apa pun.",
+hub_empty_body_web: "Tidak ada payload yang dikenal berjalan atau terpasang di konsol ini.",
+hub_state_running: "Berjalan",
+hub_state_installed: "Terpasang",
+hub_state_unknown: "Tidak ditemukan",
+hub_running_detail: "{name} · proses {pid} · {mb} MB",
+hub_installed_detail: "Ada di konsol, tidak berjalan.",
+hub_unknown_detail: "Tidak ditemukan di PS5 ini.",
+hub_not_catalogued: "Tidak ada di katalog",
+hub_open_page: "Buka halaman web",
+hub_update: "Perbarui ke {version}",
+hub_update_confirm_title: "Perbarui {name} ke {version}?",
+hub_update_confirm_body: "Mengunduh {version} dan mengirimnya ke PS5. Periksa notifikasinya di PS5.",
+hub_updated: "{name} {version} terkirim",
+hub_update_failed: "Tidak dapat memperbarui {name}",
+hub_stop: "Hentikan",
+hub_manage: "Kelola",
+hub_pm_summary: "Payload tersimpan: {n}",
+hub_smp_summary: "Image game terpasang: {n}",
+hb_no_answer: "HearBridge tidak menjawab",
+hb_checking: "Membaca status HearBridge…",
+hb_state_streaming: "Mengalirkan",
+hb_state_connecting: "Menghubungkan",
+hb_state_reconnecting: "Menghubungkan ulang",
+hb_state_scanning: "Memindai",
+hb_state_waiting: "Menunggu headset",
+hb_state_error: "Galat",
+hb_battery: "Baterai {pct}%",
+hb_link: "Koneksi {score}%",
+hb_latency: "jeda {ms} ms",
+hb_muted: "Dibisukan",
+hb_volume: "Volume suara {pct}%",
+hb_game: "Profil: {name}",
+hb_scan_confirm_title: "Pindai headset?",
+hb_scan_confirm_body: "Pemindaian menghentikan audio headset selama berjalan (sekitar 12 detik).",
+hb_scan: "Pindai headset",
+hb_open_page: "Buka halaman HearBridge",
+hb_action_failed: "HearBridge tidak menerima perubahan itu",
+hb_volume_label: "Volume",
+hb_mute: "Bisukan",
+hb_latency_label: "Latensi",
+hb_latency_auto: "Otomatis",
+hb_night: "Mode malam",
+hb_night_hint: "Ledakan lebih pelan, suara lebih jelas.",
+hb_headsets: "Headset",
+hb_reconnect: "Hubungkan ulang",
+hb_current: "Digunakan",
+hb_switch: "Beralih ke ini",
+hb_saved_empty: "Belum ada headset tersimpan. Pindai satu, atau pasangkan di halaman HearBridge.",
+hb_scan_found: "Ditemukan pada pemindaian terakhir:",
+hb_scan_empty: "Tidak ada yang ditemukan. Masukkan headset ke mode pemasangan lalu pindai lagi.",
+hb_connect: "Hubungkan",
+hb_events: "Peristiwa terbaru",
+hb_page_hint: "Memasangkan headset baru, ekualiser, cadangan, dan semua pengaturan lainnya ada di halaman HearBridge sendiri.",
+hb_stop_title: "Hentikan HearBridge?",
+hb_stop_body: "HearBridge ditutup dan headset terputus. Kirim lagi untuk menjalankannya.",
payloads_description_ports: "Port yang dipakai di PS5 dan apa yang biasanya mendengarkan di tiap port, agar tahu payload mana di mana. Buka yang punya halaman web di browser.",
"ports.scan": "Pindai lagi",
"ports.scanning": "Memeriksa setiap port di PS5…",
diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts
index 9e39a874..e5cd616b 100644
--- a/client/src/i18n/locales/it.ts
+++ b/client/src/i18n/locales/it.ts
@@ -771,15 +771,12 @@ payloads_download: "Scarica {version}",
payloads_load_failed: "Catalogo non disponibile",
payloads_loading: "Caricamento catalogo…",
payloads_no_host: "Nessun host PS5 impostato. Connettiti prima dalla schermata Connessione.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "I payload conservati sulla PS5 da Payload Manager (itsPLK): avviali o eliminali e scegli quali carica all'avvio e in che ordine. Salva lì un payload del catalogo dalla scheda Catalogo.",
payloads_pm_store: "Salva in Payload Manager",
payloads_pm_store_tooltip: "Conserva questo file sulla PS5 in Payload Manager, per avviarlo da lì o caricarlo all'avvio.",
payloads_pm_store_confirm_title: "Sostituire la cartella \"{folder}\" di Payload Manager?",
payloads_pm_store_confirm_same: "Payload Manager ha già {name}. Salvarlo lo sostituisce e rimuove ogni altro file in quella cartella.",
payloads_pm_store_confirm_other: "Payload Manager ha già una cartella \"{folder}\". Salvare lì rimuove gli altri file (di solito una versione precedente).",
payloads_pm_stored_title: "{name} salvato in Payload Manager",
-payloads_pm_stored_body: "È nella cartella \"{folder}\" di Payload Manager. La scheda Payload Manager lo avvia o lo carica all'avvio.",
payloads_pm_store_failed: "Impossibile salvare {id} in Payload Manager",
pm_launched: "{name} avviato",
pm_launched_body: "Controlla la notifica sulla PS5.",
@@ -2432,10 +2429,6 @@ helper_mismatch_go: "Ricarica helper",
"fpkg.checkDrm": "Che il valore DRM del pacchetto sia standard. Un valore gratuito o aggiornabile fa bloccare il titolo dalla console; viene riscritto solo nel pacchetto e il tuo file non viene mai toccato.",
"payloads_essentials_title": "Consigliati insieme a ps5upload",
"payloads_essentials_body": "Se stai configurando una console per la prima volta, questi tre coprono quasi tutto ciò che ps5upload si aspetta di trovare: kstuff-lite (patch del kernel su cui si basa la maggior parte dell’homebrew), ShadowMount+ (monta immagini di gioco dalla memoria) e nanoDNS (tiene la console lontana dagli aggiornamenti di Sony). Tutto il resto dell’elenco è facoltativo.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Controlla lo stato di ShadowMount+, le immagini di gioco montate, la configurazione e la diagnostica sulla PS5 selezionata.",
-"payloads_description_nanodns": "Configura il payload nanoDNS, verifica la versione in esecuzione e applica migrazioni di configurazione sicure.",
"navigation_history": "Mostra cronologia",
"navigation_back": "Indietro",
"navigation_forward": "Avanti",
@@ -3882,6 +3875,70 @@ upload_phase_settling_rate: "file/s",
upload_status_resuming: "Ripresa: {done} di {total} file già presenti ({have}); {send} da inviare.",
volumes_kept_by_console: "{kept} riservati dalla console per uso proprio",
payloads_tab_ports: "Porte",
+processes_and_ports: "Processi e porte",
+payloads_tab_ps5: "Sulla tua PS5",
+payloads_description_ps5: "I payload in esecuzione o installati sulla PS5 selezionata. Apri la pagina web di un payload, aggiornalo, fermalo o gestiscilo.",
+payloads_pm_stored_body_v2: "Si trova nella cartella \"{folder}\" di Payload Manager. Payload Manager, nella scheda Sulla tua PS5, lo avvia o lo carica all'avvio.",
+hub_checking: "Controllo di cosa c'è su questa PS5…",
+hub_count: "{n} su questa PS5",
+hub_error: "Impossibile leggere cosa è in esecuzione sulla PS5",
+hub_empty_title: "Nessun elemento trovato su questa PS5",
+hub_empty_body: "Nessun payload del catalogo è in esecuzione o installato su questa console. Il Catalogo li ha, e Invia carica qualsiasi file payload.",
+hub_empty_body_web: "Nessun payload noto è in esecuzione o installato su questa console.",
+hub_state_running: "In esecuzione",
+hub_state_installed: "Installato",
+hub_state_unknown: "Non trovato",
+hub_running_detail: "{name} · processo {pid} · {mb} MB",
+hub_installed_detail: "Sulla console, non in esecuzione.",
+hub_unknown_detail: "Non trovato su questa PS5.",
+hub_not_catalogued: "Non nel catalogo",
+hub_open_page: "Apri pagina web",
+hub_update: "Aggiorna a {version}",
+hub_update_confirm_title: "Aggiornare {name} a {version}?",
+hub_update_confirm_body: "Scarica {version} e lo invia alla PS5. Controlla la notifica sulla PS5.",
+hub_updated: "{name} {version} inviato",
+hub_update_failed: "Impossibile aggiornare {name}",
+hub_stop: "Ferma",
+hub_manage: "Gestisci",
+hub_pm_summary: "Payload memorizzati: {n}",
+hub_smp_summary: "Immagini di gioco montate: {n}",
+hb_no_answer: "HearBridge non ha risposto",
+hb_checking: "Lettura dello stato di HearBridge…",
+hb_state_streaming: "In streaming",
+hb_state_connecting: "Connessione",
+hb_state_reconnecting: "Riconnessione",
+hb_state_scanning: "Ricerca",
+hb_state_waiting: "In attesa di cuffie",
+hb_state_error: "Errore",
+hb_battery: "Batteria {pct}%",
+hb_link: "Collegamento {score}%",
+hb_latency: "{ms} ms di ritardo",
+hb_muted: "Disattivato",
+hb_volume: "Volume al {pct}%",
+hb_game: "Profilo: {name}",
+hb_scan_confirm_title: "Cercare cuffie?",
+hb_scan_confirm_body: "La ricerca interrompe l'audio delle cuffie mentre è in corso (circa 12 secondi).",
+hb_scan: "Cerca cuffie",
+hb_open_page: "Apri la pagina di HearBridge",
+hb_action_failed: "HearBridge non ha accettato la modifica",
+hb_volume_label: "Volume",
+hb_mute: "Disattiva audio",
+hb_latency_label: "Latenza",
+hb_latency_auto: "Automatica",
+hb_night: "Modalità notte",
+hb_night_hint: "Esplosioni più attenuate, voci più chiare.",
+hb_headsets: "Cuffie",
+hb_reconnect: "Riconnetti",
+hb_current: "In uso",
+hb_switch: "Passa a queste",
+hb_saved_empty: "Nessuna cuffia salvata. Cercane una o associala dalla pagina di HearBridge.",
+hb_scan_found: "Trovate nell'ultima ricerca:",
+hb_scan_empty: "Nessun risultato. Metti le cuffie in modalità di associazione e cerca di nuovo.",
+hb_connect: "Connetti",
+hb_events: "Eventi recenti",
+hb_page_hint: "L'associazione di nuove cuffie, l'equalizzatore, i backup e tutte le altre impostazioni sono nella pagina di HearBridge.",
+hb_stop_title: "Fermare HearBridge?",
+hb_stop_body: "HearBridge si chiude e le cuffie si disconnettono. Invialo di nuovo per avviarlo.",
payloads_description_ports: "Le porte in uso sulla PS5 e cosa di solito vi ascolta, per capire quale payload è dove. Apri nel browser quelle con una pagina web.",
"ports.scan": "Controlla di nuovo",
"ports.scanning": "Controllo di tutte le porte della PS5…",
diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts
index 372048f0..741fe159 100644
--- a/client/src/i18n/locales/ja.ts
+++ b/client/src/i18n/locales/ja.ts
@@ -772,15 +772,12 @@ payloads_download: "{version} をダウンロード",
payloads_load_failed: "カタログを読み込めませんでした",
payloads_loading: "カタログを読み込み中…",
payloads_no_host: "PS5 のホストが未設定です。まず接続画面で接続してください。",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager (itsPLK) が PS5 に保存しているペイロード。起動や削除、起動時に読み込むものとその順序を選べます。カタログのペイロードはカタログタブから保存できます。",
payloads_pm_store: "Payload Manager に保存",
payloads_pm_store_tooltip: "このファイルを PS5 の Payload Manager に保持し、そこから起動したり起動時に読み込めるようにします。",
payloads_pm_store_confirm_title: "Payload Manager の「{folder}」フォルダーを置き換えますか?",
payloads_pm_store_confirm_same: "Payload Manager には {name} が既にあります。保存すると置き換えられ、そのフォルダー内の他のファイルは削除されます。",
payloads_pm_store_confirm_other: "Payload Manager には「{folder}」フォルダーが既にあります。そこに保存すると中の他のファイル(通常は古いバージョン)が削除されます。",
payloads_pm_stored_title: "{name} を Payload Manager に保存しました",
-payloads_pm_stored_body: "Payload Manager の「{folder}」フォルダーにあります。Payload Manager タブから起動したり、起動時に読み込めます。",
payloads_pm_store_failed: "{id} を Payload Manager に保存できませんでした",
pm_launched: "{name} を起動しました",
pm_launched_body: "PS5 の通知を確認してください。",
@@ -2503,10 +2500,6 @@ helper_mismatch_go: "ヘルパーを再読み込み",
"fpkg.checkDrm": "パッケージの DRM 値が標準であること。無料またはアップグレード可能な値だと本体がタイトルをロックします。書き換えるのはパッケージ内だけで、元のファイルには一切触れません。",
"payloads_essentials_title": "ps5upload と一緒に使うのがおすすめ",
"payloads_essentials_body": "初めて本体をセットアップする場合、この 3 つで ps5upload が必要とするもののほとんどがそろいます:kstuff-lite(多くの自作ソフトが依存するカーネルパッチ)、ShadowMount+(ストレージからゲームイメージをマウント)、nanoDNS(本体を Sony のアップデート経路から遠ざける)。リストのほかのものは任意です。",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "選択した PS5 の ShadowMount+ の状態、マウント中のゲームイメージ、設定、診断を確認します。",
-"payloads_description_nanodns": "nanoDNS ペイロードを設定し、実行中のバージョンを確認し、安全な設定移行を適用します。",
"navigation_history": "履歴を表示",
"navigation_back": "戻る",
"navigation_forward": "進む",
@@ -3953,6 +3946,70 @@ upload_phase_settling_rate: "ファイル/秒",
upload_status_resuming: "再開中: {total} ファイル中 {done} 個はすでにあります({have})。{send} を送信します。",
volumes_kept_by_console: "{kept} は本体が自身の用途のために確保",
payloads_tab_ports: "ポート",
+processes_and_ports: "プロセスとポート",
+payloads_tab_ps5: "PS5 上",
+payloads_description_ps5: "選択した PS5 で実行中またはインストール済みのペイロードです。ペイロードの Web ページを開いたり、更新・停止・管理したりできます。",
+payloads_pm_stored_body_v2: "Payload Manager の「{folder}」フォルダーにあります。「PS5 上」タブの Payload Manager から起動するか、起動時に読み込めます。",
+hub_checking: "この PS5 の内容を確認しています…",
+hub_count: "この PS5 に {n} 件",
+hub_error: "PS5 で実行中のものを読み取れませんでした",
+hub_empty_title: "この PS5 では何も見つかりませんでした",
+hub_empty_body: "カタログのペイロードはこの本体で実行もインストールもされていません。カタログから入手でき、「送信」で任意のペイロードファイルを読み込めます。",
+hub_empty_body_web: "この本体で実行中またはインストール済みの既知のペイロードはありません。",
+hub_state_running: "実行中",
+hub_state_installed: "インストール済み",
+hub_state_unknown: "見つかりません",
+hub_running_detail: "{name} · PID {pid} · {mb} MB",
+hub_installed_detail: "本体にありますが、実行されていません。",
+hub_unknown_detail: "この PS5 には見つかりません。",
+hub_not_catalogued: "カタログにありません",
+hub_open_page: "Web ページを開く",
+hub_update: "{version} に更新",
+hub_update_confirm_title: "{name} を {version} に更新しますか?",
+hub_update_confirm_body: "{version} をダウンロードして PS5 に送信します。PS5 の通知を確認してください。",
+hub_updated: "{name} {version} を送信しました",
+hub_update_failed: "{name} を更新できませんでした",
+hub_stop: "停止",
+hub_manage: "管理",
+hub_pm_summary: "保存済みペイロード: {n}",
+hub_smp_summary: "マウント済みゲームイメージ: {n}",
+hb_no_answer: "HearBridge が応答しませんでした",
+hb_checking: "HearBridge の状態を読み取っています…",
+hb_state_streaming: "ストリーミング中",
+hb_state_connecting: "接続中",
+hb_state_reconnecting: "再接続中",
+hb_state_scanning: "スキャン中",
+hb_state_waiting: "ヘッドセット待ち",
+hb_state_error: "エラー",
+hb_battery: "バッテリー {pct}%",
+hb_link: "接続品質 {score}%",
+hb_latency: "遅延 {ms} ms",
+hb_muted: "ミュート中",
+hb_volume: "音量 {pct}%",
+hb_game: "プロファイル: {name}",
+hb_scan_confirm_title: "ヘッドセットを探しますか?",
+hb_scan_confirm_body: "スキャン中(約 12 秒)はヘッドセットの音声が止まります。",
+hb_scan: "ヘッドセットを探す",
+hb_open_page: "HearBridge のページを開く",
+hb_action_failed: "HearBridge が変更を受け付けませんでした",
+hb_volume_label: "音量",
+hb_mute: "ミュート",
+hb_latency_label: "遅延",
+hb_latency_auto: "自動",
+hb_night: "ナイトモード",
+hb_night_hint: "爆発音を抑え、声を聞き取りやすくします。",
+hb_headsets: "ヘッドセット",
+hb_reconnect: "再接続",
+hb_current: "使用中",
+hb_switch: "これに切り替える",
+hb_saved_empty: "保存済みのヘッドセットはまだありません。スキャンするか、HearBridge のページでペアリングしてください。",
+hb_scan_found: "前回のスキャンで見つかったもの:",
+hb_scan_empty: "見つかりませんでした。ヘッドセットをペアリングモードにして、もう一度スキャンしてください。",
+hb_connect: "接続",
+hb_events: "最近のイベント",
+hb_page_hint: "新しいヘッドセットのペアリング、イコライザー、バックアップなどの設定は HearBridge 自身のページにあります。",
+hb_stop_title: "HearBridge を停止しますか?",
+hb_stop_body: "HearBridge が終了し、ヘッドセットの接続が切れます。起動するには再送信してください。",
payloads_description_ports: "PS5 で使用中のポートと、それぞれで通常待ち受けているもの。どのペイロードがどこにあるか分かります。Web ページがあるものはブラウザで開けます。",
"ports.scan": "再スキャン",
"ports.scanning": "PS5 の全ポートを確認中…",
diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts
index 5bd04ac4..0948f90e 100644
--- a/client/src/i18n/locales/ko.ts
+++ b/client/src/i18n/locales/ko.ts
@@ -772,15 +772,12 @@ payloads_download: "{version} 다운로드",
payloads_load_failed: "카탈로그를 불러올 수 없습니다",
payloads_loading: "카탈로그 불러오는 중…",
payloads_no_host: "PS5 호스트가 설정되지 않았습니다. 먼저 연결 화면에서 연결하세요.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager(itsPLK)가 PS5에 보관하는 페이로드입니다. 실행하거나 삭제하고, 부팅 시 어떤 것을 어떤 순서로 불러올지 고르세요. 카탈로그의 페이로드는 카탈로그 탭에서 저장할 수 있습니다.",
payloads_pm_store: "Payload Manager에 저장",
payloads_pm_store_tooltip: "이 파일을 PS5의 Payload Manager에 보관하여 거기서 실행하거나 부팅 시 불러옵니다.",
payloads_pm_store_confirm_title: "Payload Manager의 \"{folder}\" 폴더를 교체할까요?",
payloads_pm_store_confirm_same: "Payload Manager에 {name}이(가) 이미 있습니다. 저장하면 교체되고 그 폴더의 다른 파일은 삭제됩니다.",
payloads_pm_store_confirm_other: "Payload Manager에 \"{folder}\" 폴더가 이미 있습니다. 거기에 저장하면 다른 파일(보통 이전 버전)이 삭제됩니다.",
payloads_pm_stored_title: "{name}을(를) Payload Manager에 저장했습니다",
-payloads_pm_stored_body: "Payload Manager의 \"{folder}\" 폴더에 있습니다. Payload Manager 탭에서 실행하거나 부팅 시 불러올 수 있습니다.",
payloads_pm_store_failed: "{id}을(를) Payload Manager에 저장하지 못했습니다",
pm_launched: "{name} 실행됨",
pm_launched_body: "PS5의 알림을 확인하세요.",
@@ -2501,10 +2498,6 @@ helper_mismatch_go: "헬퍼 다시 불러오기",
"fpkg.checkDrm": "패키지의 DRM 값이 표준인지 확인합니다. 무료 또는 업그레이드 가능 값이면 콘솔이 타이틀을 잠급니다. 값은 패키지 안에서만 바꾸며 원본 파일은 절대 건드리지 않습니다.",
"payloads_essentials_title": "ps5upload와 함께 권장",
"payloads_essentials_body": "콘솔을 처음 설정한다면 이 세 가지로 ps5upload가 기대하는 대부분이 갖춰집니다: kstuff-lite(대부분의 홈브루가 의존하는 커널 패치), ShadowMount+(저장소의 게임 이미지 마운트), nanoDNS(콘솔을 Sony 업데이트 경로에서 떼어 놓음). 목록의 나머지는 선택 사항입니다.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "선택한 PS5에서 ShadowMount+ 상태, 마운트된 게임 이미지, 설정, 진단을 확인합니다.",
-"payloads_description_nanodns": "nanoDNS 페이로드를 설정하고, 실행 중인 버전을 확인하고, 안전한 설정 마이그레이션을 적용합니다.",
"navigation_history": "기록 보기",
"navigation_back": "뒤로",
"navigation_forward": "앞으로",
@@ -3951,6 +3944,70 @@ upload_phase_settling_rate: "파일/초",
upload_status_resuming: "재개 중: 파일 {total}개 중 {done}개가 이미 있습니다({have}). {send} 보낼 예정.",
volumes_kept_by_console: "{kept}은(는) 본체가 자체 용도로 사용",
payloads_tab_ports: "포트",
+processes_and_ports: "프로세스 및 포트",
+payloads_tab_ps5: "내 PS5",
+payloads_description_ps5: "선택한 PS5에서 실행 중이거나 설치된 페이로드입니다. 페이로드의 웹 페이지를 열거나 업데이트, 중지, 관리할 수 있습니다.",
+payloads_pm_stored_body_v2: "Payload Manager의 \"{folder}\" 폴더에 있습니다. \"내 PS5\" 탭의 Payload Manager에서 실행하거나 부팅 시 로드할 수 있습니다.",
+hub_checking: "이 PS5에 있는 항목을 확인하는 중…",
+hub_count: "이 PS5에 {n}개",
+hub_error: "PS5에서 실행 중인 항목을 읽지 못했습니다",
+hub_empty_title: "이 PS5에서 찾은 항목이 없습니다",
+hub_empty_body: "카탈로그의 페이로드가 이 콘솔에서 실행 중이거나 설치되어 있지 않습니다. 카탈로그에서 찾을 수 있고, 보내기로 어떤 페이로드 파일이든 로드할 수 있습니다.",
+hub_empty_body_web: "이 콘솔에서 실행 중이거나 설치된 알려진 페이로드가 없습니다.",
+hub_state_running: "실행 중",
+hub_state_installed: "설치됨",
+hub_state_unknown: "찾을 수 없음",
+hub_running_detail: "{name} · PID {pid} · {mb} MB",
+hub_installed_detail: "콘솔에 있지만 실행 중이 아닙니다.",
+hub_unknown_detail: "이 PS5에서 찾을 수 없습니다.",
+hub_not_catalogued: "카탈로그에 없음",
+hub_open_page: "웹 페이지 열기",
+hub_update: "{version}(으)로 업데이트",
+hub_update_confirm_title: "{name}을(를) {version}(으)로 업데이트할까요?",
+hub_update_confirm_body: "{version}을(를) 다운로드해 PS5로 보냅니다. PS5의 알림을 확인하세요.",
+hub_updated: "{name} {version} 보냄",
+hub_update_failed: "{name}을(를) 업데이트하지 못했습니다",
+hub_stop: "중지",
+hub_manage: "관리",
+hub_pm_summary: "저장된 페이로드: {n}",
+hub_smp_summary: "마운트된 게임 이미지: {n}",
+hb_no_answer: "HearBridge가 응답하지 않았습니다",
+hb_checking: "HearBridge 상태를 읽는 중…",
+hb_state_streaming: "스트리밍 중",
+hb_state_connecting: "연결 중",
+hb_state_reconnecting: "다시 연결 중",
+hb_state_scanning: "검색 중",
+hb_state_waiting: "헤드셋 대기 중",
+hb_state_error: "오류",
+hb_battery: "배터리 {pct}%",
+hb_link: "연결 품질 {score}%",
+hb_latency: "지연 {ms}ms",
+hb_muted: "음소거됨",
+hb_volume: "볼륨 {pct}%",
+hb_game: "프로필: {name}",
+hb_scan_confirm_title: "헤드셋을 검색할까요?",
+hb_scan_confirm_body: "검색하는 동안(약 12초) 헤드셋 오디오가 멈춥니다.",
+hb_scan: "헤드셋 검색",
+hb_open_page: "HearBridge 페이지 열기",
+hb_action_failed: "HearBridge가 변경을 받아들이지 않았습니다",
+hb_volume_label: "볼륨",
+hb_mute: "음소거",
+hb_latency_label: "지연 시간",
+hb_latency_auto: "자동",
+hb_night: "야간 모드",
+hb_night_hint: "폭발음은 작게, 목소리는 더 또렷하게.",
+hb_headsets: "헤드셋",
+hb_reconnect: "다시 연결",
+hb_current: "사용 중",
+hb_switch: "이것으로 전환",
+hb_saved_empty: "저장된 헤드셋이 아직 없습니다. 검색하거나 HearBridge 페이지에서 페어링하세요.",
+hb_scan_found: "마지막 검색에서 찾은 항목:",
+hb_scan_empty: "찾은 항목이 없습니다. 헤드셋을 페어링 모드로 두고 다시 검색하세요.",
+hb_connect: "연결",
+hb_events: "최근 이벤트",
+hb_page_hint: "새 헤드셋 페어링, 이퀄라이저, 백업 등 다른 모든 설정은 HearBridge 자체 페이지에 있습니다.",
+hb_stop_title: "HearBridge를 중지할까요?",
+hb_stop_body: "HearBridge가 종료되고 헤드셋 연결이 끊어집니다. 시작하려면 다시 보내세요.",
payloads_description_ports: "PS5에서 사용 중인 포트와 각 포트에서 보통 대기하는 것을 보여 줘, 어떤 페이로드가 어디 있는지 알 수 있습니다. 웹 페이지가 있는 포트는 브라우저에서 엽니다.",
"ports.scan": "다시 검사",
"ports.scanning": "PS5의 모든 포트를 확인하는 중…",
diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts
index f209fd5f..12134915 100644
--- a/client/src/i18n/locales/pl.ts
+++ b/client/src/i18n/locales/pl.ts
@@ -1051,15 +1051,12 @@ payloads_load_failed: "Nie można załadować katalogu",
payloads_inventory_failed: "Nie można odczytać lokalnego cache'u payloadów",
payloads_loading: "Ładowanie katalogu…",
payloads_no_host: "Nie ustawiono hosta PS5. Najpierw połącz się na ekranie Połączenie.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payloady przechowywane na PS5 przez Payload Manager (itsPLK): uruchamiaj lub usuwaj je i wybieraj, które ładuje przy starcie i w jakiej kolejności. Payload z katalogu zapiszesz tam z karty Katalog.",
payloads_pm_store: "Zapisz w Payload Managerze",
payloads_pm_store_tooltip: "Zachowaj ten plik na PS5 w Payload Managerze, aby uruchamiać go stamtąd lub ładować przy starcie.",
payloads_pm_store_confirm_title: "Zastąpić folder „{folder}” Payload Managera?",
payloads_pm_store_confirm_same: "Payload Manager ma już {name}. Zapisanie zastąpi go i usunie pozostałe pliki z tego folderu.",
payloads_pm_store_confirm_other: "Payload Manager ma już folder „{folder}”. Zapisanie w nim usunie pozostałe pliki (zwykle starszą wersję).",
payloads_pm_stored_title: "Zapisano {name} w Payload Managerze",
-payloads_pm_stored_body: "Jest w folderze „{folder}” Payload Managera. Karta Payload Manager uruchamia go lub ładuje przy starcie.",
payloads_pm_store_failed: "Nie udało się zapisać {id} w Payload Managerze",
pm_launched: "Uruchomiono {name}",
pm_launched_body: "Sprawdź powiadomienie na PS5.",
@@ -2737,10 +2734,6 @@ helper_mismatch_go: "Załaduj pomocnika",
"fpkg.checkDrm": "Czy wartość DRM pakietu jest standardowa. Wartość darmowa lub z możliwością ulepszenia sprawia, że konsola blokuje tytuł; jest przepisywana tylko w pakiecie, a twój plik nigdy nie jest zmieniany.",
"payloads_essentials_title": "Zalecane razem z ps5upload",
"payloads_essentials_body": "Jeśli konfigurujesz konsolę po raz pierwszy, te trzy pokrywają większość tego, czego oczekuje ps5upload: kstuff-lite (łatki jądra, na których opiera się większość homebrew), ShadowMount+ (montowanie obrazów gier z pamięci) i nanoDNS (trzyma konsolę z dala od aktualizacji Sony). Wszystko inne na tej liście jest opcjonalne.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Sprawdź stan ShadowMount+, zamontowane obrazy gier, konfigurację i diagnostykę na wybranej PS5.",
-"payloads_description_nanodns": "Skonfiguruj payload nanoDNS, sprawdź działającą wersję i zastosuj bezpieczne migracje konfiguracji.",
"navigation_history": "Pokaż historię",
"navigation_back": "Wstecz",
"navigation_forward": "Dalej",
@@ -4187,6 +4180,70 @@ upload_phase_settling_rate: "pliki/s",
upload_status_resuming: "Wznawianie: {done} z {total} plików już jest ({have}); do wysłania {send}.",
volumes_kept_by_console: "{kept} zarezerwowane przez konsolę na własny użytek",
payloads_tab_ports: "Porty",
+processes_and_ports: "Procesy i porty",
+payloads_tab_ps5: "Na Twojej PS5",
+payloads_description_ps5: "Payloady uruchomione lub zainstalowane na wybranej PS5. Otwórz stronę WWW payloadu, zaktualizuj go, zatrzymaj lub zarządzaj nim.",
+payloads_pm_stored_body_v2: "Jest w folderze „{folder}” w Payload Managerze. Payload Manager na karcie Na Twojej PS5 uruchamia go lub ładuje przy starcie.",
+hub_checking: "Sprawdzanie, co jest na tej PS5…",
+hub_count: "{n} na tej PS5",
+hub_error: "Nie udało się odczytać, co działa na PS5",
+hub_empty_title: "Nic nie znaleziono na tej PS5",
+hub_empty_body: "Żaden payload z katalogu nie działa ani nie jest zainstalowany na tej konsoli. Znajdziesz je w Katalogu, a Wyślij ładuje dowolny plik payloadu.",
+hub_empty_body_web: "Na tej konsoli nie działa ani nie jest zainstalowany żaden znany payload.",
+hub_state_running: "Działa",
+hub_state_installed: "Zainstalowany",
+hub_state_unknown: "Nie znaleziono",
+hub_running_detail: "{name} · PID {pid} · {mb} MB",
+hub_installed_detail: "Jest na konsoli, nie działa.",
+hub_unknown_detail: "Nie znaleziono na tej PS5.",
+hub_not_catalogued: "Nie ma w katalogu",
+hub_open_page: "Otwórz stronę WWW",
+hub_update: "Zaktualizuj do {version}",
+hub_update_confirm_title: "Zaktualizować {name} do {version}?",
+hub_update_confirm_body: "Pobiera {version} i wysyła na PS5. Sprawdź powiadomienie na PS5.",
+hub_updated: "Wysłano {name} {version}",
+hub_update_failed: "Nie udało się zaktualizować {name}",
+hub_stop: "Zatrzymaj",
+hub_manage: "Zarządzaj",
+hub_pm_summary: "Zapisane payloady: {n}",
+hub_smp_summary: "Zamontowane obrazy gier: {n}",
+hb_no_answer: "HearBridge nie odpowiedział",
+hb_checking: "Odczytywanie stanu HearBridge…",
+hb_state_streaming: "Przesyła dźwięk",
+hb_state_connecting: "Łączenie",
+hb_state_reconnecting: "Ponowne łączenie",
+hb_state_scanning: "Wyszukiwanie",
+hb_state_waiting: "Czeka na słuchawki",
+hb_state_error: "Błąd",
+hb_battery: "Bateria {pct}%",
+hb_link: "Łącze {score}%",
+hb_latency: "opóźnienie {ms} ms",
+hb_muted: "Wyciszony",
+hb_volume: "Głośność {pct}%",
+hb_game: "Profil: {name}",
+hb_scan_confirm_title: "Wyszukać słuchawki?",
+hb_scan_confirm_body: "Wyszukiwanie przerywa dźwięk w słuchawkach na czas jego trwania (około 12 sekund).",
+hb_scan: "Wyszukaj słuchawki",
+hb_open_page: "Otwórz stronę HearBridge",
+hb_action_failed: "HearBridge nie przyjął tej zmiany",
+hb_volume_label: "Głośność",
+hb_mute: "Wycisz",
+hb_latency_label: "Opóźnienie",
+hb_latency_auto: "Automatyczne",
+hb_night: "Tryb nocny",
+hb_night_hint: "Cichsze wybuchy, wyraźniejsze głosy.",
+hb_headsets: "Słuchawki",
+hb_reconnect: "Połącz ponownie",
+hb_current: "W użyciu",
+hb_switch: "Przełącz na te",
+hb_saved_empty: "Brak zapisanych słuchawek. Wyszukaj je albo sparuj na stronie HearBridge.",
+hb_scan_found: "Znalezione podczas ostatniego wyszukiwania:",
+hb_scan_empty: "Nic nie znaleziono. Przełącz słuchawki w tryb parowania i wyszukaj ponownie.",
+hb_connect: "Połącz",
+hb_events: "Ostatnie zdarzenia",
+hb_page_hint: "Parowanie nowych słuchawek, korektor, kopie zapasowe i wszystkie inne ustawienia są na stronie HearBridge.",
+hb_stop_title: "Zatrzymać HearBridge?",
+hb_stop_body: "HearBridge się zamknie, a słuchawki rozłączą. Wyślij go ponownie, aby uruchomić.",
payloads_description_ports: "Porty używane na PS5 i to, co zwykle na nich nasłuchuje, żeby wiedzieć, który payload jest gdzie. Te ze stroną WWW otwórz w przeglądarce.",
"ports.scan": "Skanuj ponownie",
"ports.scanning": "Sprawdzanie wszystkich portów PS5…",
diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts
index d977c173..996f8ff2 100644
--- a/client/src/i18n/locales/pt-BR.ts
+++ b/client/src/i18n/locales/pt-BR.ts
@@ -771,15 +771,12 @@ payloads_download: "Baixar {version}",
payloads_load_failed: "Não foi possível carregar o catálogo",
payloads_loading: "Carregando catálogo…",
payloads_no_host: "Nenhum host de PS5 definido. Conecte-se primeiro na tela Conexão.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Os payloads guardados no PS5 pelo Payload Manager (itsPLK): inicie ou exclua, e escolha quais ele carrega na inicialização e em que ordem. Guarde um payload do catálogo lá pela aba Catálogo.",
payloads_pm_store: "Guardar no Payload Manager",
payloads_pm_store_tooltip: "Mantém este arquivo no PS5 dentro do Payload Manager, para iniciá-lo lá ou carregá-lo na inicialização.",
payloads_pm_store_confirm_title: "Substituir a pasta \"{folder}\" do Payload Manager?",
payloads_pm_store_confirm_same: "O Payload Manager já tem {name}. Guardar substitui o arquivo e remove qualquer outro arquivo dessa pasta.",
payloads_pm_store_confirm_other: "O Payload Manager já tem uma pasta \"{folder}\". Guardar nela remove os outros arquivos (geralmente uma versão anterior).",
payloads_pm_stored_title: "{name} guardado no Payload Manager",
-payloads_pm_stored_body: "Está na pasta \"{folder}\" do Payload Manager. A aba Payload Manager o inicia ou o carrega na inicialização.",
payloads_pm_store_failed: "Não foi possível guardar {id} no Payload Manager",
pm_launched: "{name} iniciado",
pm_launched_body: "Veja a notificação no PS5.",
@@ -2499,10 +2496,6 @@ helper_mismatch_go: "Recarregar auxiliar",
"fpkg.checkDrm": "Que o valor de DRM do pacote seja o padrão. Um valor gratuito ou atualizável faz o console bloquear o título; ele é reescrito só no pacote, e o seu arquivo nunca é alterado.",
"payloads_essentials_title": "Recomendados junto com o ps5upload",
"payloads_essentials_body": "Se você está configurando um console pela primeira vez, estes três cobrem quase tudo o que o ps5upload espera encontrar: kstuff-lite (patches de kernel dos quais a maior parte do homebrew depende), ShadowMount+ (monta imagens de jogos a partir do armazenamento) e nanoDNS (mantém o console fora do caminho de atualização da Sony). Todo o resto da lista é opcional.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Veja o status do ShadowMount+, as imagens de jogos montadas, a configuração e o diagnóstico no PS5 selecionado.",
-"payloads_description_nanodns": "Configure o payload nanoDNS, verifique a versão em execução e aplique migrações de configuração seguras.",
"navigation_history": "Ver histórico",
"navigation_back": "Voltar",
"navigation_forward": "Avançar",
@@ -3949,6 +3942,70 @@ upload_phase_settling_rate: "arquivos/s",
upload_status_resuming: "Retomando: {done} de {total} arquivos já presentes ({have}); {send} a enviar.",
volumes_kept_by_console: "{kept} reservados pelo console para uso próprio",
payloads_tab_ports: "Portas",
+processes_and_ports: "Processos e portas",
+payloads_tab_ps5: "No seu PS5",
+payloads_description_ps5: "Os payloads em execução ou instalados no PS5 selecionado. Abra a página web de um payload, atualize, pare ou gerencie.",
+payloads_pm_stored_body_v2: "Ele está na pasta \"{folder}\" do Payload Manager. O Payload Manager, na aba No seu PS5, o inicia ou carrega na inicialização.",
+hub_checking: "Verificando o que há neste PS5…",
+hub_count: "{n} neste PS5",
+hub_error: "Não foi possível ler o que está em execução no PS5",
+hub_empty_title: "Nada encontrado neste PS5",
+hub_empty_body: "Nenhum payload do catálogo está em execução ou instalado neste console. O Catálogo tem todos, e Enviar carrega qualquer arquivo de payload.",
+hub_empty_body_web: "Nenhum payload conhecido está em execução ou instalado neste console.",
+hub_state_running: "Em execução",
+hub_state_installed: "Instalado",
+hub_state_unknown: "Não encontrado",
+hub_running_detail: "{name} · processo {pid} · {mb} MB",
+hub_installed_detail: "No console, sem executar.",
+hub_unknown_detail: "Não encontrado neste PS5.",
+hub_not_catalogued: "Fora do catálogo",
+hub_open_page: "Abrir página web",
+hub_update: "Atualizar para {version}",
+hub_update_confirm_title: "Atualizar {name} para {version}?",
+hub_update_confirm_body: "Baixa {version} e envia ao PS5. Confira a notificação no PS5.",
+hub_updated: "{name} {version} enviado",
+hub_update_failed: "Não foi possível atualizar {name}",
+hub_stop: "Parar",
+hub_manage: "Gerenciar",
+hub_pm_summary: "Payloads guardados: {n}",
+hub_smp_summary: "Imagens de jogo montadas: {n}",
+hb_no_answer: "O HearBridge não respondeu",
+hb_checking: "Lendo o status do HearBridge…",
+hb_state_streaming: "Transmitindo",
+hb_state_connecting: "Conectando",
+hb_state_reconnecting: "Reconectando",
+hb_state_scanning: "Procurando",
+hb_state_waiting: "Aguardando um fone",
+hb_state_error: "Erro",
+hb_battery: "Bateria {pct}%",
+hb_link: "Conexão {score}%",
+hb_latency: "{ms} ms de atraso",
+hb_muted: "Mudo",
+hb_volume: "Volume em {pct}%",
+hb_game: "Perfil: {name}",
+hb_scan_confirm_title: "Procurar fones?",
+hb_scan_confirm_body: "A busca interrompe o áudio do fone enquanto dura (cerca de 12 segundos).",
+hb_scan: "Procurar fones",
+hb_open_page: "Abrir a página do HearBridge",
+hb_action_failed: "O HearBridge não aceitou a alteração",
+hb_volume_label: "Volume",
+hb_mute: "Silenciar",
+hb_latency_label: "Latência",
+hb_latency_auto: "Automática",
+hb_night: "Modo noturno",
+hb_night_hint: "Explosões mais baixas, vozes mais claras.",
+hb_headsets: "Fones",
+hb_reconnect: "Reconectar",
+hb_current: "Em uso",
+hb_switch: "Trocar para este",
+hb_saved_empty: "Ainda não há fones salvos. Procure um ou pareie na página do HearBridge.",
+hb_scan_found: "Encontrados na última busca:",
+hb_scan_empty: "Nada encontrado. Coloque o fone em modo de pareamento e procure de novo.",
+hb_connect: "Conectar",
+hb_events: "Eventos recentes",
+hb_page_hint: "Parear um fone novo, o equalizador, os backups e todas as outras opções ficam na própria página do HearBridge.",
+hb_stop_title: "Parar o HearBridge?",
+hb_stop_body: "O HearBridge fecha e o fone desconecta. Envie-o de novo para iniciar.",
payloads_description_ports: "As portas em uso no PS5 e o que costuma escutar em cada uma, para saber qual payload está onde. Abra no navegador as que têm página web.",
"ports.scan": "Verificar de novo",
"ports.scanning": "Verificando todas as portas do PS5…",
diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts
index d4d3f20f..7d9efb59 100644
--- a/client/src/i18n/locales/ru.ts
+++ b/client/src/i18n/locales/ru.ts
@@ -771,15 +771,12 @@ payloads_download: "Скачать {version}",
payloads_load_failed: "Не удалось загрузить каталог",
payloads_loading: "Загрузка каталога…",
payloads_no_host: "Хост PS5 не задан. Сначала подключитесь на экране «Подключение».",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Пейлоады, хранящиеся на PS5 в Payload Manager (itsPLK): запускайте или удаляйте их и выбирайте, какие он загружает при старте и в каком порядке. Сохранить туда пейлоад из каталога можно на вкладке «Каталог».",
payloads_pm_store: "Сохранить в Payload Manager",
payloads_pm_store_tooltip: "Оставить этот файл на PS5 в Payload Manager, чтобы запускать его оттуда или загружать при старте.",
payloads_pm_store_confirm_title: "Заменить папку «{folder}» в Payload Manager?",
payloads_pm_store_confirm_same: "В Payload Manager уже есть {name}. Сохранение заменит его и удалит остальные файлы в этой папке.",
payloads_pm_store_confirm_other: "В Payload Manager уже есть папка «{folder}». Сохранение в неё удалит остальные файлы там (обычно старую версию).",
payloads_pm_stored_title: "{name} сохранён в Payload Manager",
-payloads_pm_stored_body: "Он в папке «{folder}» Payload Manager. Вкладка Payload Manager запускает его или загружает при старте.",
payloads_pm_store_failed: "Не удалось сохранить {id} в Payload Manager",
pm_launched: "{name} запущен",
pm_launched_body: "Проверьте уведомление на PS5.",
@@ -2432,10 +2429,6 @@ helper_mismatch_go: "Перезагрузить помощник",
"fpkg.checkDrm": "Что значение DRM пакета стандартное. Бесплатное или обновляемое значение заставляет консоль заблокировать игру; оно переписывается только в пакете, ваш собственный файл не изменяется.",
"payloads_essentials_title": "Рекомендуется вместе с ps5upload",
"payloads_essentials_body": "Если вы настраиваете консоль впервые, эти три покрывают почти всё, что ожидает ps5upload: kstuff-lite (патчи ядра, на которые опирается большинство homebrew), ShadowMount+ (монтирование образов игр из хранилища) и nanoDNS (держит консоль подальше от обновлений Sony). Всё остальное в этом списке необязательно.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Проверка состояния ShadowMount+, смонтированных образов игр, конфигурации и диагностики на выбранной PS5.",
-"payloads_description_nanodns": "Настройка payload nanoDNS, проверка запущенной версии и безопасная миграция конфигурации.",
"navigation_history": "Открыть историю",
"navigation_back": "Назад",
"navigation_forward": "Вперёд",
@@ -3882,6 +3875,70 @@ upload_phase_settling_rate: "файлов/с",
upload_status_resuming: "Возобновление: {done} из {total} файлов уже на месте ({have}); осталось отправить {send}.",
volumes_kept_by_console: "{kept} зарезервировано консолью для собственных нужд",
payloads_tab_ports: "Порты",
+processes_and_ports: "Процессы и порты",
+payloads_tab_ps5: "На вашей PS5",
+payloads_description_ps5: "Полезные нагрузки, запущенные или установленные на выбранной PS5. Откройте веб-страницу нагрузки, обновите, остановите её или управляйте ею.",
+payloads_pm_stored_body_v2: "Он в папке «{folder}» Payload Manager. Payload Manager на вкладке «На вашей PS5» запускает его или загружает при старте.",
+hub_checking: "Проверка того, что есть на этой PS5…",
+hub_count: "{n} на этой PS5",
+hub_error: "Не удалось прочитать, что запущено на PS5",
+hub_empty_title: "На этой PS5 ничего не найдено",
+hub_empty_body: "Ни одна нагрузка из каталога не запущена и не установлена на этой консоли. Они есть в Каталоге, а «Отправить» загружает любой файл нагрузки.",
+hub_empty_body_web: "На этой консоли не запущено и не установлено ни одной известной нагрузки.",
+hub_state_running: "Запущен",
+hub_state_installed: "Установлен",
+hub_state_unknown: "Не найден",
+hub_running_detail: "{name} · PID {pid} · {mb} МБ",
+hub_installed_detail: "Есть на консоли, не запущен.",
+hub_unknown_detail: "Не найден на этой PS5.",
+hub_not_catalogued: "Нет в каталоге",
+hub_open_page: "Открыть веб-страницу",
+hub_update: "Обновить до {version}",
+hub_update_confirm_title: "Обновить {name} до {version}?",
+hub_update_confirm_body: "Скачивает {version} и отправляет на PS5. Проверьте уведомление на PS5.",
+hub_updated: "{name} {version} отправлен",
+hub_update_failed: "Не удалось обновить {name}",
+hub_stop: "Остановить",
+hub_manage: "Управление",
+hub_pm_summary: "Сохранено нагрузок: {n}",
+hub_smp_summary: "Смонтировано образов игр: {n}",
+hb_no_answer: "HearBridge не ответил",
+hb_checking: "Чтение состояния HearBridge…",
+hb_state_streaming: "Передаёт звук",
+hb_state_connecting: "Подключение",
+hb_state_reconnecting: "Переподключение",
+hb_state_scanning: "Поиск",
+hb_state_waiting: "Ожидание гарнитуры",
+hb_state_error: "Ошибка",
+hb_battery: "Батарея {pct}%",
+hb_link: "Связь {score}%",
+hb_latency: "задержка {ms} мс",
+hb_muted: "Без звука",
+hb_volume: "Громкость {pct}%",
+hb_game: "Профиль: {name}",
+hb_scan_confirm_title: "Искать гарнитуры?",
+hb_scan_confirm_body: "Во время поиска (около 12 секунд) звук на гарнитуре прерывается.",
+hb_scan: "Искать гарнитуры",
+hb_open_page: "Открыть страницу HearBridge",
+hb_action_failed: "HearBridge не принял изменение",
+hb_volume_label: "Громкость",
+hb_mute: "Без звука",
+hb_latency_label: "Задержка",
+hb_latency_auto: "Автоматически",
+hb_night: "Ночной режим",
+hb_night_hint: "Тише взрывы, чётче голоса.",
+hb_headsets: "Гарнитуры",
+hb_reconnect: "Переподключить",
+hb_current: "Используется",
+hb_switch: "Переключиться на эту",
+hb_saved_empty: "Сохранённых гарнитур пока нет. Найдите гарнитуру поиском или сопрягите её на странице HearBridge.",
+hb_scan_found: "Найдено при последнем поиске:",
+hb_scan_empty: "Ничего не найдено. Переведите гарнитуру в режим сопряжения и повторите поиск.",
+hb_connect: "Подключить",
+hb_events: "Последние события",
+hb_page_hint: "Сопряжение новой гарнитуры, эквалайзер, резервные копии и все прочие настройки — на собственной странице HearBridge.",
+hb_stop_title: "Остановить HearBridge?",
+hb_stop_body: "HearBridge закроется, гарнитура отключится. Чтобы запустить его, отправьте его снова.",
payloads_description_ports: "Занятые порты на PS5 и что обычно на них слушает, чтобы понять, какой payload где. Порты с веб-страницей можно открыть в браузере.",
"ports.scan": "Проверить снова",
"ports.scanning": "Проверка всех портов PS5…",
diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts
index 6a230dbb..19fed8ca 100644
--- a/client/src/i18n/locales/th.ts
+++ b/client/src/i18n/locales/th.ts
@@ -771,15 +771,12 @@ payloads_download: "ดาวน์โหลด {version}",
payloads_load_failed: "โหลดแคตตาล็อกไม่ได้",
payloads_loading: "กำลังโหลดแคตตาล็อก…",
payloads_no_host: "ยังไม่ได้ตั้งค่าโฮสต์ PS5 เชื่อมต่อที่หน้าจอการเชื่อมต่อก่อน",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "เพย์โหลดที่ Payload Manager (itsPLK) เก็บไว้บน PS5: เปิดหรือลบ และเลือกว่าตัวไหนจะโหลดตอนบูตตามลำดับใด บันทึกเพย์โหลดจากแค็ตตาล็อกไว้ที่นั่นได้จากแท็บแค็ตตาล็อก",
payloads_pm_store: "บันทึกใน Payload Manager",
payloads_pm_store_tooltip: "เก็บไฟล์นี้ไว้บน PS5 ใน Payload Manager เพื่อเปิดจากที่นั่นหรือโหลดตอนบูต",
payloads_pm_store_confirm_title: "แทนที่โฟลเดอร์ \"{folder}\" ของ Payload Manager หรือไม่",
payloads_pm_store_confirm_same: "Payload Manager มี {name} อยู่แล้ว การบันทึกจะแทนที่และลบไฟล์อื่นในโฟลเดอร์นั้น",
payloads_pm_store_confirm_other: "Payload Manager มีโฟลเดอร์ \"{folder}\" อยู่แล้ว การบันทึกลงไปจะลบไฟล์อื่นในนั้น (มักเป็นเวอร์ชันเก่า)",
payloads_pm_stored_title: "บันทึก {name} ใน Payload Manager แล้ว",
-payloads_pm_stored_body: "อยู่ในโฟลเดอร์ \"{folder}\" ของ Payload Manager แท็บ Payload Manager ใช้เปิดหรือโหลดตอนบูตได้",
payloads_pm_store_failed: "บันทึก {id} ใน Payload Manager ไม่สำเร็จ",
pm_launched: "เปิด {name} แล้ว",
pm_launched_body: "ดูการแจ้งเตือนบน PS5",
@@ -2491,10 +2488,6 @@ helper_mismatch_go: "โหลดตัวช่วยใหม่",
"fpkg.checkDrm": "ค่า DRM ของแพ็กเกจเป็นค่ามาตรฐาน ค่าแบบฟรีหรืออัปเกรดได้จะทำให้คอนโซลล็อกเกม ค่าจะถูกเขียนใหม่เฉพาะในแพ็กเกจ ไฟล์ของคุณเองจะไม่ถูกแตะต้อง",
"payloads_essentials_title": "แนะนำให้ใช้คู่กับ ps5upload",
"payloads_essentials_body": "หากคุณตั้งค่าคอนโซลเป็นครั้งแรก สามตัวนี้ครอบคลุมสิ่งที่ ps5upload ต้องการเกือบทั้งหมด: kstuff-lite (แพตช์เคอร์เนลที่โฮมบรูส่วนใหญ่พึ่งพา), ShadowMount+ (เมานต์อิมเมจเกมจากที่จัดเก็บ) และ nanoDNS (กันคอนโซลออกจากเส้นทางอัปเดตของ Sony) ที่เหลือในรายการนี้เป็นตัวเลือกเสริม",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "ตรวจสอบสถานะ ShadowMount+ อิมเมจเกมที่เมานต์ การกำหนดค่า และการวินิจฉัยบน PS5 ที่เลือก",
-"payloads_description_nanodns": "กำหนดค่า payload nanoDNS ตรวจสอบเวอร์ชันที่ทำงานอยู่ และใช้การย้ายการกำหนดค่าอย่างปลอดภัย",
"navigation_history": "ดูประวัติ",
"navigation_back": "ย้อนกลับ",
"navigation_forward": "ไปข้างหน้า",
@@ -3941,6 +3934,70 @@ upload_phase_settling_rate: "ไฟล์/วินาที",
upload_status_resuming: "กำลังทำต่อ: มีไฟล์อยู่แล้ว {done} จาก {total} ไฟล์ ({have}); ต้องส่ง {send}",
volumes_kept_by_console: "{kept} ถูกเครื่องเก็บไว้ใช้เอง",
payloads_tab_ports: "พอร์ต",
+processes_and_ports: "โปรเซสและพอร์ต",
+payloads_tab_ps5: "บน PS5 ของคุณ",
+payloads_description_ps5: "เพย์โหลดที่กำลังทำงานหรือติดตั้งอยู่บน PS5 ที่เลือก เปิดหน้าเว็บของเพย์โหลด อัปเดต หยุด หรือจัดการได้",
+payloads_pm_stored_body_v2: "อยู่ในโฟลเดอร์ \"{folder}\" ของ Payload Manager โดย Payload Manager ในแท็บ บน PS5 ของคุณ จะเปิดหรือโหลดตอนบูต",
+hub_checking: "กำลังตรวจสอบสิ่งที่อยู่บน PS5 นี้…",
+hub_count: "{n} รายการบน PS5 นี้",
+hub_error: "อ่านสิ่งที่กำลังทำงานบน PS5 ไม่ได้",
+hub_empty_title: "ไม่พบสิ่งใดบน PS5 นี้",
+hub_empty_body: "ไม่มีเพย์โหลดจากแค็ตตาล็อกที่ทำงานหรือติดตั้งอยู่บนคอนโซลนี้ หาได้ในแค็ตตาล็อก และ ส่ง จะโหลดไฟล์เพย์โหลดใดก็ได้",
+hub_empty_body_web: "ไม่มีเพย์โหลดที่รู้จักทำงานหรือติดตั้งอยู่บนคอนโซลนี้",
+hub_state_running: "กำลังทำงาน",
+hub_state_installed: "ติดตั้งแล้ว",
+hub_state_unknown: "ไม่พบ",
+hub_running_detail: "{name} · โปรเซส {pid} · {mb} MB",
+hub_installed_detail: "อยู่บนคอนโซล แต่ไม่ได้ทำงาน",
+hub_unknown_detail: "ไม่พบบน PS5 นี้",
+hub_not_catalogued: "ไม่อยู่ในแค็ตตาล็อก",
+hub_open_page: "เปิดหน้าเว็บ",
+hub_update: "อัปเดตเป็น {version}",
+hub_update_confirm_title: "อัปเดต {name} เป็น {version} หรือไม่",
+hub_update_confirm_body: "ดาวน์โหลด {version} แล้วส่งไปยัง PS5 ตรวจดูการแจ้งเตือนบน PS5",
+hub_updated: "ส่ง {name} {version} แล้ว",
+hub_update_failed: "อัปเดต {name} ไม่ได้",
+hub_stop: "หยุด",
+hub_manage: "จัดการ",
+hub_pm_summary: "เพย์โหลดที่เก็บไว้: {n}",
+hub_smp_summary: "อิมเมจเกมที่เมานต์: {n}",
+hb_no_answer: "HearBridge ไม่ตอบสนอง",
+hb_checking: "กำลังอ่านสถานะ HearBridge…",
+hb_state_streaming: "กำลังสตรีม",
+hb_state_connecting: "กำลังเชื่อมต่อ",
+hb_state_reconnecting: "กำลังเชื่อมต่อใหม่",
+hb_state_scanning: "กำลังสแกน",
+hb_state_waiting: "รอหูฟัง",
+hb_state_error: "ข้อผิดพลาด",
+hb_battery: "แบตเตอรี่ {pct}%",
+hb_link: "การเชื่อมต่อ {score}%",
+hb_latency: "ดีเลย์ {ms} ms",
+hb_muted: "ปิดเสียง",
+hb_volume: "ระดับเสียง {pct}%",
+hb_game: "โปรไฟล์: {name}",
+hb_scan_confirm_title: "สแกนหาหูฟังหรือไม่",
+hb_scan_confirm_body: "ระหว่างสแกน (ประมาณ 12 วินาที) เสียงที่หูฟังจะหยุด",
+hb_scan: "สแกนหาหูฟัง",
+hb_open_page: "เปิดหน้า HearBridge",
+hb_action_failed: "HearBridge ไม่รับการเปลี่ยนแปลงนั้น",
+hb_volume_label: "ระดับเสียง",
+hb_mute: "ปิดเสียง",
+hb_latency_label: "ความหน่วง",
+hb_latency_auto: "อัตโนมัติ",
+hb_night: "โหมดกลางคืน",
+hb_night_hint: "เสียงระเบิดเบาลง เสียงพูดชัดขึ้น",
+hb_headsets: "หูฟัง",
+hb_reconnect: "เชื่อมต่อใหม่",
+hb_current: "กำลังใช้",
+hb_switch: "สลับไปใช้อันนี้",
+hb_saved_empty: "ยังไม่มีหูฟังที่บันทึกไว้ สแกนหา หรือจับคู่ในหน้า HearBridge",
+hb_scan_found: "พบในการสแกนครั้งล่าสุด:",
+hb_scan_empty: "ไม่พบสิ่งใด ตั้งหูฟังเป็นโหมดจับคู่แล้วสแกนอีกครั้ง",
+hb_connect: "เชื่อมต่อ",
+hb_events: "เหตุการณ์ล่าสุด",
+hb_page_hint: "การจับคู่หูฟังใหม่ อีควอไลเซอร์ การสำรองข้อมูล และการตั้งค่าอื่นทั้งหมดอยู่ในหน้าของ HearBridge เอง",
+hb_stop_title: "หยุด HearBridge หรือไม่",
+hb_stop_body: "HearBridge จะปิดและหูฟังจะตัดการเชื่อมต่อ ส่งอีกครั้งเพื่อเริ่มใหม่",
payloads_description_ports: "พอร์ตที่ใช้งานอยู่บน PS5 และสิ่งที่มักรอรับบนแต่ละพอร์ต เพื่อให้รู้ว่าเพย์โหลดไหนอยู่ที่ไหน เปิดพอร์ตที่มีหน้าเว็บในเบราว์เซอร์ได้",
"ports.scan": "สแกนอีกครั้ง",
"ports.scanning": "กำลังตรวจทุกพอร์ตบน PS5…",
diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts
index 854648e1..d85cd7f9 100644
--- a/client/src/i18n/locales/tr.ts
+++ b/client/src/i18n/locales/tr.ts
@@ -771,15 +771,12 @@ payloads_download: "{version} sürümünü indir",
payloads_load_failed: "Katalog yüklenemedi",
payloads_loading: "Katalog yükleniyor…",
payloads_no_host: "PS5 ana bilgisayarı ayarlanmadı. Önce Bağlantı ekranından bağlan.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager'ın (itsPLK) PS5'te sakladığı payload'lar: başlatın veya silin, açılışta hangilerini hangi sırayla yükleyeceğini seçin. Katalogdaki bir payload'ı Katalog sekmesinden oraya kaydedin.",
payloads_pm_store: "Payload Manager'a kaydet",
payloads_pm_store_tooltip: "Bu dosyayı PS5'te Payload Manager içinde tutar; oradan başlatılabilir veya açılışta yüklenebilir.",
payloads_pm_store_confirm_title: "Payload Manager'ın \"{folder}\" klasörü değiştirilsin mi?",
payloads_pm_store_confirm_same: "Payload Manager'da {name} zaten var. Kaydetmek onu değiştirir ve klasördeki diğer dosyaları siler.",
payloads_pm_store_confirm_other: "Payload Manager'da zaten bir \"{folder}\" klasörü var. Oraya kaydetmek oradaki diğer dosyaları siler (genellikle eski bir sürüm).",
payloads_pm_stored_title: "{name} Payload Manager'a kaydedildi",
-payloads_pm_stored_body: "Payload Manager'ın \"{folder}\" klasöründe. Payload Manager sekmesi onu başlatır veya açılışta yükler.",
payloads_pm_store_failed: "{id} Payload Manager'a kaydedilemedi",
pm_launched: "{name} başlatıldı",
pm_launched_body: "PS5'teki bildirime bakın.",
@@ -2489,10 +2486,6 @@ helper_mismatch_go: "Yardımcıyı yükle",
"fpkg.checkDrm": "Paketin DRM değerinin standart olması. Ücretsiz ya da yükseltilebilir bir değer konsolun oyunu kilitlemesine yol açar; yalnızca pakette yeniden yazılır, kendi dosyana asla dokunulmaz.",
"payloads_essentials_title": "ps5upload ile birlikte önerilenler",
"payloads_essentials_body": "Bir konsolu ilk kez kuruyorsan, bu üçü ps5upload'un beklediği şeylerin çoğunu karşılar: kstuff-lite (homebrew'un çoğunun dayandığı çekirdek yamaları), ShadowMount+ (depolamadaki oyun görüntülerini bağlar) ve nanoDNS (konsolu Sony'nin güncelleme yolundan uzak tutar). Bu listedeki diğer her şey isteğe bağlıdır.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Seçilen PS5'te ShadowMount+ durumunu, bağlı oyun görüntülerini, yapılandırmayı ve tanılamayı incele.",
-"payloads_description_nanodns": "nanoDNS payload'ını yapılandır, çalışan sürümünü doğrula ve güvenli yapılandırma geçişlerini uygula.",
"navigation_history": "Geçmişi görüntüle",
"navigation_back": "Geri",
"navigation_forward": "İleri",
@@ -3939,6 +3932,70 @@ upload_phase_settling_rate: "dosya/sn",
upload_status_resuming: "Sürdürülüyor: {done}/{total} dosya zaten var ({have}); gönderilecek: {send}.",
volumes_kept_by_console: "{kept} konsol tarafından kendi kullanımı için ayrıldı",
payloads_tab_ports: "Portlar",
+processes_and_ports: "İşlemler ve portlar",
+payloads_tab_ps5: "PS5'inizde",
+payloads_description_ps5: "Seçili PS5'te çalışan veya yüklü payload'lar. Bir payload'ın web sayfasını açın, güncelleyin, durdurun veya yönetin.",
+payloads_pm_stored_body_v2: "Payload Manager'ın \"{folder}\" klasöründe. PS5'inizde sekmesindeki Payload Manager onu başlatır veya açılışta yükler.",
+hub_checking: "Bu PS5'te neler olduğu kontrol ediliyor…",
+hub_count: "Bu PS5'te {n}",
+hub_error: "PS5'te neyin çalıştığı okunamadı",
+hub_empty_title: "Bu PS5'te hiçbir şey bulunamadı",
+hub_empty_body: "Katalogdan hiçbir payload bu konsolda çalışmıyor veya yüklü değil. Katalogda bulunurlar; Gönder ise herhangi bir payload dosyasını yükler.",
+hub_empty_body_web: "Bu konsolda bilinen hiçbir payload çalışmıyor veya yüklü değil.",
+hub_state_running: "Çalışıyor",
+hub_state_installed: "Yüklü",
+hub_state_unknown: "Bulunamadı",
+hub_running_detail: "{name} · işlem {pid} · {mb} MB",
+hub_installed_detail: "Konsolda, çalışmıyor.",
+hub_unknown_detail: "Bu PS5'te bulunamadı.",
+hub_not_catalogued: "Katalogda yok",
+hub_open_page: "Web sayfasını aç",
+hub_update: "{version} sürümüne güncelle",
+hub_update_confirm_title: "{name}, {version} sürümüne güncellensin mi?",
+hub_update_confirm_body: "{version} sürümünü indirip PS5'e gönderir. PS5'teki bildirimi kontrol edin.",
+hub_updated: "{name} {version} gönderildi",
+hub_update_failed: "{name} güncellenemedi",
+hub_stop: "Durdur",
+hub_manage: "Yönet",
+hub_pm_summary: "Saklanan payload: {n}",
+hub_smp_summary: "Bağlı oyun imajları: {n}",
+hb_no_answer: "HearBridge yanıt vermedi",
+hb_checking: "HearBridge durumu okunuyor…",
+hb_state_streaming: "Aktarıyor",
+hb_state_connecting: "Bağlanıyor",
+hb_state_reconnecting: "Yeniden bağlanıyor",
+hb_state_scanning: "Taranıyor",
+hb_state_waiting: "Kulaklık bekleniyor",
+hb_state_error: "Hata",
+hb_battery: "Pil %{pct}",
+hb_link: "Bağlantı %{score}",
+hb_latency: "{ms} ms gecikme",
+hb_muted: "Sessiz",
+hb_volume: "Ses %{pct}",
+hb_game: "Profil: {name}",
+hb_scan_confirm_title: "Kulaklık aransın mı?",
+hb_scan_confirm_body: "Tarama sürerken (yaklaşık 12 saniye) kulaklığın sesi kesilir.",
+hb_scan: "Kulaklık ara",
+hb_open_page: "HearBridge sayfasını aç",
+hb_action_failed: "HearBridge bu değişikliği almadı",
+hb_volume_label: "Ses",
+hb_mute: "Sessize al",
+hb_latency_label: "Gecikme",
+hb_latency_auto: "Otomatik",
+hb_night: "Gece modu",
+hb_night_hint: "Daha sessiz patlamalar, daha net sesler.",
+hb_headsets: "Kulaklıklar",
+hb_reconnect: "Yeniden bağlan",
+hb_current: "Kullanımda",
+hb_switch: "Buna geç",
+hb_saved_empty: "Henüz kayıtlı kulaklık yok. Birini arayın ya da HearBridge sayfasında eşleyin.",
+hb_scan_found: "Son taramada bulunanlar:",
+hb_scan_empty: "Hiçbir şey bulunamadı. Kulaklığı eşleme moduna alıp yeniden tarayın.",
+hb_connect: "Bağlan",
+hb_events: "Son olaylar",
+hb_page_hint: "Yeni kulaklık eşleme, ekolayzer, yedekler ve diğer tüm ayarlar HearBridge'in kendi sayfasında.",
+hb_stop_title: "HearBridge durdurulsun mu?",
+hb_stop_body: "HearBridge kapanır ve kulaklığın bağlantısı kesilir. Başlatmak için yeniden gönderin.",
payloads_description_ports: "PS5'te kullanılan portlar ve her birinde genellikle neyin dinlediği; hangi payload'un nerede olduğunu görürsün. Web sayfası olanları tarayıcıda aç.",
"ports.scan": "Yeniden tara",
"ports.scanning": "PS5'in tüm portları kontrol ediliyor…",
diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts
index e72ff207..a1c68395 100644
--- a/client/src/i18n/locales/vi.ts
+++ b/client/src/i18n/locales/vi.ts
@@ -773,15 +773,12 @@ payloads_download: "Tải {version}",
payloads_load_failed: "Không tải được danh mục",
payloads_loading: "Đang tải danh mục…",
payloads_no_host: "Chưa đặt máy chủ PS5. Hãy kết nối ở màn hình Kết nối trước.",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Các payload Payload Manager (itsPLK) lưu trên PS5: chạy hoặc xóa, và chọn payload nào được nạp khi khởi động theo thứ tự nào. Lưu payload từ danh mục vào đó ở tab Danh mục.",
payloads_pm_store: "Lưu vào Payload Manager",
payloads_pm_store_tooltip: "Giữ tệp này trên PS5 trong Payload Manager để chạy từ đó hoặc nạp khi khởi động.",
payloads_pm_store_confirm_title: "Thay thế thư mục \"{folder}\" của Payload Manager?",
payloads_pm_store_confirm_same: "Payload Manager đã có {name}. Lưu sẽ thay thế nó và xóa mọi tệp khác trong thư mục đó.",
payloads_pm_store_confirm_other: "Payload Manager đã có thư mục \"{folder}\". Lưu vào đó sẽ xóa các tệp khác trong thư mục (thường là phiên bản cũ).",
payloads_pm_stored_title: "Đã lưu {name} vào Payload Manager",
-payloads_pm_stored_body: "Nằm trong thư mục \"{folder}\" của Payload Manager. Tab Payload Manager chạy nó hoặc nạp khi khởi động.",
payloads_pm_store_failed: "Không thể lưu {id} vào Payload Manager",
pm_launched: "Đã chạy {name}",
pm_launched_body: "Xem thông báo trên PS5.",
@@ -2484,10 +2481,6 @@ helper_mismatch_go: "Tải lại trợ giúp",
"fpkg.checkDrm": "Giá trị DRM của gói là tiêu chuẩn. Giá trị miễn phí hoặc có thể nâng cấp khiến máy khóa trò chơi; nó chỉ được ghi lại trong gói, tệp của bạn không bao giờ bị động tới.",
"payloads_essentials_title": "Nên dùng cùng ps5upload",
"payloads_essentials_body": "Nếu bạn thiết lập máy lần đầu, ba payload này đáp ứng hầu hết những gì ps5upload cần: kstuff-lite (bản vá nhân mà phần lớn homebrew dựa vào), ShadowMount+ (gắn ảnh trò chơi từ bộ nhớ) và nanoDNS (giữ máy tránh đường cập nhật của Sony). Mọi thứ khác trong danh sách là tùy chọn.",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "Xem trạng thái ShadowMount+, các ảnh trò chơi đã gắn, cấu hình và chẩn đoán trên PS5 đã chọn.",
-"payloads_description_nanodns": "Cấu hình payload nanoDNS, xác minh phiên bản đang chạy và áp dụng các bước chuyển cấu hình an toàn.",
"navigation_history": "Xem lịch sử",
"navigation_back": "Quay lại",
"navigation_forward": "Tiến tới",
@@ -3934,6 +3927,70 @@ upload_phase_settling_rate: "tệp/s",
upload_status_resuming: "Đang tiếp tục: {done} / {total} tệp đã có sẵn ({have}); còn {send} cần gửi.",
volumes_kept_by_console: "{kept} được máy giữ cho mục đích riêng",
payloads_tab_ports: "Cổng",
+processes_and_ports: "Tiến trình & cổng",
+payloads_tab_ps5: "Trên PS5 của bạn",
+payloads_description_ps5: "Các payload đang chạy hoặc đã cài trên PS5 đã chọn. Mở trang web của payload, cập nhật, dừng hoặc quản lý nó.",
+payloads_pm_stored_body_v2: "Nó nằm trong thư mục \"{folder}\" của Payload Manager. Payload Manager ở thẻ Trên PS5 của bạn sẽ chạy nó hoặc nạp khi khởi động.",
+hub_checking: "Đang kiểm tra những gì có trên PS5 này…",
+hub_count: "{n} trên PS5 này",
+hub_error: "Không đọc được những gì đang chạy trên PS5",
+hub_empty_title: "Không tìm thấy gì trên PS5 này",
+hub_empty_body: "Không có payload nào trong danh mục đang chạy hoặc được cài trên máy này. Danh mục có chúng, và Gửi sẽ nạp bất kỳ tệp payload nào.",
+hub_empty_body_web: "Không có payload nào đã biết đang chạy hoặc được cài trên máy này.",
+hub_state_running: "Đang chạy",
+hub_state_installed: "Đã cài",
+hub_state_unknown: "Không tìm thấy",
+hub_running_detail: "{name} · tiến trình {pid} · {mb} MB",
+hub_installed_detail: "Có trên máy, không chạy.",
+hub_unknown_detail: "Không tìm thấy trên PS5 này.",
+hub_not_catalogued: "Không có trong danh mục",
+hub_open_page: "Mở trang web",
+hub_update: "Cập nhật lên {version}",
+hub_update_confirm_title: "Cập nhật {name} lên {version}?",
+hub_update_confirm_body: "Tải {version} và gửi tới PS5. Hãy xem thông báo trên PS5.",
+hub_updated: "Đã gửi {name} {version}",
+hub_update_failed: "Không cập nhật được {name}",
+hub_stop: "Dừng",
+hub_manage: "Quản lý",
+hub_pm_summary: "Payload đã lưu: {n}",
+hub_smp_summary: "Ảnh game đã gắn: {n}",
+hb_no_answer: "HearBridge không phản hồi",
+hb_checking: "Đang đọc trạng thái HearBridge…",
+hb_state_streaming: "Đang phát",
+hb_state_connecting: "Đang kết nối",
+hb_state_reconnecting: "Đang kết nối lại",
+hb_state_scanning: "Đang quét",
+hb_state_waiting: "Đang chờ tai nghe",
+hb_state_error: "Lỗi",
+hb_battery: "Pin {pct}%",
+hb_link: "Kết nối {score}%",
+hb_latency: "độ trễ {ms} ms",
+hb_muted: "Đã tắt tiếng",
+hb_volume: "Âm lượng {pct}%",
+hb_game: "Hồ sơ: {name}",
+hb_scan_confirm_title: "Quét tìm tai nghe?",
+hb_scan_confirm_body: "Trong lúc quét (khoảng 12 giây) âm thanh tới tai nghe sẽ dừng.",
+hb_scan: "Quét tìm tai nghe",
+hb_open_page: "Mở trang HearBridge",
+hb_action_failed: "HearBridge không nhận thay đổi đó",
+hb_volume_label: "Âm lượng",
+hb_mute: "Tắt tiếng",
+hb_latency_label: "Độ trễ",
+hb_latency_auto: "Tự động",
+hb_night: "Chế độ ban đêm",
+hb_night_hint: "Tiếng nổ nhỏ hơn, giọng nói rõ hơn.",
+hb_headsets: "Tai nghe",
+hb_reconnect: "Kết nối lại",
+hb_current: "Đang dùng",
+hb_switch: "Chuyển sang cái này",
+hb_saved_empty: "Chưa có tai nghe đã lưu. Hãy quét tìm, hoặc ghép nối trên trang HearBridge.",
+hb_scan_found: "Tìm thấy trong lần quét gần nhất:",
+hb_scan_empty: "Không tìm thấy gì. Đặt tai nghe ở chế độ ghép nối rồi quét lại.",
+hb_connect: "Kết nối",
+hb_events: "Sự kiện gần đây",
+hb_page_hint: "Ghép nối tai nghe mới, bộ chỉnh âm, sao lưu và mọi cài đặt khác nằm trên trang riêng của HearBridge.",
+hb_stop_title: "Dừng HearBridge?",
+hb_stop_body: "HearBridge sẽ đóng và tai nghe ngắt kết nối. Gửi lại để chạy nó.",
payloads_description_ports: "Các cổng đang dùng trên PS5 và thứ thường lắng nghe ở mỗi cổng, để biết payload nào ở đâu. Mở các cổng có trang web trong trình duyệt.",
"ports.scan": "Quét lại",
"ports.scanning": "Đang kiểm tra mọi cổng trên PS5…",
diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts
index 9c8cba47..f5c60eec 100644
--- a/client/src/i18n/locales/zh-CN.ts
+++ b/client/src/i18n/locales/zh-CN.ts
@@ -778,15 +778,12 @@ payloads_download: "下载 {version}",
payloads_load_failed: "无法加载目录",
payloads_loading: "正在加载目录…",
payloads_no_host: "未设置 PS5 主机。请先在“连接”界面完成连接。",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager (itsPLK) 保存在 PS5 上的 payload:可启动或删除,并选择开机时加载哪些及其顺序。目录中的 payload 可在“目录”标签页保存到那里。",
payloads_pm_store: "保存到 Payload Manager",
payloads_pm_store_tooltip: "将此文件保存在 PS5 的 Payload Manager 中,以便从那里启动或开机时加载。",
payloads_pm_store_confirm_title: "替换 Payload Manager 的“{folder}”文件夹?",
payloads_pm_store_confirm_same: "Payload Manager 已有 {name}。保存会替换它,并删除该文件夹中的其他文件。",
payloads_pm_store_confirm_other: "Payload Manager 已有“{folder}”文件夹。保存到其中会删除里面的其他文件(通常是旧版本)。",
payloads_pm_stored_title: "已将 {name} 保存到 Payload Manager",
-payloads_pm_stored_body: "位于 Payload Manager 的“{folder}”文件夹。可在 Payload Manager 标签页启动或设为开机加载。",
payloads_pm_store_failed: "无法将 {id} 保存到 Payload Manager",
pm_launched: "已启动 {name}",
pm_launched_body: "请查看 PS5 上的通知。",
@@ -2438,10 +2435,6 @@ helper_mismatch_go: "重新加载助手",
"fpkg.checkDrm": "软件包的 DRM 值为标准值。免费或可升级的值会让主机锁定游戏;它只在软件包中被改写,你自己的文件绝不会被改动。",
"payloads_essentials_title": "建议与 ps5upload 搭配使用",
"payloads_essentials_body": "如果你是第一次设置主机,这三个就能满足 ps5upload 的大部分需求:kstuff-lite(大多数自制软件依赖的内核补丁)、ShadowMount+(从存储挂载游戏镜像)和 nanoDNS(让主机远离 Sony 的更新)。列表中的其他内容都是可选的。",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "查看所选 PS5 上 ShadowMount+ 的状态、已挂载的游戏镜像、配置和诊断信息。",
-"payloads_description_nanodns": "配置 nanoDNS payload,确认运行中的版本,并应用安全的配置迁移。",
"navigation_history": "查看历史",
"navigation_back": "后退",
"navigation_forward": "前进",
@@ -3888,6 +3881,70 @@ upload_phase_settling_rate: "文件/秒",
upload_status_resuming: "正在继续:{total} 个文件中已有 {done} 个({have});还需发送 {send}。",
volumes_kept_by_console: "{kept} 由主机留作自用",
payloads_tab_ports: "端口",
+processes_and_ports: "进程与端口",
+payloads_tab_ps5: "PS5 上",
+payloads_description_ps5: "所选 PS5 上正在运行或已安装的 Payload。可打开 Payload 的网页、更新、停止或管理它。",
+payloads_pm_stored_body_v2: "它位于 Payload Manager 的“{folder}”文件夹中。可在“PS5 上”标签页的 Payload Manager 中启动它或设为开机加载。",
+hub_checking: "正在检查这台 PS5 上的内容…",
+hub_count: "这台 PS5 上有 {n} 个",
+hub_error: "无法读取 PS5 上正在运行的内容",
+hub_empty_title: "这台 PS5 上没有找到任何内容",
+hub_empty_body: "目录中的 Payload 都没有在这台主机上运行或安装。可在“目录”中获取,“发送”可加载任意 Payload 文件。",
+hub_empty_body_web: "这台主机上没有正在运行或已安装的已知 Payload。",
+hub_state_running: "运行中",
+hub_state_installed: "已安装",
+hub_state_unknown: "未找到",
+hub_running_detail: "{name} · PID {pid} · {mb} MB",
+hub_installed_detail: "在主机上,未运行。",
+hub_unknown_detail: "这台 PS5 上没有找到。",
+hub_not_catalogued: "不在目录中",
+hub_open_page: "打开网页",
+hub_update: "更新到 {version}",
+hub_update_confirm_title: "将 {name} 更新到 {version}?",
+hub_update_confirm_body: "下载 {version} 并发送到 PS5。请留意 PS5 上的通知。",
+hub_updated: "已发送 {name} {version}",
+hub_update_failed: "无法更新 {name}",
+hub_stop: "停止",
+hub_manage: "管理",
+hub_pm_summary: "已存储的 Payload:{n}",
+hub_smp_summary: "已挂载的游戏镜像:{n}",
+hb_no_answer: "HearBridge 没有响应",
+hb_checking: "正在读取 HearBridge 状态…",
+hb_state_streaming: "正在传输",
+hb_state_connecting: "正在连接",
+hb_state_reconnecting: "正在重新连接",
+hb_state_scanning: "正在扫描",
+hb_state_waiting: "等待耳机",
+hb_state_error: "错误",
+hb_battery: "电量 {pct}%",
+hb_link: "连接质量 {score}%",
+hb_latency: "延迟 {ms} 毫秒",
+hb_muted: "已静音",
+hb_volume: "音量 {pct}%",
+hb_game: "配置:{name}",
+hb_scan_confirm_title: "扫描耳机?",
+hb_scan_confirm_body: "扫描期间(约 12 秒)耳机会暂停出声。",
+hb_scan: "扫描耳机",
+hb_open_page: "打开 HearBridge 页面",
+hb_action_failed: "HearBridge 没有接受这项更改",
+hb_volume_label: "音量",
+hb_mute: "静音",
+hb_latency_label: "延迟",
+hb_latency_auto: "自动",
+hb_night: "夜间模式",
+hb_night_hint: "爆炸声更柔和,人声更清晰。",
+hb_headsets: "耳机",
+hb_reconnect: "重新连接",
+hb_current: "使用中",
+hb_switch: "切换到这个",
+hb_saved_empty: "还没有已保存的耳机。扫描一个,或在 HearBridge 页面上配对。",
+hb_scan_found: "上次扫描找到:",
+hb_scan_empty: "没有找到。请让耳机进入配对模式后再扫描。",
+hb_connect: "连接",
+hb_events: "最近事件",
+hb_page_hint: "配对新耳机、均衡器、备份及其他所有设置都在 HearBridge 自己的页面上。",
+hb_stop_title: "停止 HearBridge?",
+hb_stop_body: "HearBridge 将关闭,耳机断开连接。重新发送即可启动。",
payloads_description_ports: "PS5 上正在使用的端口以及通常在每个端口上监听的程序,方便你知道哪个 payload 在哪里。有网页的端口可以在浏览器中打开。",
"ports.scan": "重新扫描",
"ports.scanning": "正在检查 PS5 的所有端口…",
diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts
index ce8e4900..e2157e2c 100644
--- a/client/src/i18n/locales/zh-TW.ts
+++ b/client/src/i18n/locales/zh-TW.ts
@@ -772,15 +772,12 @@ payloads_download: "下載 {version}",
payloads_load_failed: "無法載入目錄",
payloads_loading: "正在載入目錄…",
payloads_no_host: "尚未設定 PS5 主機。請先在「連線」畫面完成連線。",
-payloads_tab_manager: "Payload Manager",
-payloads_description_manager: "Payload Manager (itsPLK) 保存在 PS5 上的 payload:可啟動或刪除,並選擇開機時載入哪些及其順序。目錄中的 payload 可在「目錄」分頁保存到那裡。",
payloads_pm_store: "保存到 Payload Manager",
payloads_pm_store_tooltip: "將此檔案保留在 PS5 的 Payload Manager 中,以便從那裡啟動或開機時載入。",
payloads_pm_store_confirm_title: "取代 Payload Manager 的「{folder}」資料夾?",
payloads_pm_store_confirm_same: "Payload Manager 已有 {name}。保存會取代它,並刪除該資料夾中的其他檔案。",
payloads_pm_store_confirm_other: "Payload Manager 已有「{folder}」資料夾。保存到其中會刪除裡面的其他檔案(通常是舊版本)。",
payloads_pm_stored_title: "已將 {name} 保存到 Payload Manager",
-payloads_pm_stored_body: "位於 Payload Manager 的「{folder}」資料夾。可在 Payload Manager 分頁啟動或設為開機載入。",
payloads_pm_store_failed: "無法將 {id} 保存到 Payload Manager",
pm_launched: "已啟動 {name}",
pm_launched_body: "請查看 PS5 上的通知。",
@@ -2432,10 +2429,6 @@ helper_mismatch_go: "重新載入小幫手",
"fpkg.checkDrm": "套件的 DRM 值為標準值。免費或可升級的值會讓主機鎖定遊戲;它只會在套件中被改寫,您自己的檔案絕不會被更動。",
"payloads_essentials_title": "建議與 ps5upload 搭配使用",
"payloads_essentials_body": "如果您是第一次設定主機,這三個就能滿足 ps5upload 大部分的需求:kstuff-lite(大多數自製軟體依賴的核心修補)、ShadowMount+(從儲存空間掛載遊戲映像)與 nanoDNS(讓主機遠離 Sony 的更新)。清單中的其他項目都是選用的。",
-"payloads_tab_shadowmount": "ShadowMount+",
-"payloads_tab_nanodns": "nanoDNS",
-"payloads_description_shadowmount": "檢視所選 PS5 上 ShadowMount+ 的狀態、已掛載的遊戲映像、設定與診斷資訊。",
-"payloads_description_nanodns": "設定 nanoDNS payload,確認執行中的版本,並套用安全的設定遷移。",
"navigation_history": "檢視歷程",
"navigation_back": "上一頁",
"navigation_forward": "下一頁",
@@ -3882,6 +3875,70 @@ upload_phase_settling_rate: "檔案/秒",
upload_status_resuming: "正在繼續:{total} 個檔案中已有 {done} 個({have});還需傳送 {send}。",
volumes_kept_by_console: "{kept} 由主機保留自用",
payloads_tab_ports: "連接埠",
+processes_and_ports: "處理程序與連接埠",
+payloads_tab_ps5: "PS5 上",
+payloads_description_ps5: "所選 PS5 上正在執行或已安裝的 Payload。可開啟 Payload 的網頁、更新、停止或管理它。",
+payloads_pm_stored_body_v2: "它位於 Payload Manager 的「{folder}」資料夾中。可在「PS5 上」分頁的 Payload Manager 中啟動它或設為開機載入。",
+hub_checking: "正在檢查這台 PS5 上的內容…",
+hub_count: "這台 PS5 上有 {n} 個",
+hub_error: "無法讀取 PS5 上正在執行的內容",
+hub_empty_title: "這台 PS5 上沒有找到任何內容",
+hub_empty_body: "目錄中的 Payload 都沒有在這台主機上執行或安裝。可在「目錄」中取得,「傳送」可載入任意 Payload 檔案。",
+hub_empty_body_web: "這台主機上沒有正在執行或已安裝的已知 Payload。",
+hub_state_running: "執行中",
+hub_state_installed: "已安裝",
+hub_state_unknown: "找不到",
+hub_running_detail: "{name} · PID {pid} · {mb} MB",
+hub_installed_detail: "在主機上,未執行。",
+hub_unknown_detail: "這台 PS5 上找不到。",
+hub_not_catalogued: "不在目錄中",
+hub_open_page: "開啟網頁",
+hub_update: "更新到 {version}",
+hub_update_confirm_title: "將 {name} 更新到 {version}?",
+hub_update_confirm_body: "下載 {version} 並傳送到 PS5。請留意 PS5 上的通知。",
+hub_updated: "已傳送 {name} {version}",
+hub_update_failed: "無法更新 {name}",
+hub_stop: "停止",
+hub_manage: "管理",
+hub_pm_summary: "已儲存的 Payload:{n}",
+hub_smp_summary: "已掛載的遊戲映像:{n}",
+hb_no_answer: "HearBridge 沒有回應",
+hb_checking: "正在讀取 HearBridge 狀態…",
+hb_state_streaming: "正在串流",
+hb_state_connecting: "正在連線",
+hb_state_reconnecting: "正在重新連線",
+hb_state_scanning: "正在掃描",
+hb_state_waiting: "等待耳機",
+hb_state_error: "錯誤",
+hb_battery: "電量 {pct}%",
+hb_link: "連線品質 {score}%",
+hb_latency: "延遲 {ms} 毫秒",
+hb_muted: "已靜音",
+hb_volume: "音量 {pct}%",
+hb_game: "設定檔:{name}",
+hb_scan_confirm_title: "掃描耳機?",
+hb_scan_confirm_body: "掃描期間(約 12 秒)耳機會暫停出聲。",
+hb_scan: "掃描耳機",
+hb_open_page: "開啟 HearBridge 頁面",
+hb_action_failed: "HearBridge 沒有接受這項變更",
+hb_volume_label: "音量",
+hb_mute: "靜音",
+hb_latency_label: "延遲",
+hb_latency_auto: "自動",
+hb_night: "夜間模式",
+hb_night_hint: "爆炸聲更柔和,人聲更清晰。",
+hb_headsets: "耳機",
+hb_reconnect: "重新連線",
+hb_current: "使用中",
+hb_switch: "切換到這個",
+hb_saved_empty: "還沒有已儲存的耳機。掃描一個,或在 HearBridge 頁面上配對。",
+hb_scan_found: "上次掃描找到:",
+hb_scan_empty: "沒有找到。請讓耳機進入配對模式後再掃描。",
+hb_connect: "連線",
+hb_events: "最近事件",
+hb_page_hint: "配對新耳機、等化器、備份及其他所有設定都在 HearBridge 自己的頁面上。",
+hb_stop_title: "停止 HearBridge?",
+hb_stop_body: "HearBridge 將關閉,耳機中斷連線。重新傳送即可啟動。",
payloads_description_ports: "PS5 上正在使用的連接埠,以及通常在各連接埠上監聽的程式,方便你知道哪個 payload 在哪裡。有網頁的連接埠可以在瀏覽器中開啟。",
"ports.scan": "重新掃描",
"ports.scanning": "正在檢查 PS5 的所有連接埠…",
diff --git a/client/src/layout/TabbedShell.tsx b/client/src/layout/TabbedShell.tsx
index 74a8f8f6..ce6566b7 100644
--- a/client/src/layout/TabbedShell.tsx
+++ b/client/src/layout/TabbedShell.tsx
@@ -60,6 +60,11 @@ export interface TabbedShellProps {
/** Ordered list of tabs. The first one is the default when no
* `?tab=...` is present in the URL. */
tabs: ReadonlyArray>;
+ /** The tab shown when the URL names none (or an unknown one). Defaults to the first
+ * tab; ignored when it is not one of `tabs`. */
+ defaultTab?: Id;
+ /** Shown beside the title (e.g. a count), like PageHeader's `count`. */
+ count?: number;
/** Render the active tab's content. Called once per render with the
* active id; caller switches on id and returns the panel JSX. */
renderPanel: (activeId: Id) => ReactNode;
@@ -74,10 +79,12 @@ export interface TabbedShellProps {
function coerceTabId(
raw: string | null,
tabs: ReadonlyArray>,
+ fallback?: Id,
): Id {
if (raw && tabs.some((t) => t.id === raw)) {
return raw as Id;
}
+ if (fallback && tabs.some((t) => t.id === fallback)) return fallback;
return tabs[0].id;
}
@@ -87,12 +94,14 @@ export default function TabbedShell({
titleKey,
titleFallback,
tabs,
+ defaultTab,
+ count,
renderPanel,
}: TabbedShellProps) {
const tr = useTr();
const [searchParams, setSearchParams] = useSearchParams();
- const activeTab = coerceTabId(searchParams.get("tab"), tabs);
+ const activeTab = coerceTabId(searchParams.get("tab"), tabs, defaultTab);
const setActiveTab = (next: Id) => {
if (next === activeTab) return;
// A tab here is a full user-facing view, not a transient filter. Push it
@@ -120,6 +129,7 @@ export default function TabbedShell({
diff --git a/client/src/layout/navItems.ts b/client/src/layout/navItems.ts
index ca8dd82c..1e894871 100644
--- a/client/src/layout/navItems.ts
+++ b/client/src/layout/navItems.ts
@@ -205,8 +205,8 @@ export const NAV_ITEMS: NavItem[] = [
},
{
to: "/processes",
- key: "processes",
- fallback: "Processes",
+ key: "processes_and_ports",
+ fallback: "Processes & ports",
icon: Layers,
},
{
diff --git a/client/src/screens/Payloads/CatalogPanel.tsx b/client/src/screens/Payloads/CatalogPanel.tsx
index b1c042c0..deac3abd 100644
--- a/client/src/screens/Payloads/CatalogPanel.tsx
+++ b/client/src/screens/Payloads/CatalogPanel.tsx
@@ -41,6 +41,7 @@ import { recordPmStore } from "../../lib/pmUpdates";
import { hostOf } from "../../lib/addr";
import { useTr } from "../../state/lang";
import UsbAutoloaderModal from "./UsbAutoloaderModal";
+import { payloadCardLink } from "./payloadTabs";
/**
* Catalog tab of the Payloads screen — curated catalogue of
@@ -339,11 +340,11 @@ export default function CatalogPanel() {
withConsolePrefix(h, tr("payloads_pm_stored_title", { name: filename }, `Stored ${filename} in Payload Manager`)),
{
body: tr(
- "payloads_pm_stored_body",
+ "payloads_pm_stored_body_v2",
{ folder: r.folder },
- `It is in Payload Manager's "${r.folder}" folder. The Payload Manager tab launches it or loads it at boot.`,
+ `It is in Payload Manager's "${r.folder}" folder. Payload Manager on the On your PS5 tab launches it or loads it at boot.`,
),
- link: "/payloads?tab=manager",
+ link: payloadCardLink("payload-manager"),
},
);
} catch (e) {
diff --git a/client/src/screens/Payloads/hub/OnPs5Panel.tsx b/client/src/screens/Payloads/hub/OnPs5Panel.tsx
new file mode 100644
index 00000000..900e4d14
--- /dev/null
+++ b/client/src/screens/Payloads/hub/OnPs5Panel.tsx
@@ -0,0 +1,643 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useNavigate, useSearchParams } from "react-router";
+import {
+ Boxes,
+ ChevronDown,
+ ChevronUp,
+ Cpu,
+ Download,
+ ExternalLink,
+ RefreshCw,
+ Rocket,
+ Skull,
+} from "lucide-react";
+
+import { Badge, Button, ConnectionGate, EmptyState, ErrorCard, SkeletonRows } from "../../../components";
+import { useConfirm } from "../../../components/ConfirmDialog";
+import {
+ fsListDir,
+ payloadsCatalog,
+ payloadsDownload,
+ payloadsLocalInventory,
+ payloadsRelease,
+ processKill,
+ processList,
+ sendPayload,
+ type PayloadInfo,
+ type ProcessInfo,
+} from "../../../api/ps5";
+import { mgmtAddr, withPort } from "../../../lib/addr";
+import { openExternalUrl } from "../../../lib/openExternalUrl";
+import { compareVersions } from "../../../lib/semver";
+import { isTauriEnv } from "../../../lib/tauriEnv";
+import { usePoll, useTransferBusy } from "../../../lib/usePoll";
+import { useDocumentVisible } from "../../../lib/visibility";
+import { useConnectionStore } from "../../../state/connection";
+import { useTr } from "../../../state/lang";
+import { log } from "../../../state/logs";
+import { pushNotification } from "../../../state/notifications";
+import { withConsolePrefix } from "../../../state/roster";
+import { killPrompt } from "../../Processes/killPrompt";
+import NanoDnsScreen from "../../NanoDns";
+import SmpPanel from "../../Library/SmpPanel";
+import PayloadManagerPanel from "../PayloadManagerPanel";
+import { PmSummary, SmpSummary } from "./summaries";
+import { HbSummary, HearBridgePanel, useHearBridge } from "./hearbridge";
+import { hbAct } from "../../../api/hearbridge";
+import {
+ detectHubCards,
+ hubEntries,
+ markerFolders,
+ type HubCard,
+ type HubEntry,
+ type IntegrationId,
+ type IntegrationReport,
+} from "./registry";
+
+/** The process list is re-read this often while the tab is open. Slower than Processes'
+ * 3 s: cards change state rarely, and this shares the helper with everything else. */
+const REFRESH_MS = 10_000;
+
+/** Which marker files exist. A folder is listed once; a folder whose parent listing lacks
+ * it is not asked for at all (most consoles have none of these). */
+async function findMarkers(addr: string, entries: readonly HubEntry[]): Promise> {
+ const found = new Set();
+ const listed = new Map | null>();
+ const list = async (dir: string): Promise | null> => {
+ if (listed.has(dir)) return listed.get(dir) ?? null;
+ const slash = dir.lastIndexOf("/");
+ if (slash > 0) {
+ const parent = dir.slice(0, slash);
+ const siblings = await list(parent);
+ if (siblings && !siblings.has(dir.slice(slash + 1))) {
+ listed.set(dir, null);
+ return null;
+ }
+ }
+ let names: Set | null;
+ try {
+ names = new Set((await fsListDir(addr, dir)).map((e) => e.name));
+ } catch {
+ names = null;
+ }
+ listed.set(dir, names);
+ return names;
+ };
+ const folders = [...markerFolders(entries).entries()].sort((a, b) => a[0].length - b[0].length);
+ for (const [dir, paths] of folders) {
+ const names = await list(dir);
+ if (!names) continue;
+ for (const p of paths) if (names.has(p.slice(p.lastIndexOf("/") + 1))) found.add(p);
+ }
+ return found;
+}
+
+/**
+ * "On your PS5": one card per payload running or installed on the selected console, with
+ * its web page, an update when the catalogue has a newer release, Stop, and (for
+ * ShadowMount+, nanoDNS, Payload Manager and HearBridge) its own panel under Manage.
+ */
+export default function OnPs5Panel() {
+ return (
+
+
+
+ );
+}
+
+function OnPs5Body() {
+ const tr = useTr();
+ const native = isTauriEnv();
+ const navigate = useNavigate();
+ const host = useConnectionStore((s) => s.host);
+ const payloadStatus = useConnectionStore((s) => s.payloadStatus);
+ const online = payloadStatus === "up";
+ const addr = mgmtAddr(host);
+ const windowVisible = useDocumentVisible();
+ const [params, setParams] = useSearchParams();
+ const openId = params.get("open");
+
+ const [catalog, setCatalog] = useState(null);
+ const [procs, setProcs] = useState([]);
+ const [procsLoaded, setProcsLoaded] = useState(false);
+ const [error, setError] = useState(null);
+ const [markers, setMarkers] = useState>(new Set());
+ const [markersLoaded, setMarkersLoaded] = useState(false);
+ const [reports, setReports] = useState>({});
+ const [latest, setLatest] = useState>({});
+ const [busy, setBusy] = useState(null);
+ const { confirm, dialog } = useConfirm();
+ // Bumped after an Update, so the integrations' summaries read their version again.
+ const [summaryRev, setSummaryRev] = useState(0);
+ // The catalogue has been read (or failed): markers are checked once, against the full list.
+ const [catalogDone, setCatalogDone] = useState(!native);
+
+ useEffect(() => {
+ if (!native) return;
+ let cancelled = false;
+ payloadsCatalog()
+ .then((c) => {
+ if (!cancelled) setCatalog(c);
+ })
+ .catch((e) => log.warn("payloads", `catalogue: ${String(e)}`))
+ .finally(() => {
+ if (!cancelled) setCatalogDone(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [native]);
+
+ const entries = useMemo(() => hubEntries(catalog), [catalog]);
+
+ // Another console's cards are not this one's.
+ const genRef = useRef(0);
+ useEffect(() => {
+ genRef.current++;
+ setProcs([]);
+ setProcsLoaded(false);
+ setMarkers(new Set());
+ setMarkersLoaded(false);
+ setReports({});
+ setError(null);
+ }, [addr]);
+
+ const inFlight = useRef(false);
+ const refreshProcs = useCallback(async () => {
+ if (!online || inFlight.current) return;
+ inFlight.current = true;
+ const gen = genRef.current;
+ try {
+ const r = await processList(addr);
+ if (gen !== genRef.current) return;
+ setProcs(r.processes);
+ setError(null);
+ } catch (e) {
+ if (gen !== genRef.current) return;
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ inFlight.current = false;
+ if (gen === genRef.current) setProcsLoaded(true);
+ }
+ }, [addr, online]);
+ usePoll(refreshProcs, REFRESH_MS, { host: addr, enabled: online && windowVisible });
+
+ // Marker checks list folders on the console: like the pollers, they stand aside while a
+ // transfer to this console runs, and catch up when it ends.
+ const transferring = useTransferBusy(addr);
+ const refreshMarkers = useCallback(async () => {
+ if (!online || !catalogDone || transferring) return;
+ const gen = genRef.current;
+ const found = await findMarkers(addr, entries);
+ if (gen !== genRef.current) return;
+ setMarkers(found);
+ setMarkersLoaded(true);
+ }, [addr, entries, online, catalogDone, transferring]);
+ useEffect(() => {
+ void refreshMarkers();
+ }, [refreshMarkers]);
+
+ // HearBridge's own API is read while its card is on screen (or a link opened it); an
+ // answer also counts as running, should its process go by another name.
+ const hbWanted =
+ openId === "hearbridge" ||
+ detectHubCards({ entries, processes: procs, markers }).some((c) => c.integration === "hearbridge");
+ // Every status read lets HearBridge re-query the headset volume, so read slowly unless
+ // its Manage panel is open.
+ const hb = useHearBridge(host, online && hbWanted, openId === "hearbridge");
+ const hbPresent = hb.view?.present === true;
+ const cards = useMemo(
+ () =>
+ detectHubCards({
+ entries,
+ processes: procs,
+ markers,
+ answering: hbPresent ? new Set(["hearbridge"]) : undefined,
+ forceShow: openId,
+ }),
+ [entries, procs, markers, openId, hbPresent],
+ );
+ const hbVersion = hb.view?.present ? hb.view.status.version || null : null;
+ const hbPort = hb.view?.present ? hb.view.port : null;
+
+ const report = useCallback((id: string, r: IntegrationReport) => {
+ setReports((prev) => {
+ const cur = prev[id];
+ if (cur && cur.version === r.version && cur.webPort === r.webPort) return prev;
+ return { ...prev, [id]: r };
+ });
+ }, []);
+ useEffect(() => {
+ if (hbVersion || hbPort) report("hearbridge", { version: hbVersion, webPort: hbPort });
+ }, [hbVersion, hbPort, report]);
+
+ // The newest stable release of each card whose running version is known, so the card
+ // can offer an update. Desktop only (the catalogue's GitHub lookups live there).
+ const versionKey = cards
+ .map((c) => `${c.id}=${reports[c.id]?.version ?? ""}`)
+ .join("|");
+ useEffect(() => {
+ if (!native) return;
+ let cancelled = false;
+ for (const c of cards) {
+ const v = reports[c.id]?.version;
+ if (!v || c.entryIds.length !== 1 || latest[c.id] !== undefined) continue;
+ payloadsRelease(c.id)
+ .then((r) => {
+ if (!cancelled && r.picked_asset_url) setLatest((prev) => ({ ...prev, [c.id]: r.tag }));
+ })
+ .catch(() => undefined);
+ }
+ return () => {
+ cancelled = true;
+ };
+ // versionKey stands for cards + reports here.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [native, versionKey]);
+
+ // A link that opened a card (an old tab's URL, a notification) scrolls to it once.
+ const scrolledTo = useRef(null);
+ const openShown = !!openId && cards.some((c) => c.id === openId);
+ useEffect(() => {
+ if (!openId || !openShown || scrolledTo.current === openId) return;
+ scrolledTo.current = openId;
+ document
+ .querySelector(`[data-card="${CSS.escape(openId)}"]`)
+ ?.scrollIntoView({ block: "start", behavior: "smooth" });
+ }, [openId, openShown]);
+
+ const setOpen = (id: string | null) => {
+ const next = new URLSearchParams(params);
+ if (id) next.set("open", id);
+ else next.delete("open");
+ setParams(next, { replace: true });
+ };
+
+ const stop = async (card: HubCard) => {
+ if (card.integration === "hearbridge" && hbPresent) return stopHearBridge();
+ const p = card.processes[0];
+ if (!p) return;
+ const kind = killPrompt(p);
+ if (!kind) return;
+ const ok = await confirm({
+ title: tr("processes_kill_payload_title", { name: card.name }, `Stop payload "${card.name}"?`),
+ message: tr(
+ "processes_kill_payload_body",
+ { name: card.name },
+ `This kills the payload "${card.name}" (kstuff, etaHEN and the like). Whatever it provides stops until you send it again, and games that need it may stop starting.`,
+ ),
+ confirmLabel: tr("processes_kill", undefined, "Kill"),
+ destructive: true,
+ });
+ if (!ok) return;
+ setBusy(card.id);
+ try {
+ for (const proc of card.processes) {
+ const ack = await processKill(addr, proc.pid);
+ if (!ack.ok) throw new Error(ack.err || `pid ${proc.pid}`);
+ }
+ log.info("payloads", `stopped ${card.name}`);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(null);
+ await refreshProcs();
+ }
+ };
+
+ /** HearBridge has a clean stop of its own (it closes the headset link first). */
+ const stopHearBridge = async () => {
+ const ok = await confirm({
+ title: tr("hb_stop_title", undefined, "Stop HearBridge?"),
+ message: tr(
+ "hb_stop_body",
+ undefined,
+ "HearBridge closes and the headset disconnects. Send it again to start it.",
+ ),
+ confirmLabel: tr("hub_stop", undefined, "Stop"),
+ destructive: true,
+ });
+ if (!ok) return;
+ setBusy("hearbridge");
+ try {
+ await hbAct(host, { action: "stop" });
+ log.info("payloads", "stopped HearBridge");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(null);
+ await hb.refresh();
+ await refreshProcs();
+ }
+ };
+
+ const update = async (card: HubCard, tag: string) => {
+ const ok = await confirm({
+ title: tr("hub_update_confirm_title", { name: card.name, version: tag }, `Update ${card.name} to ${tag}?`),
+ message: tr(
+ "hub_update_confirm_body",
+ { version: tag },
+ `Downloads ${tag} and sends it to the PS5. Check the PS5 for its notification.`,
+ ),
+ confirmLabel: tr("hub_update", { version: tag }, `Update to ${tag}`),
+ });
+ if (!ok) return;
+ setBusy(card.id);
+ try {
+ const rel = await payloadsRelease(card.id);
+ const inv = await payloadsLocalInventory();
+ let local = inv.find((e) => e.payload_id === card.id && e.version === rel.tag);
+ if (!local) local = await payloadsDownload(card.id, rel.picked_asset_url, rel.tag);
+ await sendPayload(host.trim(), local.path);
+ pushNotification(
+ "success",
+ withConsolePrefix(host, tr("hub_updated", { name: card.name, version: rel.tag }, `Sent ${card.name} ${rel.tag}`)),
+ );
+ setReports((prev) => ({ ...prev, [card.id]: { ...prev[card.id], version: null } }));
+ setSummaryRev((n) => n + 1);
+ if (card.integration === "hearbridge") void hb.refresh();
+ } catch (e) {
+ pushNotification(
+ "error",
+ withConsolePrefix(host, tr("hub_update_failed", { name: card.name }, `Couldn't update ${card.name}`)),
+ { body: String(e) },
+ );
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const loading = !procsLoaded || !markersLoaded;
+
+ return (
+
+
+
+ {loading
+ ? tr("hub_checking", undefined, "Checking what is on this PS5…")
+ : tr("hub_count", { n: cards.length }, `${cards.length} on this PS5`)}
+
+ }
+ onClick={() => {
+ void refreshProcs();
+ void refreshMarkers();
+ }}
+ >
+ {tr("refresh", undefined, "Refresh")}
+
+
+ {tr("hb_saved_empty", undefined, "No saved headsets yet. Scan for one, or pair it on HearBridge's page.")}
+
+ )}
+ {scanned && (
+
+
+ {scanned.length
+ ? tr("hb_scan_found", undefined, "Found in the last scan:")
+ : tr("hb_scan_empty", undefined, "Nothing found. Put the headset in pairing mode and scan again.")}
+
+
+ {scanned.map((d) => (
+
+
+ {d.name || d.addr}
+
+
+ ))}
+
+
+ )}
+
+
+ {s.events.length > 0 && (
+
+
{tr("hb_events", undefined, "Recent events")}
+
+ {s.events.slice(-8).reverse().map((e, i) => (
+
{e}
+ ))}
+
+
+ )}
+
+
+ {openPage}
+
+ {tr(
+ "hb_page_hint",
+ undefined,
+ "Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.",
+ )}
+
+
+ {dialog}
+
+ );
+}
diff --git a/client/src/screens/Payloads/hub/registry.test.ts b/client/src/screens/Payloads/hub/registry.test.ts
new file mode 100644
index 00000000..a768f85c
--- /dev/null
+++ b/client/src/screens/Payloads/hub/registry.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+
+import type { PayloadInfo, ProcessInfo } from "../../../api/ps5";
+import { detectHubCards, entryFromCatalog, hubEntries, markerFolders, normProcName } from "./registry";
+
+const proc = (pid: number, comm: string, kind: ProcessInfo["kind"] = "payload", extra: Partial = {}): ProcessInfo => ({
+ pid,
+ name: comm,
+ comm,
+ title_id: "",
+ app_id: 0,
+ memory_mib: 4,
+ threads: 3,
+ kind,
+ ...extra,
+});
+
+const info = (id: string, display: string, hint: string | null, marker: string | null, ports: number[] = []): PayloadInfo => ({
+ id,
+ display_name: display,
+ role: "",
+ description: "",
+ repo_owner: "o",
+ repo_name: "r",
+ on_console_marker_path: marker,
+ process_name_hint: hint,
+ ports,
+ autoload_priority: 0,
+ autoload_delay_ms: 0,
+ homepage: "",
+});
+
+// A slice of the desktop catalogue (commands/payloads.rs).
+const CATALOG: PayloadInfo[] = [
+ info("kstuff-echostretch", "kstuff", null, "/data/kstuff.elf"),
+ info("kstuff-lite-drakmor", "kstuff-lite", null, "/data/kstuff.elf"),
+ info("shadowmountplus", "ShadowMount+", "shadowmountplus", "/data/shadowmount/debug.log"),
+ info("ftpsrv", "ftpsrv", "ftpsrv", null, [2121]),
+ info("payload-manager", "Payload Manager", null, "/data/pldmgr/pldmgr_config.txt", [8084]),
+ info("nanodns", "nanoDNS", "nanodns.elf", "/data/nanodns/nanodns.ini", [53]),
+ info("hearbridge", "HearBridge", "hearbridge.elf", "/data/hearbridge/hearbridge.log", [8090]),
+ info("klogsrv", "klogsrv", "klogsrv.elf", "/data/klog/klog.log", [3232]),
+];
+
+// The FW 13.60 Phat's process list (payloads + the helper), 2026-10-18.
+const PHAT: ProcessInfo[] = [
+ proc(130, "ps5upload-tile", "system", { is_self: true }),
+ proc(129, "shadowmountplus.elf"),
+ proc(128, "kstuff.elf"),
+ proc(127, "elfldr.elf"),
+ proc(107, "kstuff.elf"),
+ proc(97, "ftpsrv.elf"),
+ proc(96, "nanodns.elf"),
+ proc(90, "pldmgr.elf"),
+ proc(60, "SceShellUI", "system"),
+];
+
+describe("On your PS5 detection", () => {
+ it("finds every catalogued payload running on the Phat, integrations first", () => {
+ const cards = detectHubCards({ entries: hubEntries(CATALOG), processes: PHAT, markers: new Set() });
+ expect(cards.map((c) => [c.id, c.state])).toEqual([
+ ["nanodns", "running"],
+ ["payload-manager", "running"],
+ ["shadowmountplus", "running"],
+ ["proc:elfldr", "running"],
+ ["ftpsrv", "running"],
+ ["kstuff-echostretch", "running"],
+ ]);
+ const kstuff = cards.find((c) => c.id === "kstuff-echostretch")!;
+ // Two catalogue builds, one process name: one card naming both, both pids.
+ expect(kstuff.entryIds).toEqual(["kstuff-echostretch", "kstuff-lite-drakmor"]);
+ expect(kstuff.name).toBe("kstuff / kstuff-lite");
+ expect(kstuff.processes.map((p) => p.pid)).toEqual([128, 107]);
+ expect(cards.find((c) => c.id === "payload-manager")!.webPort).toBe(8084);
+ expect(cards.find((c) => c.id === "proc:elfldr")!.name).toBe("elfldr.elf");
+ });
+
+ it("never shows the helper itself or system processes", () => {
+ const cards = detectHubCards({ entries: hubEntries(CATALOG), processes: PHAT, markers: new Set() });
+ expect(cards.some((c) => c.processes.some((p) => p.is_self || p.kind === "system"))).toBe(false);
+ });
+
+ it("shows a payload whose marker is on the console as installed", () => {
+ const cards = detectHubCards({
+ entries: hubEntries(CATALOG),
+ processes: [],
+ markers: new Set(["/data/hearbridge/hearbridge.log", "/data/klog/klog.log"]),
+ });
+ expect(cards.map((c) => [c.id, c.state])).toEqual([
+ ["hearbridge", "installed"],
+ ["klogsrv", "installed"],
+ ]);
+ });
+
+ it("leaves out what is neither running nor installed, unless a link opened it", () => {
+ const entries = hubEntries(CATALOG);
+ expect(detectHubCards({ entries, processes: [], markers: new Set() })).toEqual([]);
+ const forced = detectHubCards({ entries, processes: [], markers: new Set(), forceShow: "hearbridge" });
+ expect(forced.map((c) => [c.id, c.state])).toEqual([["hearbridge", "unknown"]]);
+ });
+
+ it("counts an integration whose own API answered as running", () => {
+ const cards = detectHubCards({
+ entries: hubEntries(CATALOG),
+ processes: [],
+ markers: new Set(["/data/hearbridge/hearbridge.log"]),
+ answering: new Set(["hearbridge"]),
+ });
+ expect(cards.map((c) => [c.id, c.state, c.integration, c.webPort])).toEqual([
+ ["hearbridge", "running", "hearbridge", 8090],
+ ]);
+ });
+
+ it("works without a catalogue (the browser build) for the integrations", () => {
+ const cards = detectHubCards({ entries: hubEntries(null), processes: PHAT, markers: new Set() });
+ expect(cards.map((c) => c.id)).toEqual([
+ "nanodns",
+ "payload-manager",
+ "shadowmountplus",
+ "proc:elfldr",
+ "proc:ftpsrv",
+ "proc:kstuff",
+ ]);
+ expect(cards.find((c) => c.id === "proc:kstuff")!.processes).toHaveLength(2);
+ });
+
+ it("compares process names without case or extension", () => {
+ expect(normProcName("HearBridge.ELF")).toBe("hearbridge");
+ expect(normProcName("etaHEN")).toBe("etahen");
+ expect(entryFromCatalog(CATALOG[4]).processNames).toEqual(["pldmgr"]);
+ expect(entryFromCatalog(CATALOG[5]).webPort).toBeNull();
+ });
+
+ it("lists each marker folder once", () => {
+ const folders = markerFolders(hubEntries(CATALOG));
+ expect(folders.get("/data")).toEqual(["/data/kstuff.elf"]);
+ expect(folders.get("/data/pldmgr")).toEqual(["/data/pldmgr/pldmgr_config.txt"]);
+ });
+});
diff --git a/client/src/screens/Payloads/hub/registry.ts b/client/src/screens/Payloads/hub/registry.ts
new file mode 100644
index 00000000..2d335664
--- /dev/null
+++ b/client/src/screens/Payloads/hub/registry.ts
@@ -0,0 +1,242 @@
+// "On your PS5": which payloads are on the console, as cards. Pure: the panel feeds it the
+// catalogue, the process list and which marker files exist, and renders what comes back.
+//
+// A payload is "running" when a process with its name is up, "installed" when its marker
+// file is on the console. Payloads show up in the process list under their ELF's file name
+// ("shadowmountplus.elf", "nanodns.elf", "pldmgr.elf"; measured on FW 13.60), so names are
+// compared without case or extension.
+
+import type { PayloadInfo, ProcessInfo } from "../../../api/ps5";
+
+/** The payloads with a panel of their own on the card ("Manage"). Adding one = a registry
+ * entry here plus its panel in OnPs5Panel; never a new tab. */
+export type IntegrationId = "shadowmountplus" | "nanodns" | "payload-manager" | "hearbridge";
+
+export const INTEGRATION_IDS: readonly IntegrationId[] = [
+ "shadowmountplus",
+ "nanodns",
+ "payload-manager",
+ "hearbridge",
+];
+
+export function isIntegration(id: string): id is IntegrationId {
+ return (INTEGRATION_IDS as readonly string[]).includes(id);
+}
+
+/** What an integration's card summary learned from its own API. */
+export interface IntegrationReport {
+ version?: string | null;
+ /** The port its web page answered on, when that differs from the catalogue's. */
+ webPort?: number | null;
+}
+
+/** What the hub needs to know about one payload. */
+export interface HubEntry {
+ id: string;
+ name: string;
+ /** Process names it runs under (any case, with or without ".elf"). */
+ processNames: string[];
+ /** A file only it leaves on the console. */
+ marker: string | null;
+ /** Its web page's port, if it has one. */
+ webPort: number | null;
+ isCustom: boolean;
+}
+
+/** Ports the catalogue lists that serve a web page (diagnostics::known_port_is_web). */
+const WEB_PORTS = new Set([8080, 8084, 8090]);
+
+/** Process names the catalogue doesn't carry (it has no hint for these), measured on the
+ * console's process list. */
+const EXTRA_PROCESS_NAMES: Record = {
+ "payload-manager": ["pldmgr"],
+ "kstuff-echostretch": ["kstuff"],
+ "kstuff-lite-drakmor": ["kstuff"],
+};
+
+/** The integrations as the browser build knows them: it has no catalogue (that lives in the
+ * desktop app), so these four stand in for it. Mirrors commands/payloads.rs. */
+export const BUILTIN_ENTRIES: readonly HubEntry[] = [
+ {
+ id: "shadowmountplus",
+ name: "ShadowMount+",
+ processNames: ["shadowmountplus"],
+ marker: "/data/shadowmount/debug.log",
+ webPort: null,
+ isCustom: false,
+ },
+ {
+ id: "nanodns",
+ name: "nanoDNS",
+ processNames: ["nanodns"],
+ marker: "/data/nanodns/nanodns.ini",
+ webPort: null,
+ isCustom: false,
+ },
+ {
+ id: "payload-manager",
+ name: "Payload Manager",
+ processNames: ["pldmgr"],
+ marker: "/data/pldmgr/pldmgr_config.txt",
+ webPort: 8084,
+ isCustom: false,
+ },
+ {
+ id: "hearbridge",
+ name: "HearBridge",
+ processNames: ["hearbridge"],
+ marker: "/data/hearbridge/hearbridge.log",
+ webPort: 8090,
+ isCustom: false,
+ },
+];
+
+export function entryFromCatalog(p: PayloadInfo): HubEntry {
+ const names = [
+ ...(p.process_name_hint ? [p.process_name_hint] : []),
+ ...(EXTRA_PROCESS_NAMES[p.id] ?? []),
+ ];
+ return {
+ id: p.id,
+ name: p.display_name,
+ processNames: names,
+ marker: p.on_console_marker_path,
+ webPort: p.ports.find((port) => WEB_PORTS.has(port)) ?? null,
+ isCustom: !!p.is_custom,
+ };
+}
+
+/** The entries to detect: the catalogue when there is one, plus any integration it lacks. */
+export function hubEntries(catalog: readonly PayloadInfo[] | null): HubEntry[] {
+ const out = (catalog ?? []).map(entryFromCatalog);
+ for (const b of BUILTIN_ENTRIES) if (!out.some((e) => e.id === b.id)) out.push(b);
+ return out;
+}
+
+export function normProcName(s: string): string {
+ return s.trim().toLowerCase().replace(/\.(elf|bin)$/, "");
+}
+
+export type HubState = "running" | "installed" | "unknown";
+
+export interface HubCard {
+ /** The catalogue id, or `proc:` for a payload the catalogue doesn't know. */
+ id: string;
+ /** Every catalogue entry this card stands for (two kstuff builds share one process name). */
+ entryIds: string[];
+ name: string;
+ state: HubState;
+ /** Its running processes (empty when installed but not running). */
+ processes: ProcessInfo[];
+ webPort: number | null;
+ integration: IntegrationId | null;
+ isCustom: boolean;
+}
+
+export interface DetectInput {
+ entries: readonly HubEntry[];
+ processes: readonly ProcessInfo[];
+ /** Marker paths found on the console. */
+ markers: ReadonlySet;
+ /** Integrations whose own API answered (Payload Manager on :8084, HearBridge): running
+ * even when the process list hasn't been read yet. */
+ answering?: ReadonlySet;
+ /** A card to show even when nothing was found (a deep link opened it). */
+ forceShow?: string | null;
+}
+
+/** One card per payload that is running or installed, then one per running payload the
+ * catalogue doesn't know. Running before installed, integrations first, then by name. */
+export function detectHubCards({
+ entries,
+ processes,
+ markers,
+ answering,
+ forceShow,
+}: DetectInput): HubCard[] {
+ const claimed = new Set();
+ const byEvidence = new Map();
+ const cards: HubCard[] = [];
+ for (const e of entries) {
+ const names = new Set(e.processNames.map(normProcName).filter(Boolean));
+ const procs = processes.filter(
+ (p) => !p.is_self && (names.has(normProcName(p.comm)) || names.has(normProcName(p.name))),
+ );
+ const integration = isIntegration(e.id) ? e.id : null;
+ const answered = !!answering?.has(e.id);
+ const installed = !!e.marker && markers.has(e.marker);
+ const forced = forceShow === e.id;
+ if (procs.length === 0 && !installed && !answered && !forced) continue;
+ for (const p of procs) claimed.add(p.pid);
+ const state: HubState = procs.length > 0 || answered ? "running" : installed ? "installed" : "unknown";
+ // Two catalogue entries seen through the same process or marker are one thing on the
+ // console (kstuff's two builds both run as kstuff.elf): one card naming both.
+ const evidence = procs.length
+ ? `p:${procs.map((p) => p.pid).sort((a, b) => a - b).join(",")}`
+ : installed && !answered
+ ? `m:${e.marker}`
+ : `id:${e.id}`;
+ const same = byEvidence.get(evidence);
+ if (same && !integration && !same.integration) {
+ same.entryIds.push(e.id);
+ same.name = `${same.name} / ${e.name}`;
+ continue;
+ }
+ const card: HubCard = {
+ id: e.id,
+ entryIds: [e.id],
+ name: e.name,
+ state,
+ processes: procs,
+ webPort: e.webPort,
+ integration,
+ isCustom: e.isCustom,
+ };
+ byEvidence.set(evidence, card);
+ cards.push(card);
+ }
+ // Payloads nobody catalogued, seen running (one card per name).
+ const unknown = new Map();
+ for (const p of processes) {
+ if (p.kind !== "payload" || p.is_self || claimed.has(p.pid)) continue;
+ const label = p.comm || p.name;
+ const key = normProcName(label);
+ if (!key) continue;
+ const card = unknown.get(key);
+ if (card) {
+ card.processes.push(p);
+ continue;
+ }
+ unknown.set(key, {
+ id: `proc:${key}`,
+ entryIds: [],
+ name: label,
+ state: "running",
+ processes: [p],
+ webPort: null,
+ integration: null,
+ isCustom: true,
+ });
+ }
+ const rank = (c: HubCard) =>
+ (c.state === "running" ? 0 : c.state === "installed" ? 2 : 4) + (c.integration ? 0 : 1);
+ return [...cards, ...unknown.values()].sort(
+ (a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name),
+ );
+}
+
+/** Group marker paths by folder, so each folder is listed once. */
+export function markerFolders(entries: readonly HubEntry[]): Map {
+ const out = new Map();
+ for (const e of entries) {
+ const m = e.marker?.replace(/\/+$/, "");
+ if (!m) continue;
+ const slash = m.lastIndexOf("/");
+ if (slash < 0) continue;
+ const dir = slash === 0 ? "/" : m.slice(0, slash);
+ const list = out.get(dir) ?? [];
+ if (!list.includes(m)) list.push(m);
+ out.set(dir, list);
+ }
+ return out;
+}
diff --git a/client/src/screens/Payloads/hub/summaries.tsx b/client/src/screens/Payloads/hub/summaries.tsx
new file mode 100644
index 00000000..53400799
--- /dev/null
+++ b/client/src/screens/Payloads/hub/summaries.tsx
@@ -0,0 +1,67 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+
+import { pmOverview } from "../../../api/payloadManager";
+import { smpStatus } from "../../../api/ps5";
+import { mgmtAddr } from "../../../lib/addr";
+import { useTr } from "../../../state/lang";
+import type { IntegrationReport } from "./registry";
+
+/** Payload Manager's card line: its version and how many payloads it keeps. Read once per
+ * console; its own panel (Manage) has the live view. */
+export function PmSummary({
+ host,
+ onReport,
+ fallback,
+}: {
+ host: string;
+ onReport: (r: IntegrationReport) => void;
+ fallback: ReactNode;
+}) {
+ const tr = useTr();
+ const [line, setLine] = useState(null);
+ const reportRef = useRef(onReport);
+ useEffect(() => {
+ reportRef.current = onReport;
+ });
+ useEffect(() => {
+ let cancelled = false;
+ setLine(null);
+ if (!host.trim()) return;
+ pmOverview(host)
+ .then((v) => {
+ if (cancelled || !v.present) return;
+ reportRef.current({ version: v.version || null });
+ setLine(
+ tr("hub_pm_summary", { n: v.payloads.length }, `Payloads stored: ${v.payloads.length}`),
+ );
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ // tr is stable per language; re-reading on a language switch is not needed.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [host]);
+ return <>{line ?? fallback}>;
+}
+
+/** ShadowMount+'s card line: how many game images it has mounted. */
+export function SmpSummary({ host, fallback }: { host: string; fallback: ReactNode }) {
+ const tr = useTr();
+ const [mounted, setMounted] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ setMounted(null);
+ if (!host.trim()) return;
+ smpStatus(mgmtAddr(host.trim()))
+ .then((s) => {
+ if (!cancelled) setMounted(s.mounted_images.length);
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, [host]);
+ if (mounted === null) return <>{fallback}>;
+ return <>{tr("hub_smp_summary", { n: mounted }, `Game images mounted: ${mounted}`)}>;
+}
diff --git a/client/src/screens/Payloads/index.tsx b/client/src/screens/Payloads/index.tsx
index a26bc963..f67a1cae 100644
--- a/client/src/screens/Payloads/index.tsx
+++ b/client/src/screens/Payloads/index.tsx
@@ -1,18 +1,19 @@
-import { Boxes, Globe, HardDrive, Network, Package, Rocket } from "lucide-react";
+import { Boxes, Gamepad2, Rocket } from "lucide-react";
+import { Navigate, useSearchParams } from "react-router";
import TabbedShell, { type TabbedShellTab } from "../../layout/TabbedShell";
import { useTr } from "../../state/lang";
import CatalogPanel from "./CatalogPanel";
import SendPanel from "./SendPanel";
-import PayloadManagerPanel from "./PayloadManagerPanel";
-import PortsPanel from "./PortsPanel";
-import NanoDnsScreen from "../NanoDns";
-import SmpPanel from "../Library/SmpPanel";
-import { ConnectionGate } from "../../components";
+import OnPs5Panel from "./hub/OnPs5Panel";
import { useConnectionStore } from "../../state/connection";
-import { mgmtAddr } from "../../lib/addr";
import { isTauriEnv } from "../../lib/tauriEnv";
-import { payloadTabsFor, type PayloadTabId } from "./payloadTabs";
+import {
+ defaultPayloadTab,
+ legacyPayloadsTarget,
+ payloadTabsFor,
+ type PayloadTabId,
+} from "./payloadTabs";
/**
* Payloads screen — URL-routed tabs:
@@ -20,24 +21,29 @@ import { payloadTabsFor, type PayloadTabId } from "./payloadTabs";
* - **catalog**: curated GitHub-released third-party homebrew.
* - **send**: arbitrary ELF/BIN/JS/LUA/JAR picker. Includes
* playlists and recent-sends history.
- * - **shadowmount** / **nanodns**: those payloads' status and config on
- * the console. The only two the browser build shows (payloadTabsFor).
+ * - **ps5** ("On your PS5"): a card per payload running or installed on the
+ * console; ShadowMount+, nanoDNS, Payload Manager and HearBridge open
+ * their own panel from the card. The only tab the browser build shows.
*
- * (Historically split across /payloads and the old /send-payload route;
- * merged under ?tab=send for a cleaner sidebar. Legacy redirects remain
- * for old bookmarks.)
- *
- * The shell (URL contract + tablist + a11y + keyboard nav + page
- * header) lives in `layout/TabbedShell`; this file is just tab
- * metadata and a panel switch.
+ * The integrations used to be tabs of their own; `?tab=manager|shadowmount|
+ * nanodns` now opens their card, and `?tab=ports` moved to Processes.
*/
type TabId = PayloadTabId;
export default function PayloadsScreen() {
const tr = useTr();
- const host = useConnectionStore((state) => state.host);
- const available = payloadTabsFor(isTauriEnv());
+ const [params] = useSearchParams();
+ const host = useConnectionStore((s) => s.host);
+ const payloadStatus = useConnectionStore((s) => s.payloadStatus);
+ // "Connected" for picking the default tab: a console is set and not known to be down.
+ // (Waiting for "up" would flip a bare /payloads from Catalog to On your PS5 mid-look.)
+ const connected = !!host?.trim() && payloadStatus !== "down";
+ const native = isTauriEnv();
+ const legacy = legacyPayloadsTarget(params.get("tab"));
+ if (legacy) return ;
+
+ const available = payloadTabsFor(native);
const allTabs: ReadonlyArray> = [
{
id: "catalog",
@@ -62,47 +68,14 @@ export default function PayloadsScreen() {
),
},
{
- id: "manager",
- icon: Package,
- key: "payloads_tab_manager",
- fallback: "Payload Manager",
+ id: "ps5",
+ icon: Gamepad2,
+ key: "payloads_tab_ps5",
+ fallback: "On your PS5",
description: tr(
- "payloads_description_manager",
+ "payloads_description_ps5",
undefined,
- "The payloads kept on the PS5 by Payload Manager (itsPLK): launch or delete them, and choose which ones it loads at boot and in what order. Store a catalogue payload there from the Catalog tab.",
- ),
- },
- {
- id: "shadowmount",
- icon: HardDrive,
- key: "payloads_tab_shadowmount",
- fallback: "ShadowMount+",
- description: tr(
- "payloads_description_shadowmount",
- undefined,
- "Inspect ShadowMount+ status, mounted game images, configuration, and diagnostics on the selected PS5.",
- ),
- },
- {
- id: "nanodns",
- icon: Globe,
- key: "payloads_tab_nanodns",
- fallback: "nanoDNS",
- description: tr(
- "payloads_description_nanodns",
- undefined,
- "Configure the nanoDNS payload, verify its running version, and apply safe config migrations.",
- ),
- },
- {
- id: "ports",
- icon: Network,
- key: "payloads_tab_ports",
- fallback: "Ports",
- description: tr(
- "payloads_description_ports",
- undefined,
- "The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.",
+ "The payloads running or installed on the selected PS5. Open a payload's web page, update it, stop it, or manage it.",
),
},
];
@@ -110,28 +83,7 @@ export default function PayloadsScreen() {
const renderPanel = (id: TabId) => {
if (id === "send") return ;
- if (id === "manager") {
- return (
-
-
-
- );
- }
- if (id === "nanodns") return ;
- if (id === "ports") {
- return (
-
-
-
- );
- }
- if (id === "shadowmount") {
- return (
-
-
-
- );
- }
+ if (id === "ps5") return ;
return ;
};
@@ -142,6 +94,7 @@ export default function PayloadsScreen() {
titleKey="payloads"
titleFallback="Payloads"
tabs={tabs}
+ defaultTab={defaultPayloadTab(native, connected)}
renderPanel={renderPanel}
/>
);
diff --git a/client/src/screens/Payloads/payloadTabs.test.ts b/client/src/screens/Payloads/payloadTabs.test.ts
index b5089e9c..8403970d 100644
--- a/client/src/screens/Payloads/payloadTabs.test.ts
+++ b/client/src/screens/Payloads/payloadTabs.test.ts
@@ -3,7 +3,12 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { NAV_ITEMS } from "../../layout/navItems";
-import { payloadTabsFor } from "./payloadTabs";
+import {
+ defaultPayloadTab,
+ legacyPayloadsTarget,
+ payloadCardLink,
+ payloadTabsFor,
+} from "./payloadTabs";
const read = (rel: string): string => readFileSync(new URL(rel, import.meta.url).pathname, "utf8");
const app = read("../../App.tsx");
@@ -11,18 +16,17 @@ const shim = read("../../lib/browserInvoke.ts");
describe("Payloads in the browser build", () => {
it("keeps every tab on the desktop", () => {
- expect(payloadTabsFor(true)).toEqual([
- "catalog",
- "send",
- "manager",
- "shadowmount",
- "nanodns",
- "ports",
- ]);
+ expect(payloadTabsFor(true)).toEqual(["catalog", "send", "ps5"]);
});
- it("offers only the tabs that work through the engine", () => {
- expect(payloadTabsFor(false)).toEqual(["manager", "shadowmount", "nanodns", "ports"]);
+ it("offers only On your PS5, which works through the engine", () => {
+ expect(payloadTabsFor(false)).toEqual(["ps5"]);
+ });
+
+ it("opens on On your PS5 once a console is connected", () => {
+ expect(defaultPayloadTab(true, true)).toBe("ps5");
+ expect(defaultPayloadTab(true, false)).toBe("catalog");
+ expect(defaultPayloadTab(false, false)).toBe("ps5");
});
it("is reachable: in the nav and not behind NativeOnlyRoute", () => {
@@ -31,8 +35,49 @@ describe("Payloads in the browser build", () => {
});
it("relies only on calls the browser build maps", () => {
- for (const cmd of ["smp_status", "fs_read_preview", "fs_write_bytes_run"]) {
+ for (const cmd of [
+ "smp_status",
+ "fs_read_preview",
+ "fs_write_bytes_run",
+ "process_list_get",
+ "process_kill_pid",
+ "ps5_list_dir",
+ ]) {
expect(shim, cmd).toContain(`case "${cmd}":`);
}
});
});
+
+describe("old Payloads links", () => {
+ it("open the integration's card on On your PS5", () => {
+ expect(legacyPayloadsTarget("manager")).toBe("/payloads?tab=ps5&open=payload-manager");
+ expect(legacyPayloadsTarget("shadowmount")).toBe("/payloads?tab=ps5&open=shadowmountplus");
+ expect(legacyPayloadsTarget("nanodns")).toBe("/payloads?tab=ps5&open=nanodns");
+ });
+
+ it("send Ports to Processes & ports", () => {
+ expect(legacyPayloadsTarget("ports")).toBe("/processes?tab=ports");
+ });
+
+ it("leave current tabs alone", () => {
+ for (const t of [null, "catalog", "send", "ps5", "nonsense"]) {
+ expect(legacyPayloadsTarget(t)).toBeNull();
+ }
+ });
+
+ it("are routed in the app", () => {
+ for (const [from, card] of [
+ ["/nanodns", "nanodns"],
+ ["/nano-dns", "nanodns"],
+ ["/shadowmount", "shadowmountplus"],
+ ["/payloads/manager", "payload-manager"],
+ ["/payloads/shadowmount", "shadowmountplus"],
+ ["/payloads/nanodns", "nanodns"],
+ ["/payloads/hearbridge", "hearbridge"],
+ ]) {
+ expect(app, from).toContain(`path="${from}" element={}`);
+ }
+ expect(app).toContain(`path="/payloads/ports" element={}`);
+ expect(payloadCardLink("payload-manager")).toBe("/payloads?tab=ps5&open=payload-manager");
+ });
+});
diff --git a/client/src/screens/Payloads/payloadTabs.ts b/client/src/screens/Payloads/payloadTabs.ts
index a7a491cb..85732f87 100644
--- a/client/src/screens/Payloads/payloadTabs.ts
+++ b/client/src/screens/Payloads/payloadTabs.ts
@@ -1,27 +1,46 @@
-export type PayloadTabId = "catalog" | "send" | "manager" | "shadowmount" | "nanodns" | "ports";
+export type PayloadTabId = "catalog" | "send" | "ps5";
-const ALL_TABS: readonly PayloadTabId[] = [
- "catalog",
- "send",
- "manager",
- "shadowmount",
- "nanodns",
- "ports",
-];
+const ALL_TABS: readonly PayloadTabId[] = ["catalog", "send", "ps5"];
/**
* The Payloads tabs this build can use.
*
* Catalog and Send download ELFs to this machine and write them to the
* console's loader socket, which the browser build cannot do (payload_send and
- * payloads_* have no browser mapping). ShadowMount+ and nanoDNS only read and
- * write files on the console through the engine (smp_status, fs_read_preview,
- * fs_write_bytes_run), so the web UI keeps those two. Payload Manager talks to the
- * console's :8084 through the engine, so the web UI keeps it as well, and Ports is one engine
- * call (net.ports).
+ * payloads_* have no browser mapping). "On your PS5" only talks to the console
+ * through the engine (process list, file reads, the integrations' engine
+ * routes), so it is the one tab the web UI keeps.
*/
export function payloadTabsFor(native: boolean): readonly PayloadTabId[] {
- return native
- ? ALL_TABS
- : ALL_TABS.filter((t) => t === "manager" || t === "shadowmount" || t === "nanodns" || t === "ports");
+ return native ? ALL_TABS : ALL_TABS.filter((t) => t === "ps5");
+}
+
+/** The tab a bare /payloads opens on: what is on the console once one is connected,
+ * the catalogue before that. */
+export function defaultPayloadTab(native: boolean, connected: boolean): PayloadTabId {
+ if (!native || connected) return "ps5";
+ return "catalog";
+}
+
+/** The card id each retired per-integration tab now opens on "On your PS5". */
+const LEGACY_CARD: Record = {
+ manager: "payload-manager",
+ shadowmount: "shadowmountplus",
+ nanodns: "nanodns",
+ hearbridge: "hearbridge",
+};
+
+/** Where an old Payloads tab (`?tab=manager|shadowmount|nanodns|ports`) lives now, or null
+ * for a current tab. Integrations became cards on "On your PS5"; Ports moved next to
+ * Processes. */
+export function legacyPayloadsTarget(tab: string | null): string | null {
+ if (!tab) return null;
+ if (tab === "ports") return "/processes?tab=ports";
+ const card = LEGACY_CARD[tab];
+ return card ? payloadCardLink(card) : null;
+}
+
+/** The link that opens "On your PS5" with one card's Manage panel expanded. */
+export function payloadCardLink(cardId: string): string {
+ return `/payloads?tab=ps5&open=${encodeURIComponent(cardId)}`;
}
diff --git a/client/src/screens/Payloads/PortsPanel.tsx b/client/src/screens/Processes/PortsPanel.tsx
similarity index 100%
rename from client/src/screens/Payloads/PortsPanel.tsx
rename to client/src/screens/Processes/PortsPanel.tsx
diff --git a/client/src/screens/Processes/index.tsx b/client/src/screens/Processes/index.tsx
index c142571c..f7d4d830 100644
--- a/client/src/screens/Processes/index.tsx
+++ b/client/src/screens/Processes/index.tsx
@@ -1,12 +1,15 @@
import { usePoll, useTransferBusy } from "../../lib/usePoll";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Cpu, RefreshCw, RotateCw, Skull, TriangleAlert, Layers } from "lucide-react";
+import { Cpu, RefreshCw, RotateCw, Skull, TriangleAlert, Layers, Network } from "lucide-react";
+import { useSearchParams } from "react-router";
+
+import TabbedShell, { type TabbedShellTab } from "../../layout/TabbedShell";
+import PortsPanel from "./PortsPanel";
import { useConnectionStore } from "../../state/connection";
import { mgmtAddr, transferAddr } from "../../lib/addr";
import { useDocumentVisible } from "../../lib/visibility";
import {
- PageHeader,
Button,
EmptyState,
ErrorCard,
@@ -56,7 +59,63 @@ const RESTART_RELAUNCH_DELAY_MS = 1200;
type SortKey = "memory" | "pid" | "name" | "threads";
+/**
+ * "Processes & ports": the running processes and, beside them, the ports in use (a port is
+ * found by binding it, so it can't be tied to a pid; the two lists stay separate).
+ */
export default function ProcessesScreen() {
+ const tr = useTr();
+ const [count, setCount] = useState(undefined);
+ const [searchParams] = useSearchParams();
+ const tab: ProcessesTabId = searchParams.get("tab") === "ports" ? "ports" : "processes";
+ const tabs: ReadonlyArray> = [
+ {
+ id: "processes",
+ icon: Layers,
+ key: "processes_title",
+ fallback: "Processes",
+ description: tr(
+ "processes_subtitle_v2",
+ undefined,
+ "The programs running on the PS5 right now. You can stop or restart one; take care with system processes, since stopping the wrong one can freeze the console.",
+ ),
+ },
+ {
+ id: "ports",
+ icon: Network,
+ key: "payloads_tab_ports",
+ fallback: "Ports",
+ description: tr(
+ "payloads_description_ports",
+ undefined,
+ "The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.",
+ ),
+ },
+ ];
+ return (
+
+ id === "ports" ? (
+
+
+
+ ) : (
+
+ )
+ }
+ />
+ );
+}
+
+type ProcessesTabId = "processes" | "ports";
+
+function ProcessesPanel({ onCount }: { onCount: (n: number | undefined) => void }) {
const tr = useTr();
const host = useConnectionStore((s) => s.host);
const payloadStatus = useConnectionStore((s) => s.payloadStatus);
@@ -261,20 +320,12 @@ export default function ProcessesScreen() {
}, [procs, showSystem, sortKey]);
const systemCount = procs.filter((p) => p.kind === "system").length;
+ useEffect(() => {
+ onCount(procs.length || undefined);
+ }, [procs.length, onCount]);
return (
-
-
-
+ <>
)}
-
+ >
);
}
diff --git a/engine/crates/ps5upload-core/src/hearbridge.rs b/engine/crates/ps5upload-core/src/hearbridge.rs
new file mode 100644
index 00000000..e57ccc00
--- /dev/null
+++ b/engine/crates/ps5upload-core/src/hearbridge.rs
@@ -0,0 +1,710 @@
+//! HearBridge (X-F1REBALL-X/HearBridge-PS5): Bluetooth headset audio for the PS5, with its
+//! own small HTTP API. The app reads and drives it through the engine because the page
+//! sends no CORS headers, so a browser page on another origin can't call it directly.
+//!
+//! Its API, read from its source (src/http.c):
+//! HTTP/1.1, `Connection: close`, on TCP 8090; when that port is taken it tries the next
+//! five (8091..8095).
+//! GET /api/status one JSON object: version, token, state, device, gain_pct, muted,
+//! codec, latency{target_ms,estimate_ms}, battery{pct}, link{score,
+//! rssi}, night{on}, lat_extra{auto}, game{id,name,profile}, events[]…
+//! GET /api/saved {"devices":[{addr,name,kind,current}]} paired headsets
+//! GET /api/devices {"version":1,"devices":[{index,addr,name,kind,rssi}]} last scan
+//! POST /api/volume?pct=0..500 /api/mute?on=0|1 /api/night?on=0|1
+//! /api/latency?auto=0|1 or ?ms=40..400 /api/reconnect /api/stop
+//! /api/select?index=N or ?addr=XX:XX:.. /api/scan?user=1 (without user=1 a
+//! scan is ignored: an inquiry holds the radio the DualSense shares)
+//! Every POST needs header `X-HB-Token` with the token from /api/status (403 otherwise).
+//!
+//! Nothing is cached on disk. The port a console's HearBridge answered on is remembered in
+//! memory so the next call tries it first; the token is read fresh for every change.
+//!
+//! Plain HTTP over a TcpStream, like `payload_manager`: the Android engine has no HTTP
+//! client crate.
+
+use std::collections::HashMap;
+use std::io::{Read, Write};
+use std::net::TcpStream;
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, Instant};
+
+use serde::Deserialize;
+use serde_json::Value;
+
+/// HearBridge's port, then the five it falls back to.
+pub const HEARBRIDGE_PORTS: [u16; 6] = [8090, 8091, 8092, 8093, 8094, 8095];
+
+const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500);
+const IO_TIMEOUT: Duration = Duration::from_secs(5);
+/// Finding HearBridge (every port, if need be) never takes longer than this.
+const FIND_DEADLINE: Duration = Duration::from_secs(8);
+/// /api/status is at most 32 KiB (its own buffer); saved/devices at most 8 KiB.
+const MAX_RESPONSE: usize = 256 * 1024;
+
+fn port_cache() -> &'static Mutex> {
+ static CACHE: OnceLock>> = OnceLock::new();
+ CACHE.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+/// A HearBridge that answered: its port, its status (without the token) and the token.
+#[derive(Debug, Clone)]
+pub struct Found {
+ pub port: u16,
+ pub status: Value,
+ token: String,
+}
+
+/// Find HearBridge on `host` and read its status.
+pub fn status(host: &str) -> Result {
+ find_on(host, &HEARBRIDGE_PORTS)
+}
+
+fn find_on(host: &str, ports: &[u16]) -> Result {
+ check_host(host)?;
+ let deadline = Instant::now() + FIND_DEADLINE;
+ let cached = port_cache().lock().ok().and_then(|c| c.get(host).copied());
+ // The port it answered on last time first, then HearBridge's own order.
+ let mut order: Vec = Vec::with_capacity(ports.len());
+ if let Some(p) = cached.filter(|p| ports.contains(p)) {
+ order.push(p);
+ }
+ order.extend(ports.iter().copied().filter(|p| Some(*p) != cached));
+ let mut last = String::new();
+ for port in order {
+ if Instant::now() >= deadline {
+ last = format!("gave up after {}s", FIND_DEADLINE.as_secs());
+ break;
+ }
+ match status_at(host, port, deadline) {
+ Ok(found) => {
+ if let Ok(mut c) = port_cache().lock() {
+ c.insert(host.to_string(), port);
+ }
+ return Ok(found);
+ }
+ Err(e) => {
+ last = e.msg;
+ // Nothing answered at all (the console is off or unreachable): the other
+ // ports are on the same host, so they would only time out the same way.
+ if e.connect_timed_out {
+ break;
+ }
+ }
+ }
+ }
+ if let Ok(mut c) = port_cache().lock() {
+ c.remove(host);
+ }
+ let range = match (ports.first(), ports.last()) {
+ (Some(a), Some(b)) if a != b => format!("{a}-{b}"),
+ (Some(a), _) => a.to_string(),
+ _ => String::new(),
+ };
+ Err(format!(
+ "HearBridge didn't answer on {host} port {range} ({last})"
+ ))
+}
+
+/// A console address the routes accept: no whitespace, control characters or URL syntax
+/// that could change where the request goes or what it says.
+pub fn check_host(host: &str) -> Result<(), String> {
+ let bad = host.is_empty()
+ || host.len() > 255
+ || host.chars().any(|c| {
+ c.is_whitespace() || c.is_control() || matches!(c, '/' | '\\' | '?' | '#' | '@')
+ });
+ if bad {
+ return Err(format!("not a console address: {host:?}"));
+ }
+ Ok(())
+}
+
+fn status_at(host: &str, port: u16, deadline: Instant) -> Result {
+ let (code, body) = request(host, port, "GET", "/api/status", None, deadline)?;
+ if code != 200 {
+ return Err(format!(":{port} answered HTTP {code}").into());
+ }
+ parse_status(&body)
+ .map(|(status, token)| Found {
+ port,
+ status,
+ token,
+ })
+ .map_err(HttpErr::from)
+}
+
+/// The status object without its token, and the token. Anything that isn't a JSON object
+/// with a `version` string is not HearBridge (another service on that port).
+fn parse_status(body: &[u8]) -> Result<(Value, String), String> {
+ let mut v: Value =
+ serde_json::from_slice(body).map_err(|_| "not HearBridge (no JSON status)".to_string())?;
+ let obj = v
+ .as_object_mut()
+ .ok_or_else(|| "not HearBridge (status is not an object)".to_string())?;
+ if !obj.get("version").is_some_and(Value::is_string) {
+ return Err("not HearBridge (no version in its status)".into());
+ }
+ let token = match obj.remove("token") {
+ Some(Value::String(t)) => t,
+ _ => String::new(),
+ };
+ Ok((v, token))
+}
+
+/// Which list to read.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DeviceList {
+ /// Paired headsets (`/api/saved`).
+ Saved,
+ /// The last scan's results (`/api/devices`).
+ Scanned,
+}
+
+/// `{"devices":[…]}` from HearBridge; an empty list when it has none or sent something else.
+pub fn devices(host: &str, which: DeviceList) -> Result {
+ devices_on(host, which, &HEARBRIDGE_PORTS)
+}
+
+fn devices_on(host: &str, which: DeviceList, ports: &[u16]) -> Result {
+ let found = find_on(host, ports)?;
+ let path = match which {
+ DeviceList::Saved => "/api/saved",
+ DeviceList::Scanned => "/api/devices",
+ };
+ let (code, body) = request(host, found.port, "GET", path, None, io_deadline())?;
+ if code != 200 {
+ return Err(format!("HearBridge could not list devices (HTTP {code})"));
+ }
+ Ok(parse_devices(&body))
+}
+
+fn parse_devices(body: &[u8]) -> Value {
+ let list = serde_json::from_slice::(body)
+ .ok()
+ .and_then(|v| v.get("devices").filter(|d| d.is_array()).cloned())
+ .unwrap_or_else(|| Value::Array(Vec::new()));
+ serde_json::json!({ "devices": list })
+}
+
+/// An action's parameters, as the app sends them.
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct ActionParams {
+ pub pct: Option,
+ pub on: Option,
+ pub index: Option,
+ pub addr: Option,
+ pub auto: Option,
+ pub ms: Option,
+}
+
+/// The changes the app makes. Pairing, EQ, backups and the rest stay on HearBridge's page.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Action {
+ Volume(u16),
+ Mute(bool),
+ Reconnect,
+ SelectIndex(u8),
+ SelectAddr(String),
+ Scan,
+ Night(bool),
+ LatencyAuto(bool),
+ LatencyMs(u16),
+ Stop,
+}
+
+fn is_mac(s: &str) -> bool {
+ let b = s.as_bytes();
+ b.len() == 17
+ && b.iter().enumerate().all(|(i, c)| {
+ if i % 3 == 2 {
+ *c == b':'
+ } else {
+ c.is_ascii_hexdigit()
+ }
+ })
+}
+
+impl Action {
+ /// The action `name` with its parameters checked against the ranges HearBridge takes.
+ /// Anything else is refused here, so only these requests ever reach the console.
+ pub fn parse(name: &str, p: &ActionParams) -> Result {
+ let need_on = || p.on.ok_or_else(|| format!("{name} needs on"));
+ Ok(match name {
+ "volume" => match p.pct {
+ Some(v) if (0..=500).contains(&v) => Action::Volume(v as u16),
+ _ => return Err("volume needs pct 0-500".into()),
+ },
+ "mute" => Action::Mute(need_on()?),
+ "night" => Action::Night(need_on()?),
+ "reconnect" => Action::Reconnect,
+ "scan" => Action::Scan,
+ "stop" => Action::Stop,
+ "select" => match (&p.addr, p.index) {
+ (Some(a), _) if is_mac(a) => Action::SelectAddr(a.to_ascii_uppercase()),
+ (Some(_), _) => return Err("select needs a Bluetooth address".into()),
+ (None, Some(i)) if (0..64).contains(&i) => Action::SelectIndex(i as u8),
+ _ => return Err("select needs index 0-63 or addr".into()),
+ },
+ "latency" => match (p.auto, p.ms) {
+ (Some(a), _) => Action::LatencyAuto(a),
+ (None, Some(ms)) if (40..=400).contains(&ms) => Action::LatencyMs(ms as u16),
+ _ => return Err("latency needs auto or ms 40-400".into()),
+ },
+ other => return Err(format!("unknown HearBridge action {other:?}")),
+ })
+ }
+
+ fn path(&self) -> String {
+ let b = |on: bool| if on { 1 } else { 0 };
+ match self {
+ Action::Volume(v) => format!("/api/volume?pct={v}"),
+ Action::Mute(on) => format!("/api/mute?on={}", b(*on)),
+ Action::Night(on) => format!("/api/night?on={}", b(*on)),
+ Action::Reconnect => "/api/reconnect".into(),
+ Action::Scan => "/api/scan?user=1".into(),
+ Action::Stop => "/api/stop".into(),
+ Action::SelectIndex(i) => format!("/api/select?index={i}"),
+ Action::SelectAddr(a) => format!("/api/select?addr={a}"),
+ Action::LatencyAuto(a) => format!("/api/latency?auto={}", b(*a)),
+ Action::LatencyMs(ms) => format!("/api/latency?ms={ms}"),
+ }
+ }
+}
+
+/// Send `action` to HearBridge on `host`. Returns its answer: the new status (token removed)
+/// for most actions, `{"ok":1}` for select/scan/reconnect.
+pub fn act(host: &str, action: &Action) -> Result {
+ act_on(host, action, &HEARBRIDGE_PORTS)
+}
+
+fn act_on(host: &str, action: &Action, ports: &[u16]) -> Result {
+ let path = action.path();
+ let mut found = find_on(host, ports)?;
+ for attempt in 0..2 {
+ if found.token.is_empty() {
+ return Err("HearBridge gave no token; it may be too old for changes from here".into());
+ }
+ let (code, body) = request(
+ host,
+ found.port,
+ "POST",
+ &path,
+ Some(&found.token),
+ io_deadline(),
+ )?;
+ // 403: the token changed (HearBridge restarted between the read and the change).
+ if code == 403 && attempt == 0 {
+ found = match status_at(host, found.port, io_deadline()) {
+ Ok(f) => f,
+ Err(_) => find_on(host, ports)?,
+ };
+ continue;
+ }
+ if code != 200 {
+ let msg = String::from_utf8_lossy(&body);
+ return Err(format!(
+ "HearBridge refused {path} (HTTP {code}: {})",
+ msg.trim()
+ ));
+ }
+ let mut v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
+ if let Some(o) = v.as_object_mut() {
+ o.remove("token");
+ }
+ return Ok(v);
+ }
+ Err("HearBridge refused the token".into())
+}
+
+/// A failed request, and whether it failed because nothing accepted the connection in time.
+#[derive(Debug)]
+struct HttpErr {
+ msg: String,
+ connect_timed_out: bool,
+}
+
+impl From for HttpErr {
+ fn from(msg: String) -> Self {
+ HttpErr {
+ msg,
+ connect_timed_out: false,
+ }
+ }
+}
+
+impl From for String {
+ fn from(e: HttpErr) -> Self {
+ e.msg
+ }
+}
+
+/// The deadline for one request outside discovery.
+fn io_deadline() -> Instant {
+ Instant::now() + IO_TIMEOUT
+}
+
+fn request(
+ host: &str,
+ port: u16,
+ method: &str,
+ path: &str,
+ token: Option<&str>,
+ deadline: Instant,
+) -> Result<(u16, Vec), HttpErr> {
+ let left = || deadline.saturating_duration_since(Instant::now());
+ let addr = crate::payload_lifecycle::join_host_port(host, port);
+ let sock = std::net::ToSocketAddrs::to_socket_addrs(&addr)
+ .map_err(|e| format!("resolve {addr}: {e}"))?
+ .next()
+ .ok_or_else(|| format!("resolve {addr}: no address"))?;
+ let connect_for = CONNECT_TIMEOUT.min(left());
+ if connect_for.is_zero() {
+ return Err(format!("no time left to connect to {addr}").into());
+ }
+ let mut stream = TcpStream::connect_timeout(&sock, connect_for).map_err(|e| HttpErr {
+ connect_timed_out: e.kind() == std::io::ErrorKind::TimedOut,
+ msg: format!("connect {addr}: {e}"),
+ })?;
+ let io_for = IO_TIMEOUT.min(left()).max(Duration::from_millis(1));
+ stream.set_read_timeout(Some(io_for)).ok();
+ stream.set_write_timeout(Some(io_for)).ok();
+ stream
+ .write_all(request_head(method, path, &addr, token).as_bytes())
+ .map_err(|e| format!("send to {addr}: {e}"))?;
+ // Read to the end, but never past the deadline: a server that trickles bytes would
+ // otherwise reset the per-read timeout forever.
+ let mut resp = Vec::new();
+ let mut buf = [0u8; 8192];
+ loop {
+ if Instant::now() >= deadline {
+ return Err(format!("{addr} took too long to answer").into());
+ }
+ match stream.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ resp.extend_from_slice(&buf[..n]);
+ if resp.len() > MAX_RESPONSE {
+ return Err(format!("{addr} sent more than {MAX_RESPONSE} bytes").into());
+ }
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(e) => return Err(format!("read from {addr}: {e}").into()),
+ }
+ }
+ parse_response(&resp).ok_or_else(|| format!("{addr} sent a response that is not HTTP").into())
+}
+
+/// The request line and headers. The User-Agent starts with "curl" on purpose: HearBridge
+/// takes a status read from anything else as its page being open in a browser
+/// (`page_seen_ms`), which may start a link wake meant for someone looking at that page
+/// (http.c `/api/status`, main.c `lw_tick`). It does not avoid everything: every status read
+/// still counts in `status_polls`, which lets HearBridge re-query the headset's volume while
+/// reads keep coming, so the app polls slowly unless the Manage panel is open.
+fn request_head(method: &str, path: &str, addr: &str, token: Option<&str>) -> String {
+ let mut head = format!(
+ "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: curl-compatible ps5upload\r\n"
+ );
+ if let Some(t) = token {
+ head.push_str(&format!("X-HB-Token: {t}\r\n"));
+ }
+ if method == "POST" {
+ head.push_str("Content-Length: 0\r\n");
+ }
+ head.push_str("\r\n");
+ head
+}
+
+fn parse_response(resp: &[u8]) -> Option<(u16, Vec)> {
+ let split = resp.windows(4).position(|w| w == b"\r\n\r\n")?;
+ let head = std::str::from_utf8(&resp[..split]).ok()?;
+ let status: u16 = head
+ .lines()
+ .next()?
+ .split_whitespace()
+ .nth(1)?
+ .parse()
+ .ok()?;
+ Some((status, resp[split + 4..].to_vec()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::TcpListener;
+ use std::sync::Arc;
+ use std::thread;
+ use std::time::Instant;
+
+ /// One request as the fake server saw it.
+ #[derive(Debug, Clone)]
+ struct Seen {
+ line: String,
+ token: Option,
+ ua: Option,
+ }
+
+ /// A fake HearBridge on an ephemeral port: answers each request with `reply(line)`.
+ fn fake(
+ reply: impl Fn(&str, Option<&str>) -> (u16, String) + Send + Sync + 'static,
+ ) -> (u16, Arc>>) {
+ let l = TcpListener::bind("127.0.0.1:0").unwrap();
+ let port = l.local_addr().unwrap().port();
+ let seen = Arc::new(Mutex::new(Vec::new()));
+ let seen2 = seen.clone();
+ let reply = Arc::new(reply);
+ thread::spawn(move || {
+ for s in l.incoming() {
+ let Ok(mut s) = s else { continue };
+ let mut buf = [0u8; 4096];
+ let mut got = Vec::new();
+ while !got.windows(4).any(|w| w == b"\r\n\r\n") {
+ match s.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => got.extend_from_slice(&buf[..n]),
+ }
+ }
+ let text = String::from_utf8_lossy(&got).to_string();
+ let line = text.lines().next().unwrap_or("").to_string();
+ let header = |name: &str| {
+ text.lines().find_map(|l| {
+ let (k, v) = l.split_once(':')?;
+ k.eq_ignore_ascii_case(name).then(|| v.trim().to_string())
+ })
+ };
+ let token = header("X-HB-Token");
+ seen2.lock().unwrap().push(Seen {
+ line: line.clone(),
+ token: token.clone(),
+ ua: header("User-Agent"),
+ });
+ let (code, body) = reply(&line, token.as_deref());
+ let _ = write!(
+ s,
+ "HTTP/1.1 {code} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ }
+ });
+ (port, seen)
+ }
+
+ fn closed_port() -> u16 {
+ let l = TcpListener::bind("127.0.0.1:0").unwrap();
+ l.local_addr().unwrap().port()
+ }
+
+ const STATUS: &str = r#"{"version":"1.9.0","token":"abc123","connected":1,"state":"streaming","device":"WH-1000XM4","gain_pct":120,"muted":0,"codec":"SBC","latency":{"target_ms":120,"estimate_ms":180},"battery":{"status":"ok","level":3,"pct":80},"link":{"score":92,"rssi":-50},"events":["Connected"]}"#;
+
+ #[test]
+ fn reads_the_status_without_handing_out_the_token() {
+ let (port, seen) = fake(|_, _| (200, STATUS.into()));
+ let f = find_on("127.0.0.1", &[port]).unwrap();
+ assert_eq!(f.port, port);
+ assert_eq!(f.status["device"], "WH-1000XM4");
+ assert!(f.status.get("token").is_none());
+ assert_eq!(f.token, "abc123");
+ let s = seen.lock().unwrap();
+ assert_eq!(s[0].line, "GET /api/status HTTP/1.1");
+ // Not counted as HearBridge's page being open (see request_head).
+ assert!(s[0].ua.as_deref().unwrap().starts_with("curl"));
+ }
+
+ #[test]
+ fn tolerates_an_older_status_with_few_fields() {
+ let (status, token) = parse_status(br#"{"version":"0.3"}"#).unwrap();
+ assert_eq!(status["version"], "0.3");
+ assert_eq!(token, "");
+ assert!(parse_status(b"").is_err());
+ assert!(parse_status(br#"{"ok":1}"#).is_err());
+ assert!(parse_status(br#"[1]"#).is_err());
+ }
+
+ #[test]
+ fn falls_back_to_the_next_port_like_hearbridge_does() {
+ let dead = closed_port();
+ // Something else on the second port: answers, but isn't HearBridge.
+ let (other, _) = fake(|_, _| (200, "hi".into()));
+ let (hb, _) = fake(|_, _| (200, STATUS.into()));
+ let host = "127.0.0.1";
+ let f = find_on(host, &[dead, other, hb]).unwrap();
+ assert_eq!(f.port, hb);
+ let err = find_on(host, &[dead]).unwrap_err();
+ assert!(err.contains("HearBridge didn't answer"), "{err}");
+ }
+
+ #[test]
+ fn sends_the_token_with_every_change() {
+ let (port, seen) = fake(|line, token| {
+ if line.starts_with("GET /api/status") {
+ (200, STATUS.into())
+ } else if token == Some("abc123") {
+ (
+ 200,
+ r#"{"version":"1.9.0","token":"abc123","gain_pct":200}"#.into(),
+ )
+ } else {
+ (403, r#"{"error":"token"}"#.into())
+ }
+ });
+ let v = act_on("127.0.0.1", &Action::Volume(200), &[port]).unwrap();
+ assert_eq!(v["gain_pct"], 200);
+ assert!(v.get("token").is_none());
+ let s = seen.lock().unwrap();
+ let post = s.iter().find(|r| r.line.starts_with("POST")).unwrap();
+ assert_eq!(post.line, "POST /api/volume?pct=200 HTTP/1.1");
+ assert_eq!(post.token.as_deref(), Some("abc123"));
+ }
+
+ #[test]
+ fn rereads_the_token_once_when_hearbridge_restarted() {
+ let calls = Arc::new(Mutex::new(0));
+ let c2 = calls.clone();
+ let (port, _) = fake(move |line, token| {
+ if line.starts_with("GET /api/status") {
+ let mut n = c2.lock().unwrap();
+ *n += 1;
+ // First read hands out a token that is stale by the time it is used.
+ let t = if *n == 1 { "old" } else { "new" };
+ (200, format!(r#"{{"version":"1.9.0","token":"{t}"}}"#))
+ } else if token == Some("new") {
+ (200, r#"{"ok":1}"#.into())
+ } else {
+ (403, r#"{"error":"token"}"#.into())
+ }
+ });
+ let v = act_on("127.0.0.1", &Action::Reconnect, &[port]).unwrap();
+ assert_eq!(v["ok"], 1);
+ }
+
+ #[test]
+ fn a_missing_token_is_a_clear_error() {
+ let (port, _) = fake(|_, _| (200, r#"{"version":"0.3"}"#.into()));
+ let e = act_on("127.0.0.1", &Action::Stop, &[port]).unwrap_err();
+ assert!(e.contains("no token"), "{e}");
+ }
+
+ #[test]
+ fn lists_devices_and_survives_garbage() {
+ let (port, _) = fake(|line, _| {
+ if line.starts_with("GET /api/status") {
+ (200, STATUS.into())
+ } else if line.starts_with("GET /api/saved") {
+ (
+ 200,
+ r#"{"devices":[{"addr":"AA:BB:CC:DD:EE:FF","name":"Buds","kind":"audio","current":1}]}"#
+ .into(),
+ )
+ } else {
+ (200, "not json".into())
+ }
+ });
+ let saved = devices_on("127.0.0.1", DeviceList::Saved, &[port]).unwrap();
+ assert_eq!(saved["devices"][0]["name"], "Buds");
+ let scanned = devices_on("127.0.0.1", DeviceList::Scanned, &[port]).unwrap();
+ assert_eq!(scanned, serde_json::json!({ "devices": [] }));
+ }
+
+ #[test]
+ fn refuses_console_addresses_that_are_not_one() {
+ for ok in ["192.168.86.99", "ps5.local", "[fe80::1]", "10.0.0.2:9120"] {
+ assert!(check_host(ok).is_ok(), "{ok}");
+ }
+ for bad in [
+ "",
+ "1.2.3.4 x",
+ "1.2.3.4\r\nX: y",
+ "1.2.3.4/api",
+ "a?b",
+ "u@h",
+ "a\\b",
+ ] {
+ assert!(check_host(bad).is_err(), "{bad:?}");
+ assert!(find_on(bad, &[1]).is_err());
+ }
+ }
+
+ #[test]
+ fn an_unreachable_console_is_not_tried_on_every_port() {
+ // A blackholed address: the first connect times out (or the network says
+ // unreachable at once); either way the other five ports are not waited on.
+ let t = Instant::now();
+ let e = find_on("10.255.255.1", &HEARBRIDGE_PORTS).unwrap_err();
+ assert!(
+ t.elapsed() < Duration::from_secs(4),
+ "{:?} {e}",
+ t.elapsed()
+ );
+ }
+
+ #[test]
+ fn a_server_that_never_answers_is_given_up_on() {
+ // Accepts and then says nothing: the read times out, discovery moves on.
+ let l = TcpListener::bind("127.0.0.1:0").unwrap();
+ let silent = l.local_addr().unwrap().port();
+ thread::spawn(move || {
+ let _held: Vec<_> = l.incoming().take(4).collect();
+ thread::sleep(Duration::from_secs(30));
+ });
+ let (hb, _) = fake(|_, _| (200, STATUS.into()));
+ let t = Instant::now();
+ assert_eq!(find_on("127.0.0.1", &[silent, hb]).unwrap().port, hb);
+ assert!(t.elapsed() <= FIND_DEADLINE + Duration::from_secs(1));
+ }
+
+ #[test]
+ fn only_allowlisted_actions_with_sane_values_pass() {
+ let p = |f: fn(&mut ActionParams)| {
+ let mut a = ActionParams::default();
+ f(&mut a);
+ a
+ };
+ assert_eq!(
+ Action::parse("volume", &p(|a| a.pct = Some(500)))
+ .unwrap()
+ .path(),
+ "/api/volume?pct=500"
+ );
+ assert!(Action::parse("volume", &p(|a| a.pct = Some(501))).is_err());
+ assert!(Action::parse("volume", &p(|_| {})).is_err());
+ assert_eq!(
+ Action::parse("mute", &p(|a| a.on = Some(true)))
+ .unwrap()
+ .path(),
+ "/api/mute?on=1"
+ );
+ assert_eq!(
+ Action::parse("scan", &p(|_| {})).unwrap().path(),
+ "/api/scan?user=1"
+ );
+ assert_eq!(
+ Action::parse("select", &p(|a| a.index = Some(3)))
+ .unwrap()
+ .path(),
+ "/api/select?index=3"
+ );
+ assert_eq!(
+ Action::parse("select", &p(|a| a.addr = Some("aa:bb:cc:dd:ee:ff".into())))
+ .unwrap()
+ .path(),
+ "/api/select?addr=AA:BB:CC:DD:EE:FF"
+ );
+ assert!(Action::parse("select", &p(|a| a.addr = Some("x&y=1".into()))).is_err());
+ assert!(Action::parse("select", &p(|a| a.index = Some(64))).is_err());
+ assert_eq!(
+ Action::parse("latency", &p(|a| a.auto = Some(false)))
+ .unwrap()
+ .path(),
+ "/api/latency?auto=0"
+ );
+ assert_eq!(
+ Action::parse("latency", &p(|a| a.ms = Some(40)))
+ .unwrap()
+ .path(),
+ "/api/latency?ms=40"
+ );
+ assert!(Action::parse("latency", &p(|a| a.ms = Some(39))).is_err());
+ for refused in ["forget", "eq", "restore", "backup", "hci", "../stop", ""] {
+ assert!(Action::parse(refused, &p(|_| {})).is_err(), "{refused}");
+ }
+ }
+}
diff --git a/engine/crates/ps5upload-core/src/lib.rs b/engine/crates/ps5upload-core/src/lib.rs
index 0b6040e7..9262ee8c 100644
--- a/engine/crates/ps5upload-core/src/lib.rs
+++ b/engine/crates/ps5upload-core/src/lib.rs
@@ -17,6 +17,7 @@ pub mod fs_ops;
pub mod fw_spoof;
pub mod game_meta;
pub mod health;
+pub mod hearbridge;
pub mod hw;
pub mod installer_client;
pub mod local_image;
diff --git a/engine/crates/ps5upload-engine/src/hearbridge_api.rs b/engine/crates/ps5upload-engine/src/hearbridge_api.rs
new file mode 100644
index 00000000..284fcb42
--- /dev/null
+++ b/engine/crates/ps5upload-engine/src/hearbridge_api.rs
@@ -0,0 +1,203 @@
+//! `/api/ps5/hearbridge/*` — the console's HearBridge (Bluetooth headset audio, HTTP on
+//! :8090..8095) as the app sees it: its status, its saved and scanned headsets, and the
+//! few changes the app makes. Every call is a plain HTTP request to the console
+//! (`ps5upload_core::hearbridge`); its token never leaves the engine.
+
+use axum::{
+ extract::Query,
+ http::StatusCode,
+ response::{IntoResponse, Response},
+ routing::{get, post},
+ Json, Router,
+};
+use serde::Deserialize;
+
+use ps5upload_core::hearbridge as hb;
+
+/// The routes, for any router state (the handlers need none).
+pub(crate) fn routes() -> Router {
+ Router::new()
+ .route("/api/ps5/hearbridge/status", get(status))
+ .route("/api/ps5/hearbridge/saved", get(saved))
+ .route("/api/ps5/hearbridge/devices", get(devices))
+ .route("/api/ps5/hearbridge/action", post(action))
+}
+
+#[derive(Deserialize)]
+pub(crate) struct HostParams {
+ #[serde(alias = "addr")]
+ host: String,
+}
+
+fn bad_gateway(e: String) -> Response {
+ crate::json_err(StatusCode::BAD_GATEWAY, e).into_response()
+}
+
+/// The console's address, or a 400 for something that isn't one (whitespace, control
+/// characters, URL syntax), refused before anything is sent anywhere.
+#[allow(clippy::result_large_err)]
+fn host(h: &str) -> Result {
+ let h = h.trim();
+ hb::check_host(h).map_err(|e| crate::json_err(StatusCode::BAD_REQUEST, e).into_response())?;
+ Ok(ps5upload_core::events::console_host(h))
+}
+
+/// GET /api/ps5/hearbridge/status?host= — `{ok, present:true, port, status}`, or
+/// `{ok, present:false, error}` when nothing answers on 8090..8095.
+pub(crate) async fn status(Query(q): Query) -> Response {
+ let h = match host(&q.host) {
+ Ok(h) => h,
+ Err(r) => return r,
+ };
+ match tokio::task::spawn_blocking(move || hb::status(&h)).await {
+ Ok(Ok(f)) => Json(serde_json::json!({
+ "ok": true,
+ "present": true,
+ "port": f.port,
+ "status": f.status,
+ }))
+ .into_response(),
+ Ok(Err(e)) => {
+ Json(serde_json::json!({ "ok": true, "present": false, "error": e })).into_response()
+ }
+ Err(e) => bad_gateway(format!("{e}")),
+ }
+}
+
+async fn list(q: HostParams, which: hb::DeviceList) -> Response {
+ let h = match host(&q.host) {
+ Ok(h) => h,
+ Err(r) => return r,
+ };
+ match tokio::task::spawn_blocking(move || hb::devices(&h, which)).await {
+ Ok(Ok(v)) => {
+ Json(serde_json::json!({ "ok": true, "devices": v["devices"] })).into_response()
+ }
+ Ok(Err(e)) => bad_gateway(e),
+ Err(e) => bad_gateway(format!("{e}")),
+ }
+}
+
+/// GET /api/ps5/hearbridge/saved?host= — the paired headsets `{ok, devices}`.
+pub(crate) async fn saved(Query(q): Query) -> Response {
+ list(q, hb::DeviceList::Saved).await
+}
+
+/// GET /api/ps5/hearbridge/devices?host= — the last scan's results `{ok, devices}`.
+pub(crate) async fn devices(Query(q): Query) -> Response {
+ list(q, hb::DeviceList::Scanned).await
+}
+
+#[derive(Deserialize)]
+pub(crate) struct ActionReq {
+ /// The console (`addr` here is the headset's Bluetooth address, see `ActionParams`).
+ host: String,
+ action: String,
+ #[serde(flatten)]
+ params: hb::ActionParams,
+}
+
+/// POST /api/ps5/hearbridge/action `{host, action, pct?|on?|index?|addr?|auto?|ms?}` —
+/// one of volume, mute, reconnect, select, scan, night, latency, stop. Answers
+/// `{ok, result}` with HearBridge's reply (its new status, token removed).
+pub(crate) async fn action(Json(req): Json) -> Response {
+ let a = match hb::Action::parse(&req.action, &req.params) {
+ Ok(a) => a,
+ Err(e) => return crate::json_err(StatusCode::BAD_REQUEST, e).into_response(),
+ };
+ let h = match host(&req.host) {
+ Ok(h) => h,
+ Err(r) => return r,
+ };
+ crate::log_info!("hearbridge {}: host={h}", req.action);
+ match tokio::task::spawn_blocking(move || hb::act(&h, &a)).await {
+ Ok(Ok(v)) => Json(serde_json::json!({ "ok": true, "result": v })).into_response(),
+ Ok(Err(e)) => bad_gateway(e),
+ Err(e) => bad_gateway(format!("{e}")),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::{Duration, Instant};
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ /// Serve the routes on an ephemeral port and send one raw request; the status code and
+ /// body come back.
+ async fn call(method: &str, path: &str, body: Option<&str>) -> (u16, String) {
+ let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = l.local_addr().unwrap().port();
+ tokio::spawn(async move {
+ axum::serve(l, routes::<()>()).await.unwrap();
+ });
+ let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
+ .await
+ .unwrap();
+ let body = body.unwrap_or("");
+ let req = format!(
+ "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
+ body.len()
+ );
+ s.write_all(req.as_bytes()).await.unwrap();
+ let mut out = Vec::new();
+ s.read_to_end(&mut out).await.unwrap();
+ let text = String::from_utf8_lossy(&out).to_string();
+ let code = text
+ .split_whitespace()
+ .nth(1)
+ .and_then(|c| c.parse().ok())
+ .unwrap_or(0);
+ let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
+ (code, body)
+ }
+
+ #[tokio::test]
+ async fn an_unknown_action_is_refused_before_anything_is_sent() {
+ // A blackholed console: any request to it would take seconds, so a fast 400 shows
+ // nothing was tried.
+ let t = Instant::now();
+ let (code, body) = call(
+ "POST",
+ "/api/ps5/hearbridge/action",
+ Some(r#"{"host":"10.255.255.1","action":"forget","addr":"AA:BB:CC:DD:EE:FF"}"#),
+ )
+ .await;
+ assert_eq!(code, 400, "{body}");
+ assert!(body.contains("unknown HearBridge action"), "{body}");
+ let (code, body) = call(
+ "POST",
+ "/api/ps5/hearbridge/action",
+ Some(r#"{"host":"10.255.255.1","action":"volume","pct":9000}"#),
+ )
+ .await;
+ assert_eq!(code, 400, "{body}");
+ assert!(t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed());
+ }
+
+ #[tokio::test]
+ async fn a_host_that_is_not_an_address_is_refused() {
+ let (code, _) = call(
+ "POST",
+ "/api/ps5/hearbridge/action",
+ Some(r#"{"host":"1.2.3.4/x","action":"stop"}"#),
+ )
+ .await;
+ assert_eq!(code, 400);
+ // `addr` is accepted for `host` on the reads, and checked the same way.
+ let (code, body) = call("GET", "/api/ps5/hearbridge/status?addr=1.2.3.4%20x", None).await;
+ assert_eq!(code, 400, "{body}");
+ let (code, _) = call("GET", "/api/ps5/hearbridge/saved?host=a%0d%0ab", None).await;
+ assert_eq!(code, 400);
+ }
+
+ #[tokio::test]
+ async fn status_by_addr_reports_an_absent_hearbridge() {
+ let (code, body) = call("GET", "/api/ps5/hearbridge/status?addr=127.0.0.1", None).await;
+ assert_eq!(code, 200, "{body}");
+ let v: serde_json::Value = serde_json::from_str(&body).unwrap();
+ assert_eq!(v["ok"], true);
+ // Something may listen on 8090 on a dev machine; either way no token is handed out.
+ assert!(v.get("token").is_none() && v["status"].get("token").is_none());
+ }
+}
diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs
index 8094948a..bb777e91 100644
--- a/engine/crates/ps5upload-engine/src/lib.rs
+++ b/engine/crates/ps5upload-engine/src/lib.rs
@@ -51,6 +51,7 @@ mod folder_build;
mod fpkg_api;
mod fpkg_firmware;
mod games_api;
+mod hearbridge_api;
mod helper_mirror;
mod helper_stop;
mod icon_cache;
@@ -10701,6 +10702,7 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
.route("/api/ps5/pldmgr/delete", post(pldmgr_api::delete))
.route("/api/ps5/pldmgr/autoload", post(pldmgr_api::autoload))
.route("/api/ps5/pldmgr/store", post(pldmgr_api::store))
+ .merge(hearbridge_api::routes())
.route("/api/ps5/elfldr/ensure", post(ps5_elfldr_ensure))
.route("/api/ps5/process/kill", post(ps5_process_kill))
.route("/api/ps5/power/control", post(ps5_power_control))
diff --git a/scripts/i18n-known-missing.json b/scripts/i18n-known-missing.json
index a068940b..15debca6 100644
--- a/scripts/i18n-known-missing.json
+++ b/scripts/i18n-known-missing.json
@@ -8,7 +8,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"smp_meta_json"
]
},
@@ -21,7 +20,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"screenshots_png_hdr",
"screenshots_png_sdr",
@@ -40,7 +38,6 @@
"ffpkg_inspect_sce_sys",
"game_hub_addons",
"health_cat_remoteplay",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"remotePlay",
@@ -58,7 +55,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"remotePlay",
@@ -73,8 +69,7 @@
"br_issue_link_placeholder",
"br_payloads_placeholder",
"br_where_discord",
- "cw_settings_app_name",
- "payloads_tab_manager"
+ "cw_settings_app_name"
]
},
"fr": {
@@ -87,7 +82,6 @@
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
"hardware_systime_via",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"queue_console_total",
@@ -105,7 +99,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"screenshots_png_hdr",
"screenshots_png_sdr",
@@ -124,7 +117,6 @@
"ffpkg_inspect_sce_sys",
"health_cat_remoteplay",
"payloads_prerelease",
- "payloads_tab_manager",
"pkglib.auth.fake",
"power_wake_signin_rpkey",
"processes_pid",
@@ -148,7 +140,6 @@
"fs_item_one",
"hardware_systime_via",
"health_cat_remoteplay",
- "payloads_tab_manager",
"processes_pid",
"remotePlay",
"remotePlay_slot",
@@ -165,7 +156,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"remotePlay",
"remotePlay_slot",
@@ -184,7 +174,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"screenshots_png_sdr",
"smp_meta_json"
@@ -199,7 +188,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"screenshots_png_hdr",
"screenshots_png_sdr",
@@ -215,7 +203,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"smp_meta_json"
@@ -232,7 +219,6 @@
"ffpkg_inspect_sce_sys",
"fs_item_one",
"hardware_systime_via",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"queue_console_total",
@@ -251,7 +237,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"screenshots_png_hdr",
@@ -268,7 +253,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"smp_meta_json"
]
@@ -282,7 +266,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"remotePlay",
@@ -300,7 +283,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"power_wake_signin_rpkey",
"processes_pid",
"remotePlay",
@@ -317,7 +299,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"screenshots_png_sdr",
"smp_meta_json"
@@ -332,7 +313,6 @@
"br_where_discord",
"cw_settings_app_name",
"ffpkg_inspect_sce_sys",
- "payloads_tab_manager",
"processes_pid",
"screenshots_png_sdr",
"smp_meta_json"