Merge Payloads hub, HearBridge and Processes & ports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-18 08:51:53 -07:00
commit a2d0aa8886
44 files changed
+4338 -303

No files matched your search

+14 -4
View File
@@ -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 }) {
<Route
path="/payloads"
element={
// The browser build gets the ShadowMount+ and nanoDNS tabs only
// The browser build gets the "On your PS5" tab only
// (screens/Payloads/payloadTabs.ts).
<Suspense fallback={<ScreenLoader />}>
<PayloadsScreen />
</Suspense>
}
/>
<Route path="/nanodns" element={<Navigate to="/payloads?tab=nanodns" replace />} />
<Route path="/nano-dns" element={<Navigate to="/payloads?tab=nanodns" replace />} />
<Route path="/shadowmount" element={<Navigate to="/payloads?tab=shadowmount" replace />} />
{/* 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. */}
<Route path="/nanodns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
<Route path="/nano-dns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
<Route path="/shadowmount" element={<Navigate to={payloadCardLink("shadowmountplus")} replace />} />
<Route path="/hearbridge" element={<Navigate to={payloadCardLink("hearbridge")} replace />} />
<Route path="/payloads/manager" element={<Navigate to={payloadCardLink("payload-manager")} replace />} />
<Route path="/payloads/shadowmount" element={<Navigate to={payloadCardLink("shadowmountplus")} replace />} />
<Route path="/payloads/nanodns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
<Route path="/payloads/hearbridge" element={<Navigate to={payloadCardLink("hearbridge")} replace />} />
<Route path="/payloads/ports" element={<Navigate to="/processes?tab=ports" replace />} />
<Route path="/ports" element={<Navigate to="/processes?tab=ports" replace />} />
{/* 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
+104
View File
@@ -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");
});
});
+168
View File
@@ -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<string, unknown>;
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<Obj> {
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<HbOverview> {
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<HbSavedDevice[]> {
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<HbScannedDevice[]> {
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<HbStatus | null> {
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;
}
+2 -1
View File
@@ -37,7 +37,8 @@ const NAV_KEYWORDS: Record<string, string[]> = {
"/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"],
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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-এর প্রতিটি পোর্ট পরীক্ষা করা হচ্ছে…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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 के हर पोर्ट की जाँच हो रही है…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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 の全ポートを確認中…",
+64 -7
View File
@@ -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의 모든 포트를 확인하는 중…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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…",
+64 -7
View File
@@ -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 的所有端口…",
+64 -7
View File
@@ -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 的所有連接埠…",
+11 -1
View File
@@ -60,6 +60,11 @@ export interface TabbedShellProps<Id extends string> {
/** Ordered list of tabs. The first one is the default when no
* `?tab=...` is present in the URL. */
tabs: ReadonlyArray<TabbedShellTab<Id>>;
/** 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<Id extends string> {
function coerceTabId<Id extends string>(
raw: string | null,
tabs: ReadonlyArray<TabbedShellTab<Id>>,
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<Id extends string>({
titleKey,
titleFallback,
tabs,
defaultTab,
count,
renderPanel,
}: TabbedShellProps<Id>) {
const tr = useTr();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = coerceTabId<Id>(searchParams.get("tab"), tabs);
const activeTab = coerceTabId<Id>(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<Id extends string>({
<PageHeader
icon={IconForTitle}
title={tr(titleKey, undefined, titleFallback)}
count={count}
description={description}
/>
+2 -2
View File
@@ -205,8 +205,8 @@ export const NAV_ITEMS: NavItem[] = [
},
{
to: "/processes",
key: "processes",
fallback: "Processes",
key: "processes_and_ports",
fallback: "Processes & ports",
icon: Layers,
},
{
+4 -3
View File
@@ -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) {
@@ -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<Set<string>> {
const found = new Set<string>();
const listed = new Map<string, Set<string> | null>();
const list = async (dir: string): Promise<Set<string> | 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<string> | 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 (
<ConnectionGate require="payload">
<OnPs5Body />
</ConnectionGate>
);
}
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<PayloadInfo[] | null>(null);
const [procs, setProcs] = useState<ProcessInfo[]>([]);
const [procsLoaded, setProcsLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [markers, setMarkers] = useState<Set<string>>(new Set());
const [markersLoaded, setMarkersLoaded] = useState(false);
const [reports, setReports] = useState<Record<string, IntegrationReport>>({});
const [latest, setLatest] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(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<string | null>(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 (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm text-[var(--color-muted)]">
{loading
? tr("hub_checking", undefined, "Checking what is on this PS5…")
: tr("hub_count", { n: cards.length }, `${cards.length} on this PS5`)}
</span>
<Button
variant="secondary"
size="sm"
leftIcon={<RefreshCw size={14} />}
onClick={() => {
void refreshProcs();
void refreshMarkers();
}}
>
{tr("refresh", undefined, "Refresh")}
</Button>
</div>
{error && (
<ErrorCard
title={tr("hub_error", undefined, "Couldn't read what is running on the PS5")}
detail={error}
onDismiss={() => setError(null)}
/>
)}
{loading && cards.length === 0 ? (
<SkeletonRows rows={3} />
) : cards.length === 0 ? (
<EmptyState
icon={Boxes}
title={tr("hub_empty_title", undefined, "Nothing found on this PS5")}
message={
native
? tr(
"hub_empty_body",
undefined,
"No payload from the catalogue is running or installed on this console. Catalog has them, and Send loads any payload file.",
)
: tr("hub_empty_body_web", undefined, "No known payload is running or installed on this console.")
}
action={
native ? (
<div className="flex flex-wrap justify-center gap-2">
<Button size="sm" leftIcon={<Boxes size={14} />} onClick={() => navigate("/payloads?tab=catalog")}>
{tr("payloads_tab_catalog", undefined, "Catalog")}
</Button>
<Button size="sm" leftIcon={<Rocket size={14} />} onClick={() => navigate("/payloads?tab=send")}>
{tr("payloads_tab_send", undefined, "Send payload")}
</Button>
</div>
) : undefined
}
/>
) : (
<ul className="flex flex-col gap-2.5">
{cards.map((c) => {
const rep = reports[c.id];
const tag = latest[c.id];
const newer =
native && rep?.version && tag && compareVersions(rep.version, tag) === -1 ? tag : null;
return (
<HubCardView
key={c.id}
card={c}
host={host}
version={rep?.version ?? null}
webPort={rep?.webPort ?? c.webPort}
newer={newer}
catalogued={catalog !== null}
busy={busy === c.id}
expanded={openId === c.id}
onToggle={() => setOpen(openId === c.id ? null : c.id)}
onStop={() => void stop(c)}
onUpdate={(t) => void update(c, t)}
canStop={c.integration === "hearbridge" && hbPresent ? true : undefined}
summary={
c.integration === "hearbridge"
? () => <HbSummary view={hb.view} />
: c.integration
? (fallback) => (
<Summary
key={summaryRev}
id={c.integration!}
host={host}
onReport={report}
fallback={fallback}
/>
)
: null
}
panel={
c.integration === "hearbridge" ? (
<HearBridgePanel host={host} view={hb.view} apply={hb.apply} refresh={hb.refresh} />
) : c.integration ? (
<IntegrationPanel id={c.integration} host={host} />
) : null
}
/>
);
})}
</ul>
)}
{dialog}
</div>
);
}
/** The one-line live summary an integration shows on its card. */
function Summary({
id,
host,
onReport,
fallback,
}: {
id: IntegrationId;
host: string;
onReport: (id: string, r: IntegrationReport) => void;
fallback: ReactNode;
}) {
if (id === "payload-manager") {
return <PmSummary host={host} onReport={(r) => onReport(id, r)} fallback={fallback} />;
}
if (id === "shadowmountplus") return <SmpSummary host={host} fallback={fallback} />;
return <>{fallback}</>;
}
/** The integration's own panel, as its tab showed it before. */
function IntegrationPanel({ id, host }: { id: IntegrationId; host: string }) {
if (id === "payload-manager") return <PayloadManagerPanel />;
if (id === "shadowmountplus") return <SmpPanel mgmtAddr={host.trim() ? mgmtAddr(host.trim()) : null} />;
if (id === "nanodns") return <NanoDnsScreen />;
return null;
}
function HubCardView({
card,
host,
version,
webPort,
newer,
catalogued,
canStop: canStopOverride,
busy,
expanded,
onToggle,
onStop,
onUpdate,
summary,
panel,
}: {
card: HubCard;
host: string;
version: string | null;
webPort: number | null;
newer: string | null;
/** The catalogue was read, so "not in the catalogue" can be said (not in the web UI). */
catalogued: boolean;
/** Overrides the process-based Stop (an integration with its own clean stop). */
canStop?: boolean;
busy: boolean;
expanded: boolean;
onToggle: () => void;
onStop: () => void;
onUpdate: (tag: string) => void;
summary: ((fallback: ReactNode) => ReactNode) | null;
panel: ReactNode;
}) {
const tr = useTr();
const running = card.state === "running";
const link = running && webPort ? `http://${withPort(host.trim(), webPort)}/` : null;
const proc = card.processes[0];
const canStop = canStopOverride ?? (running && !!proc && killPrompt(proc) !== null);
const stateBadge =
card.state === "running" ? (
<Badge tone="good" dot>
{tr("hub_state_running", undefined, "Running")}
</Badge>
) : card.state === "installed" ? (
<Badge tone="neutral">{tr("hub_state_installed", undefined, "Installed")}</Badge>
) : (
<Badge tone="warn">{tr("hub_state_unknown", undefined, "Not found")}</Badge>
);
const detail =
card.state === "running" && proc
? tr(
"hub_running_detail",
{ name: proc.comm || proc.name, pid: proc.pid, mb: proc.memory_mib.toFixed(1) },
`${proc.comm || proc.name} · pid ${proc.pid} · ${proc.memory_mib.toFixed(1)} MB`,
)
: card.state === "installed"
? tr("hub_installed_detail", undefined, "On the console, not running.")
: card.state === "unknown"
? tr("hub_unknown_detail", undefined, "Not found on this PS5.")
: null;
return (
<li
className="rounded-[var(--radius-card)] border border-[var(--glass-edge)] bg-[var(--color-surface-raised)] shadow-[var(--edge-highlight),var(--shadow-1)]"
data-card={card.id}
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-3 py-2.5 sm:px-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[var(--color-surface-3)]">
<Cpu size={17} className="text-[var(--color-muted)]" />
</div>
<div className="min-w-[min(100%,12rem)] flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate font-medium">{card.name}</span>
{stateBadge}
{version && (
<Badge tone="neutral" variant="outline">
{version.startsWith("v") ? version : `v${version}`}
</Badge>
)}
{catalogued && card.id.startsWith("proc:") && (
<Badge tone="neutral" variant="outline">
{tr("hub_not_catalogued", undefined, "Not in the catalogue")}
</Badge>
)}
</div>
<div className="mt-0.5 text-xs text-[var(--color-muted)]">{summary ? summary(detail) : detail}</div>
</div>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{link && (
<Button
variant="ghost"
size="sm"
leftIcon={<ExternalLink size={14} />}
onClick={() => void openExternalUrl(link)}
title={link}
>
{tr("hub_open_page", undefined, "Open web page")}
</Button>
)}
{newer && (
<Button
variant="secondary"
size="sm"
leftIcon={<Download size={14} />}
disabled={busy}
onClick={() => onUpdate(newer)}
>
{tr("hub_update", { version: newer }, `Update to ${newer}`)}
</Button>
)}
{canStop && (
<Button variant="ghost" size="sm" leftIcon={<Skull size={14} />} disabled={busy} onClick={onStop}>
{tr("hub_stop", undefined, "Stop")}
</Button>
)}
{panel && (
<Button
variant={expanded ? "primary" : "secondary"}
size="sm"
rightIcon={expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
aria-expanded={expanded}
onClick={onToggle}
>
{tr("hub_manage", undefined, "Manage")}
</Button>
)}
</div>
</div>
{expanded && panel && (
<div className="border-t border-[var(--glass-edge)] px-3 py-3 sm:px-4">{panel}</div>
)}
</li>
);
}
@@ -0,0 +1,92 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { parseHbStatus, type HbOverview } from "../../../api/hearbridge";
import { HbSummary, HearBridgePanel, hbPageUrl, hbSummaryParts } from "./hearbridge";
// No DOM here (vitest runs in node): static markup, asserting on what is shown.
const tr = ((_k: string, vars: Record<string, unknown> | undefined, fallback: string) =>
fallback.replace(/\{(\w+)\}/g, (_, n) => String(vars?.[n] ?? ""))) as never;
const streaming = parseHbStatus({
version: "1.9.0",
state: "streaming",
device: "WH-1000XM4",
gain_pct: 140,
muted: 0,
codec: "SBC",
latency: { target_ms: 120, estimate_ms: 185 },
lat_extra: { auto: 0 },
battery: { pct: 70 },
link: { score: 88 },
night: { on: 0 },
game: { name: "Astro Bot", profile: 1 },
events: ["Connected", "Streaming"],
});
const present: HbOverview = { present: true, port: 8091, status: streaming };
const noop = () => undefined;
const noopAsync = async () => undefined;
describe("HearBridge card", () => {
it("sums up the headset in one line", () => {
expect(hbSummaryParts(streaming, tr)).toEqual([
"Streaming",
"WH-1000XM4",
"Battery 70%",
"Link 88%",
"185 ms delay",
"SBC",
"Volume 140%",
"Profile: Astro Bot",
]);
});
it("says less while waiting, and nothing it doesn't know", () => {
const waiting = parseHbStatus({ version: "0.3", state: "disconnected", muted: 1 });
expect(hbSummaryParts(waiting, tr)).toEqual(["Waiting for a headset", "Muted"]);
});
it("has its own line while the first status read is out", () => {
expect(renderToStaticMarkup(<HbSummary view={null} />)).toContain("Reading HearBridge");
});
it("says HearBridge didn't answer instead of failing", () => {
expect(renderToStaticMarkup(<HbSummary view={{ present: false, error: "x" }} />)).toContain(
"HearBridge didn&#x27;t answer",
);
});
it("opens HearBridge's page on the port it answered on", () => {
expect(hbPageUrl("192.168.86.99", 8091)).toBe("http://192.168.86.99:8091/");
expect(hbPageUrl("192.168.86.99", null)).toBe("http://192.168.86.99:8090/");
});
});
describe("HearBridge Manage panel", () => {
it("has the everyday controls and points to HearBridge's page for the rest", () => {
const html = renderToStaticMarkup(
<HearBridgePanel host="192.168.86.99" view={present} apply={noop} refresh={noopAsync} />,
);
expect(html).toContain('max="500"'); // volume, HearBridge's 0..500 %
expect(html).toContain('max="400"'); // fixed latency, 40..400 ms
expect(html).toContain("Night mode");
expect(html).toContain("Reconnect");
expect(html).toContain("Scan for headsets");
expect(html).toContain("Recent events");
expect(html).toContain("Open HearBridge page");
});
it("falls back to the page link when HearBridge doesn't answer", () => {
const html = renderToStaticMarkup(
<HearBridgePanel
host="192.168.86.99"
view={{ present: false, error: "HearBridge didn't answer on 192.168.86.99 port 8090-8095" }}
apply={noop}
refresh={noopAsync}
/>,
);
expect(html).toContain("Open HearBridge page");
expect(html).not.toContain('type="range"');
});
});
@@ -0,0 +1,397 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Bluetooth, ExternalLink, RefreshCw, Search } from "lucide-react";
import { Badge, Button, Callout, Toggle } from "../../../components";
import { useConfirm } from "../../../components/ConfirmDialog";
import {
hbAct,
hbOverview,
hbSaved,
hbScanned,
type HbAction,
type HbOverview,
type HbSavedDevice,
type HbScannedDevice,
type HbStatus,
} from "../../../api/hearbridge";
import { withPort } from "../../../lib/addr";
import { openExternalUrl } from "../../../lib/openExternalUrl";
import { usePoll } from "../../../lib/usePoll";
import { useDocumentVisible } from "../../../lib/visibility";
import { useTr } from "../../../state/lang";
import { log } from "../../../state/logs";
/** HearBridge's status is re-read this often while its card is on screen. Each read
* counts as its page being open on the console side only for the volume re-query, so
* keep it unhurried. */
const POLL_MS = 5000;
/** …and this often while the card is collapsed. */
const POLL_SLOW_MS = 15_000;
/** HearBridge's scan runs about 12 s (its own page says so). */
const SCAN_MS = 13_000;
export const HB_DEFAULT_PORT = 8090;
/** HearBridge's status on `host`, kept fresh while `active`. `null` until first read. */
export function useHearBridge(host: string, active: boolean, fast = false) {
const visible = useDocumentVisible();
const [view, setView] = useState<HbOverview | null>(null);
const gen = useRef(0);
useEffect(() => {
gen.current++;
setView(null);
}, [host]);
const refresh = useCallback(async () => {
if (!host.trim()) return;
const g = gen.current;
try {
const v = await hbOverview(host);
if (g === gen.current) setView(v);
} catch (e) {
if (g === gen.current) setView({ present: false, error: e instanceof Error ? e.message : String(e) });
}
}, [host]);
usePoll(refresh, fast ? POLL_MS : POLL_SLOW_MS, { host, enabled: active && visible && !!host.trim() });
/** Use the status HearBridge sent back with a change, so the panel doesn't wait a poll. */
const apply = useCallback((s: HbStatus | null) => {
if (!s) return;
setView((v) => (v && v.present ? { ...v, status: s } : v));
}, []);
return { view, refresh, apply };
}
/** The card's one-line summary: state, headset, battery, link, latency, codec, volume, game. */
export function HbSummary({ view }: { view: HbOverview | null }) {
const tr = useTr();
if (!view) return <>{tr("hb_checking", undefined, "Reading HearBridge's status…")}</>;
if (!view.present) return <>{tr("hb_no_answer", undefined, "HearBridge didn't answer")}</>;
return <>{hbSummaryParts(view.status, tr).join(" · ")}</>;
}
type Tr = ReturnType<typeof useTr>;
export function hbStateLabel(state: string, tr: Tr): string {
switch (state) {
case "streaming":
return tr("hb_state_streaming", undefined, "Streaming");
case "connecting":
return tr("hb_state_connecting", undefined, "Connecting");
case "reconnecting":
return tr("hb_state_reconnecting", undefined, "Reconnecting");
case "scanning":
return tr("hb_state_scanning", undefined, "Scanning");
case "disconnected":
return tr("hb_state_waiting", undefined, "Waiting for a headset");
case "error":
return tr("hb_state_error", undefined, "Error");
default:
return state;
}
}
export function hbSummaryParts(s: HbStatus, tr: Tr): string[] {
const parts: string[] = [];
if (s.state) parts.push(hbStateLabel(s.state, tr));
if (s.device) parts.push(s.device);
if (s.batteryPct !== null) parts.push(tr("hb_battery", { pct: s.batteryPct }, `Battery ${s.batteryPct}%`));
if (s.linkScore !== null && s.state === "streaming") {
parts.push(tr("hb_link", { score: s.linkScore }, `Link ${s.linkScore}%`));
}
const ms = s.latencyEstimateMs ?? s.latencyTargetMs;
if (ms !== null && s.state === "streaming") parts.push(tr("hb_latency", { ms }, `${ms} ms delay`));
if (s.codec && s.state === "streaming") parts.push(s.codec);
if (s.muted) parts.push(tr("hb_muted", undefined, "Muted"));
else if (s.gainPct !== null) parts.push(tr("hb_volume", { pct: s.gainPct }, `Volume ${s.gainPct}%`));
if (s.gameProfile && s.gameName) parts.push(tr("hb_game", { name: s.gameName }, `Profile: ${s.gameName}`));
return parts;
}
/** HearBridge's page on the console (pairing, EQ, backups and the rest live there). */
export function hbPageUrl(host: string, port: number | null | undefined): string {
return `http://${withPort(host.trim(), port ?? HB_DEFAULT_PORT)}/`;
}
/** Manage: the everyday controls. Everything else stays on HearBridge's own page. */
export function HearBridgePanel({
host,
view,
apply,
refresh,
}: {
host: string;
view: HbOverview | null;
apply: (s: HbStatus | null) => void;
refresh: () => Promise<void>;
}) {
const tr = useTr();
const { confirm, dialog } = useConfirm();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<HbSavedDevice[] | null>(null);
const [scanned, setScanned] = useState<HbScannedDevice[] | null>(null);
const [scanning, setScanning] = useState(false);
// Slider values while dragging; sent on release so a drag is one change, not dozens.
const [gainDraft, setGainDraft] = useState<number | null>(null);
const [latDraft, setLatDraft] = useState<number | null>(null);
const present = view?.present === true;
const port = view && view.present ? view.port : HB_DEFAULT_PORT;
const s = view && view.present ? view.status : null;
const loadSaved = useCallback(async () => {
try {
setSaved(await hbSaved(host));
} catch {
setSaved(null);
}
}, [host]);
useEffect(() => {
if (present) void loadSaved();
}, [present, loadSaved]);
/** Make one change; false when HearBridge didn't take it (the error is shown). */
const run = async (a: HbAction): Promise<boolean> => {
setBusy(true);
setError(null);
try {
apply(await hbAct(host, a));
log.info("hearbridge", `${a.action} on ${host}`);
await refresh();
return true;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return false;
} finally {
setBusy(false);
}
};
const scan = async () => {
if (s?.state === "streaming") {
const ok = await confirm({
title: tr("hb_scan_confirm_title", undefined, "Scan for headsets?"),
message: tr(
"hb_scan_confirm_body",
undefined,
"Scanning stops the headset's audio while it runs (about 12 seconds).",
),
confirmLabel: tr("hb_scan", undefined, "Scan for headsets"),
});
if (!ok) return;
}
setScanning(true);
setScanned(null);
if (!(await run({ action: "scan" }))) {
setScanning(false);
return;
}
window.setTimeout(() => {
hbScanned(host)
.then(setScanned)
.catch(() => setScanned([]))
.finally(() => setScanning(false));
}, SCAN_MS);
};
const openPage = (
<Button
variant="secondary"
size="sm"
leftIcon={<ExternalLink size={14} />}
onClick={() => void openExternalUrl(hbPageUrl(host, port))}
>
{tr("hb_open_page", undefined, "Open HearBridge page")}
</Button>
);
if (!view) return null;
if (!present || !s) {
return (
<div className="flex flex-col gap-3">
<Callout tone="warn" title={tr("hb_no_answer", undefined, "HearBridge didn't answer")}>
{view.present ? null : view.error}
</Callout>
<div>{openPage}</div>
</div>
);
}
const gain = gainDraft ?? s.gainPct ?? 100;
const lat = latDraft ?? s.latencyTargetMs ?? 120;
return (
<div className="flex flex-col gap-4 text-sm">
{error && (
<Callout tone="error" title={tr("hb_action_failed", undefined, "HearBridge didn't take that change")}>
{error}
</Callout>
)}
<section className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<label className="flex min-w-[min(100%,18rem)] flex-1 items-center gap-3">
<span className="w-24 shrink-0">{tr("hb_volume_label", undefined, "Volume")}</span>
<input
type="range"
min={0}
max={500}
step={10}
value={gain}
disabled={busy || s.gainPct === null}
onChange={(e) => setGainDraft(Number(e.target.value))}
onPointerUp={() => {
if (gainDraft !== null) void run({ action: "volume", pct: gainDraft }).finally(() => setGainDraft(null));
}}
onKeyUp={() => {
if (gainDraft !== null) void run({ action: "volume", pct: gainDraft }).finally(() => setGainDraft(null));
}}
className="flex-1"
aria-label={tr("hb_volume_label", undefined, "Volume")}
/>
<span className="w-14 text-right font-mono tabular-nums">{gain}%</span>
</label>
<Toggle
checked={!!s.muted}
onChange={(on) => void run({ action: "mute", on })}
label={tr("hb_mute", undefined, "Mute")}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="w-24 shrink-0">{tr("hb_latency_label", undefined, "Latency")}</span>
<Toggle
checked={!!s.latencyAuto}
onChange={(auto) => void run({ action: "latency", auto })}
label={tr("hb_latency_auto", undefined, "Automatic")}
/>
{!s.latencyAuto && (
<label className="flex min-w-[min(100%,14rem)] flex-1 items-center gap-3">
<input
type="range"
min={40}
max={400}
step={10}
value={lat}
disabled={busy}
onChange={(e) => setLatDraft(Number(e.target.value))}
onPointerUp={() => {
if (latDraft !== null) void run({ action: "latency", ms: latDraft }).finally(() => setLatDraft(null));
}}
onKeyUp={() => {
if (latDraft !== null) void run({ action: "latency", ms: latDraft }).finally(() => setLatDraft(null));
}}
className="flex-1"
aria-label={tr("hb_latency_label", undefined, "Latency")}
/>
<span className="w-16 text-right font-mono tabular-nums">{lat} ms</span>
</label>
)}
</div>
<Toggle
checked={!!s.night}
onChange={(on) => void run({ action: "night", on })}
label={tr("hb_night", undefined, "Night mode")}
hint={tr("hb_night_hint", undefined, "Quieter explosions, clearer voices.")}
/>
</section>
<section className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="font-medium">{tr("hb_headsets", undefined, "Headsets")}</h4>
<Button
variant="secondary"
size="sm"
leftIcon={<RefreshCw size={14} />}
disabled={busy}
onClick={() => void run({ action: "reconnect" })}
>
{tr("hb_reconnect", undefined, "Reconnect")}
</Button>
<Button
variant="secondary"
size="sm"
leftIcon={<Search size={14} />}
loading={scanning}
disabled={busy || scanning}
onClick={() => void scan()}
>
{tr("hb_scan", undefined, "Scan for headsets")}
</Button>
</div>
{saved && saved.length > 0 && (
<ul className="flex flex-col gap-1.5">
{saved.map((d) => (
<li key={d.addr} className="flex flex-wrap items-center gap-2">
<Bluetooth size={14} className="text-[var(--color-muted)]" />
<span>{d.name || d.addr}</span>
{d.current ? (
<Badge tone="good">{tr("hb_current", undefined, "In use")}</Badge>
) : (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void run({ action: "select", addr: d.addr })}
>
{tr("hb_switch", undefined, "Switch to this one")}
</Button>
)}
</li>
))}
</ul>
)}
{saved && saved.length === 0 && (
<p className="text-xs text-[var(--color-muted)]">
{tr("hb_saved_empty", undefined, "No saved headsets yet. Scan for one, or pair it on HearBridge's page.")}
</p>
)}
{scanned && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-[var(--color-muted)]">
{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.")}
</span>
<ul className="flex flex-col gap-1.5">
{scanned.map((d) => (
<li key={`${d.index}-${d.addr}`} className="flex flex-wrap items-center gap-2">
<Bluetooth size={14} className="text-[var(--color-muted)]" />
<span>{d.name || d.addr}</span>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void run({ action: "select", index: d.index })}
>
{tr("hb_connect", undefined, "Connect")}
</Button>
</li>
))}
</ul>
</div>
)}
</section>
{s.events.length > 0 && (
<section className="flex flex-col gap-1">
<h4 className="font-medium">{tr("hb_events", undefined, "Recent events")}</h4>
<ul className="font-mono text-xs text-[var(--color-muted)]">
{s.events.slice(-8).reverse().map((e, i) => (
<li key={`${i}-${e}`}>{e}</li>
))}
</ul>
</section>
)}
<section className="flex flex-wrap items-center gap-3">
{openPage}
<span className="text-xs text-[var(--color-muted)]">
{tr(
"hb_page_hint",
undefined,
"Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.",
)}
</span>
</section>
{dialog}
</div>
);
}
@@ -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> = {}): 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"]);
});
});
+242
View File
@@ -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<string, string[]> = {
"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:<name>` 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<string>;
/** Integrations whose own API answered (Payload Manager on :8084, HearBridge): running
* even when the process list hasn't been read yet. */
answering?: ReadonlySet<string>;
/** 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<number>();
const byEvidence = new Map<string, HubCard>();
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<string, HubCard>();
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<string, string[]> {
const out = new Map<string, string[]>();
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;
}
@@ -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<string | null>(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<number | null>(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}`)}</>;
}
+33 -80
View File
@@ -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 <Navigate to={legacy} replace />;
const available = payloadTabsFor(native);
const allTabs: ReadonlyArray<TabbedShellTab<TabId>> = [
{
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 <SendPanel />;
if (id === "manager") {
return (
<ConnectionGate require="host">
<PayloadManagerPanel />
</ConnectionGate>
);
}
if (id === "nanodns") return <NanoDnsScreen />;
if (id === "ports") {
return (
<ConnectionGate require="payload">
<PortsPanel />
</ConnectionGate>
);
}
if (id === "shadowmount") {
return (
<ConnectionGate require="payload">
<SmpPanel mgmtAddr={host?.trim() ? mgmtAddr(host.trim()) : null} />
</ConnectionGate>
);
}
if (id === "ps5") return <OnPs5Panel />;
return <CatalogPanel />;
};
@@ -142,6 +94,7 @@ export default function PayloadsScreen() {
titleKey="payloads"
titleFallback="Payloads"
tabs={tabs}
defaultTab={defaultPayloadTab(native, connected)}
renderPanel={renderPanel}
/>
);
+57 -12
View File
@@ -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={<Navigate to={payloadCardLink("${card}")} replace />}`);
}
expect(app).toContain(`path="/payloads/ports" element={<Navigate to="/processes?tab=ports" replace />}`);
expect(payloadCardLink("payload-manager")).toBe("/payloads?tab=ps5&open=payload-manager");
});
});
+36 -17
View File
@@ -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<string, string> = {
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)}`;
}
+66 -15
View File
@@ -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<number | undefined>(undefined);
const [searchParams] = useSearchParams();
const tab: ProcessesTabId = searchParams.get("tab") === "ports" ? "ports" : "processes";
const tabs: ReadonlyArray<TabbedShellTab<ProcessesTabId>> = [
{
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 (
<TabbedShell
idPrefix="processes"
titleIcon={Layers}
titleKey="processes_and_ports"
titleFallback="Processes & ports"
count={tab === "processes" ? count : undefined}
tabs={tabs}
renderPanel={(id) =>
id === "ports" ? (
<ConnectionGate require="payload">
<PortsPanel />
</ConnectionGate>
) : (
<ProcessesPanel onCount={setCount} />
)
}
/>
);
}
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 (
<div className="app-page">
<PageHeader
icon={Layers}
title={tr("processes_title", undefined, "Processes")}
count={procs.length || undefined}
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.",
)}
/>
<>
<ConnectionGate require="payload">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Toggle
@@ -447,7 +498,7 @@ export default function ProcessesScreen() {
</Modal>
)}
</ConnectionGate>
</div>
</>
);
}
@@ -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<HashMap<String, u16>> {
static CACHE: OnceLock<Mutex<HashMap<String, u16>>> = 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<Found, String> {
find_on(host, &HEARBRIDGE_PORTS)
}
fn find_on(host: &str, ports: &[u16]) -> Result<Found, String> {
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<u16> = 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<Found, HttpErr> {
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<Value, String> {
devices_on(host, which, &HEARBRIDGE_PORTS)
}
fn devices_on(host: &str, which: DeviceList, ports: &[u16]) -> Result<Value, String> {
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::<Value>(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<i64>,
pub on: Option<bool>,
pub index: Option<i64>,
pub addr: Option<String>,
pub auto: Option<bool>,
pub ms: Option<i64>,
}
/// 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<Action, String> {
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<Value, String> {
act_on(host, action, &HEARBRIDGE_PORTS)
}
fn act_on(host: &str, action: &Action, ports: &[u16]) -> Result<Value, String> {
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<String> for HttpErr {
fn from(msg: String) -> Self {
HttpErr {
msg,
connect_timed_out: false,
}
}
}
impl From<HttpErr> 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<u8>), 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<u8>)> {
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<String>,
ua: Option<String>,
}
/// 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<Mutex<Vec<Seen>>>) {
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"<html>").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, "<html>hi</html>".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}");
}
}
}
+1
View File
@@ -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;
@@ -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<S: Clone + Send + Sync + 'static>() -> Router<S> {
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<String, Response> {
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<HostParams>) -> 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<HostParams>) -> 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<HostParams>) -> 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<ActionReq>) -> 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());
}
}
@@ -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))
+1 -21
View File
@@ -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"