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

    {tr("hb_headsets", undefined, "Headsets")}

    + + +
    + {saved && saved.length > 0 && ( +
      + {saved.map((d) => ( +
    • + + {d.name || d.addr} + {d.current ? ( + {tr("hb_current", undefined, "In use")} + ) : ( + + )} +
    • + ))} +
    + )} + {saved && saved.length === 0 && ( +

    + {tr("hb_saved_empty", undefined, "No saved headsets yet. Scan for one, or pair it on HearBridge's page.")} +

    + )} + {scanned && ( +
    + + {scanned.length + ? tr("hb_scan_found", undefined, "Found in the last scan:") + : tr("hb_scan_empty", undefined, "Nothing found. Put the headset in pairing mode and scan again.")} + +
      + {scanned.map((d) => ( +
    • + + {d.name || d.addr} + +
    • + ))} +
    +
    + )} +
    + + {s.events.length > 0 && ( +
    +

    {tr("hb_events", undefined, "Recent events")}

    +
      + {s.events.slice(-8).reverse().map((e, i) => ( +
    • {e}
    • + ))} +
    +
    + )} + +
    + {openPage} + + {tr( + "hb_page_hint", + undefined, + "Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.", + )} + +
    + {dialog} +
    + ); +} diff --git a/client/src/screens/Payloads/hub/registry.test.ts b/client/src/screens/Payloads/hub/registry.test.ts new file mode 100644 index 00000000..a768f85c --- /dev/null +++ b/client/src/screens/Payloads/hub/registry.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import type { PayloadInfo, ProcessInfo } from "../../../api/ps5"; +import { detectHubCards, entryFromCatalog, hubEntries, markerFolders, normProcName } from "./registry"; + +const proc = (pid: number, comm: string, kind: ProcessInfo["kind"] = "payload", extra: Partial = {}): ProcessInfo => ({ + pid, + name: comm, + comm, + title_id: "", + app_id: 0, + memory_mib: 4, + threads: 3, + kind, + ...extra, +}); + +const info = (id: string, display: string, hint: string | null, marker: string | null, ports: number[] = []): PayloadInfo => ({ + id, + display_name: display, + role: "", + description: "", + repo_owner: "o", + repo_name: "r", + on_console_marker_path: marker, + process_name_hint: hint, + ports, + autoload_priority: 0, + autoload_delay_ms: 0, + homepage: "", +}); + +// A slice of the desktop catalogue (commands/payloads.rs). +const CATALOG: PayloadInfo[] = [ + info("kstuff-echostretch", "kstuff", null, "/data/kstuff.elf"), + info("kstuff-lite-drakmor", "kstuff-lite", null, "/data/kstuff.elf"), + info("shadowmountplus", "ShadowMount+", "shadowmountplus", "/data/shadowmount/debug.log"), + info("ftpsrv", "ftpsrv", "ftpsrv", null, [2121]), + info("payload-manager", "Payload Manager", null, "/data/pldmgr/pldmgr_config.txt", [8084]), + info("nanodns", "nanoDNS", "nanodns.elf", "/data/nanodns/nanodns.ini", [53]), + info("hearbridge", "HearBridge", "hearbridge.elf", "/data/hearbridge/hearbridge.log", [8090]), + info("klogsrv", "klogsrv", "klogsrv.elf", "/data/klog/klog.log", [3232]), +]; + +// The FW 13.60 Phat's process list (payloads + the helper), 2026-10-18. +const PHAT: ProcessInfo[] = [ + proc(130, "ps5upload-tile", "system", { is_self: true }), + proc(129, "shadowmountplus.elf"), + proc(128, "kstuff.elf"), + proc(127, "elfldr.elf"), + proc(107, "kstuff.elf"), + proc(97, "ftpsrv.elf"), + proc(96, "nanodns.elf"), + proc(90, "pldmgr.elf"), + proc(60, "SceShellUI", "system"), +]; + +describe("On your PS5 detection", () => { + it("finds every catalogued payload running on the Phat, integrations first", () => { + const cards = detectHubCards({ entries: hubEntries(CATALOG), processes: PHAT, markers: new Set() }); + expect(cards.map((c) => [c.id, c.state])).toEqual([ + ["nanodns", "running"], + ["payload-manager", "running"], + ["shadowmountplus", "running"], + ["proc:elfldr", "running"], + ["ftpsrv", "running"], + ["kstuff-echostretch", "running"], + ]); + const kstuff = cards.find((c) => c.id === "kstuff-echostretch")!; + // Two catalogue builds, one process name: one card naming both, both pids. + expect(kstuff.entryIds).toEqual(["kstuff-echostretch", "kstuff-lite-drakmor"]); + expect(kstuff.name).toBe("kstuff / kstuff-lite"); + expect(kstuff.processes.map((p) => p.pid)).toEqual([128, 107]); + expect(cards.find((c) => c.id === "payload-manager")!.webPort).toBe(8084); + expect(cards.find((c) => c.id === "proc:elfldr")!.name).toBe("elfldr.elf"); + }); + + it("never shows the helper itself or system processes", () => { + const cards = detectHubCards({ entries: hubEntries(CATALOG), processes: PHAT, markers: new Set() }); + expect(cards.some((c) => c.processes.some((p) => p.is_self || p.kind === "system"))).toBe(false); + }); + + it("shows a payload whose marker is on the console as installed", () => { + const cards = detectHubCards({ + entries: hubEntries(CATALOG), + processes: [], + markers: new Set(["/data/hearbridge/hearbridge.log", "/data/klog/klog.log"]), + }); + expect(cards.map((c) => [c.id, c.state])).toEqual([ + ["hearbridge", "installed"], + ["klogsrv", "installed"], + ]); + }); + + it("leaves out what is neither running nor installed, unless a link opened it", () => { + const entries = hubEntries(CATALOG); + expect(detectHubCards({ entries, processes: [], markers: new Set() })).toEqual([]); + const forced = detectHubCards({ entries, processes: [], markers: new Set(), forceShow: "hearbridge" }); + expect(forced.map((c) => [c.id, c.state])).toEqual([["hearbridge", "unknown"]]); + }); + + it("counts an integration whose own API answered as running", () => { + const cards = detectHubCards({ + entries: hubEntries(CATALOG), + processes: [], + markers: new Set(["/data/hearbridge/hearbridge.log"]), + answering: new Set(["hearbridge"]), + }); + expect(cards.map((c) => [c.id, c.state, c.integration, c.webPort])).toEqual([ + ["hearbridge", "running", "hearbridge", 8090], + ]); + }); + + it("works without a catalogue (the browser build) for the integrations", () => { + const cards = detectHubCards({ entries: hubEntries(null), processes: PHAT, markers: new Set() }); + expect(cards.map((c) => c.id)).toEqual([ + "nanodns", + "payload-manager", + "shadowmountplus", + "proc:elfldr", + "proc:ftpsrv", + "proc:kstuff", + ]); + expect(cards.find((c) => c.id === "proc:kstuff")!.processes).toHaveLength(2); + }); + + it("compares process names without case or extension", () => { + expect(normProcName("HearBridge.ELF")).toBe("hearbridge"); + expect(normProcName("etaHEN")).toBe("etahen"); + expect(entryFromCatalog(CATALOG[4]).processNames).toEqual(["pldmgr"]); + expect(entryFromCatalog(CATALOG[5]).webPort).toBeNull(); + }); + + it("lists each marker folder once", () => { + const folders = markerFolders(hubEntries(CATALOG)); + expect(folders.get("/data")).toEqual(["/data/kstuff.elf"]); + expect(folders.get("/data/pldmgr")).toEqual(["/data/pldmgr/pldmgr_config.txt"]); + }); +}); diff --git a/client/src/screens/Payloads/hub/registry.ts b/client/src/screens/Payloads/hub/registry.ts new file mode 100644 index 00000000..2d335664 --- /dev/null +++ b/client/src/screens/Payloads/hub/registry.ts @@ -0,0 +1,242 @@ +// "On your PS5": which payloads are on the console, as cards. Pure: the panel feeds it the +// catalogue, the process list and which marker files exist, and renders what comes back. +// +// A payload is "running" when a process with its name is up, "installed" when its marker +// file is on the console. Payloads show up in the process list under their ELF's file name +// ("shadowmountplus.elf", "nanodns.elf", "pldmgr.elf"; measured on FW 13.60), so names are +// compared without case or extension. + +import type { PayloadInfo, ProcessInfo } from "../../../api/ps5"; + +/** The payloads with a panel of their own on the card ("Manage"). Adding one = a registry + * entry here plus its panel in OnPs5Panel; never a new tab. */ +export type IntegrationId = "shadowmountplus" | "nanodns" | "payload-manager" | "hearbridge"; + +export const INTEGRATION_IDS: readonly IntegrationId[] = [ + "shadowmountplus", + "nanodns", + "payload-manager", + "hearbridge", +]; + +export function isIntegration(id: string): id is IntegrationId { + return (INTEGRATION_IDS as readonly string[]).includes(id); +} + +/** What an integration's card summary learned from its own API. */ +export interface IntegrationReport { + version?: string | null; + /** The port its web page answered on, when that differs from the catalogue's. */ + webPort?: number | null; +} + +/** What the hub needs to know about one payload. */ +export interface HubEntry { + id: string; + name: string; + /** Process names it runs under (any case, with or without ".elf"). */ + processNames: string[]; + /** A file only it leaves on the console. */ + marker: string | null; + /** Its web page's port, if it has one. */ + webPort: number | null; + isCustom: boolean; +} + +/** Ports the catalogue lists that serve a web page (diagnostics::known_port_is_web). */ +const WEB_PORTS = new Set([8080, 8084, 8090]); + +/** Process names the catalogue doesn't carry (it has no hint for these), measured on the + * console's process list. */ +const EXTRA_PROCESS_NAMES: Record = { + "payload-manager": ["pldmgr"], + "kstuff-echostretch": ["kstuff"], + "kstuff-lite-drakmor": ["kstuff"], +}; + +/** The integrations as the browser build knows them: it has no catalogue (that lives in the + * desktop app), so these four stand in for it. Mirrors commands/payloads.rs. */ +export const BUILTIN_ENTRIES: readonly HubEntry[] = [ + { + id: "shadowmountplus", + name: "ShadowMount+", + processNames: ["shadowmountplus"], + marker: "/data/shadowmount/debug.log", + webPort: null, + isCustom: false, + }, + { + id: "nanodns", + name: "nanoDNS", + processNames: ["nanodns"], + marker: "/data/nanodns/nanodns.ini", + webPort: null, + isCustom: false, + }, + { + id: "payload-manager", + name: "Payload Manager", + processNames: ["pldmgr"], + marker: "/data/pldmgr/pldmgr_config.txt", + webPort: 8084, + isCustom: false, + }, + { + id: "hearbridge", + name: "HearBridge", + processNames: ["hearbridge"], + marker: "/data/hearbridge/hearbridge.log", + webPort: 8090, + isCustom: false, + }, +]; + +export function entryFromCatalog(p: PayloadInfo): HubEntry { + const names = [ + ...(p.process_name_hint ? [p.process_name_hint] : []), + ...(EXTRA_PROCESS_NAMES[p.id] ?? []), + ]; + return { + id: p.id, + name: p.display_name, + processNames: names, + marker: p.on_console_marker_path, + webPort: p.ports.find((port) => WEB_PORTS.has(port)) ?? null, + isCustom: !!p.is_custom, + }; +} + +/** The entries to detect: the catalogue when there is one, plus any integration it lacks. */ +export function hubEntries(catalog: readonly PayloadInfo[] | null): HubEntry[] { + const out = (catalog ?? []).map(entryFromCatalog); + for (const b of BUILTIN_ENTRIES) if (!out.some((e) => e.id === b.id)) out.push(b); + return out; +} + +export function normProcName(s: string): string { + return s.trim().toLowerCase().replace(/\.(elf|bin)$/, ""); +} + +export type HubState = "running" | "installed" | "unknown"; + +export interface HubCard { + /** The catalogue id, or `proc:` for a payload the catalogue doesn't know. */ + id: string; + /** Every catalogue entry this card stands for (two kstuff builds share one process name). */ + entryIds: string[]; + name: string; + state: HubState; + /** Its running processes (empty when installed but not running). */ + processes: ProcessInfo[]; + webPort: number | null; + integration: IntegrationId | null; + isCustom: boolean; +} + +export interface DetectInput { + entries: readonly HubEntry[]; + processes: readonly ProcessInfo[]; + /** Marker paths found on the console. */ + markers: ReadonlySet; + /** Integrations whose own API answered (Payload Manager on :8084, HearBridge): running + * even when the process list hasn't been read yet. */ + answering?: ReadonlySet; + /** A card to show even when nothing was found (a deep link opened it). */ + forceShow?: string | null; +} + +/** One card per payload that is running or installed, then one per running payload the + * catalogue doesn't know. Running before installed, integrations first, then by name. */ +export function detectHubCards({ + entries, + processes, + markers, + answering, + forceShow, +}: DetectInput): HubCard[] { + const claimed = new Set(); + const byEvidence = new Map(); + const cards: HubCard[] = []; + for (const e of entries) { + const names = new Set(e.processNames.map(normProcName).filter(Boolean)); + const procs = processes.filter( + (p) => !p.is_self && (names.has(normProcName(p.comm)) || names.has(normProcName(p.name))), + ); + const integration = isIntegration(e.id) ? e.id : null; + const answered = !!answering?.has(e.id); + const installed = !!e.marker && markers.has(e.marker); + const forced = forceShow === e.id; + if (procs.length === 0 && !installed && !answered && !forced) continue; + for (const p of procs) claimed.add(p.pid); + const state: HubState = procs.length > 0 || answered ? "running" : installed ? "installed" : "unknown"; + // Two catalogue entries seen through the same process or marker are one thing on the + // console (kstuff's two builds both run as kstuff.elf): one card naming both. + const evidence = procs.length + ? `p:${procs.map((p) => p.pid).sort((a, b) => a - b).join(",")}` + : installed && !answered + ? `m:${e.marker}` + : `id:${e.id}`; + const same = byEvidence.get(evidence); + if (same && !integration && !same.integration) { + same.entryIds.push(e.id); + same.name = `${same.name} / ${e.name}`; + continue; + } + const card: HubCard = { + id: e.id, + entryIds: [e.id], + name: e.name, + state, + processes: procs, + webPort: e.webPort, + integration, + isCustom: e.isCustom, + }; + byEvidence.set(evidence, card); + cards.push(card); + } + // Payloads nobody catalogued, seen running (one card per name). + const unknown = new Map(); + for (const p of processes) { + if (p.kind !== "payload" || p.is_self || claimed.has(p.pid)) continue; + const label = p.comm || p.name; + const key = normProcName(label); + if (!key) continue; + const card = unknown.get(key); + if (card) { + card.processes.push(p); + continue; + } + unknown.set(key, { + id: `proc:${key}`, + entryIds: [], + name: label, + state: "running", + processes: [p], + webPort: null, + integration: null, + isCustom: true, + }); + } + const rank = (c: HubCard) => + (c.state === "running" ? 0 : c.state === "installed" ? 2 : 4) + (c.integration ? 0 : 1); + return [...cards, ...unknown.values()].sort( + (a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name), + ); +} + +/** Group marker paths by folder, so each folder is listed once. */ +export function markerFolders(entries: readonly HubEntry[]): Map { + const out = new Map(); + for (const e of entries) { + const m = e.marker?.replace(/\/+$/, ""); + if (!m) continue; + const slash = m.lastIndexOf("/"); + if (slash < 0) continue; + const dir = slash === 0 ? "/" : m.slice(0, slash); + const list = out.get(dir) ?? []; + if (!list.includes(m)) list.push(m); + out.set(dir, list); + } + return out; +} diff --git a/client/src/screens/Payloads/hub/summaries.tsx b/client/src/screens/Payloads/hub/summaries.tsx new file mode 100644 index 00000000..53400799 --- /dev/null +++ b/client/src/screens/Payloads/hub/summaries.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { pmOverview } from "../../../api/payloadManager"; +import { smpStatus } from "../../../api/ps5"; +import { mgmtAddr } from "../../../lib/addr"; +import { useTr } from "../../../state/lang"; +import type { IntegrationReport } from "./registry"; + +/** Payload Manager's card line: its version and how many payloads it keeps. Read once per + * console; its own panel (Manage) has the live view. */ +export function PmSummary({ + host, + onReport, + fallback, +}: { + host: string; + onReport: (r: IntegrationReport) => void; + fallback: ReactNode; +}) { + const tr = useTr(); + const [line, setLine] = useState(null); + const reportRef = useRef(onReport); + useEffect(() => { + reportRef.current = onReport; + }); + useEffect(() => { + let cancelled = false; + setLine(null); + if (!host.trim()) return; + pmOverview(host) + .then((v) => { + if (cancelled || !v.present) return; + reportRef.current({ version: v.version || null }); + setLine( + tr("hub_pm_summary", { n: v.payloads.length }, `Payloads stored: ${v.payloads.length}`), + ); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + // tr is stable per language; re-reading on a language switch is not needed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [host]); + return <>{line ?? fallback}; +} + +/** ShadowMount+'s card line: how many game images it has mounted. */ +export function SmpSummary({ host, fallback }: { host: string; fallback: ReactNode }) { + const tr = useTr(); + const [mounted, setMounted] = useState(null); + useEffect(() => { + let cancelled = false; + setMounted(null); + if (!host.trim()) return; + smpStatus(mgmtAddr(host.trim())) + .then((s) => { + if (!cancelled) setMounted(s.mounted_images.length); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [host]); + if (mounted === null) return <>{fallback}; + return <>{tr("hub_smp_summary", { n: mounted }, `Game images mounted: ${mounted}`)}; +} diff --git a/client/src/screens/Payloads/index.tsx b/client/src/screens/Payloads/index.tsx index a26bc963..f67a1cae 100644 --- a/client/src/screens/Payloads/index.tsx +++ b/client/src/screens/Payloads/index.tsx @@ -1,18 +1,19 @@ -import { Boxes, Globe, HardDrive, Network, Package, Rocket } from "lucide-react"; +import { Boxes, Gamepad2, Rocket } from "lucide-react"; +import { Navigate, useSearchParams } from "react-router"; import TabbedShell, { type TabbedShellTab } from "../../layout/TabbedShell"; import { useTr } from "../../state/lang"; import CatalogPanel from "./CatalogPanel"; import SendPanel from "./SendPanel"; -import PayloadManagerPanel from "./PayloadManagerPanel"; -import PortsPanel from "./PortsPanel"; -import NanoDnsScreen from "../NanoDns"; -import SmpPanel from "../Library/SmpPanel"; -import { ConnectionGate } from "../../components"; +import OnPs5Panel from "./hub/OnPs5Panel"; import { useConnectionStore } from "../../state/connection"; -import { mgmtAddr } from "../../lib/addr"; import { isTauriEnv } from "../../lib/tauriEnv"; -import { payloadTabsFor, type PayloadTabId } from "./payloadTabs"; +import { + defaultPayloadTab, + legacyPayloadsTarget, + payloadTabsFor, + type PayloadTabId, +} from "./payloadTabs"; /** * Payloads screen — URL-routed tabs: @@ -20,24 +21,29 @@ import { payloadTabsFor, type PayloadTabId } from "./payloadTabs"; * - **catalog**: curated GitHub-released third-party homebrew. * - **send**: arbitrary ELF/BIN/JS/LUA/JAR picker. Includes * playlists and recent-sends history. - * - **shadowmount** / **nanodns**: those payloads' status and config on - * the console. The only two the browser build shows (payloadTabsFor). + * - **ps5** ("On your PS5"): a card per payload running or installed on the + * console; ShadowMount+, nanoDNS, Payload Manager and HearBridge open + * their own panel from the card. The only tab the browser build shows. * - * (Historically split across /payloads and the old /send-payload route; - * merged under ?tab=send for a cleaner sidebar. Legacy redirects remain - * for old bookmarks.) - * - * The shell (URL contract + tablist + a11y + keyboard nav + page - * header) lives in `layout/TabbedShell`; this file is just tab - * metadata and a panel switch. + * The integrations used to be tabs of their own; `?tab=manager|shadowmount| + * nanodns` now opens their card, and `?tab=ports` moved to Processes. */ type TabId = PayloadTabId; export default function PayloadsScreen() { const tr = useTr(); - const host = useConnectionStore((state) => state.host); - const available = payloadTabsFor(isTauriEnv()); + const [params] = useSearchParams(); + const host = useConnectionStore((s) => s.host); + const payloadStatus = useConnectionStore((s) => s.payloadStatus); + // "Connected" for picking the default tab: a console is set and not known to be down. + // (Waiting for "up" would flip a bare /payloads from Catalog to On your PS5 mid-look.) + const connected = !!host?.trim() && payloadStatus !== "down"; + const native = isTauriEnv(); + const legacy = legacyPayloadsTarget(params.get("tab")); + if (legacy) return ; + + const available = payloadTabsFor(native); const allTabs: ReadonlyArray> = [ { id: "catalog", @@ -62,47 +68,14 @@ export default function PayloadsScreen() { ), }, { - id: "manager", - icon: Package, - key: "payloads_tab_manager", - fallback: "Payload Manager", + id: "ps5", + icon: Gamepad2, + key: "payloads_tab_ps5", + fallback: "On your PS5", description: tr( - "payloads_description_manager", + "payloads_description_ps5", undefined, - "The payloads kept on the PS5 by Payload Manager (itsPLK): launch or delete them, and choose which ones it loads at boot and in what order. Store a catalogue payload there from the Catalog tab.", - ), - }, - { - id: "shadowmount", - icon: HardDrive, - key: "payloads_tab_shadowmount", - fallback: "ShadowMount+", - description: tr( - "payloads_description_shadowmount", - undefined, - "Inspect ShadowMount+ status, mounted game images, configuration, and diagnostics on the selected PS5.", - ), - }, - { - id: "nanodns", - icon: Globe, - key: "payloads_tab_nanodns", - fallback: "nanoDNS", - description: tr( - "payloads_description_nanodns", - undefined, - "Configure the nanoDNS payload, verify its running version, and apply safe config migrations.", - ), - }, - { - id: "ports", - icon: Network, - key: "payloads_tab_ports", - fallback: "Ports", - description: tr( - "payloads_description_ports", - undefined, - "The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.", + "The payloads running or installed on the selected PS5. Open a payload's web page, update it, stop it, or manage it.", ), }, ]; @@ -110,28 +83,7 @@ export default function PayloadsScreen() { const renderPanel = (id: TabId) => { if (id === "send") return ; - if (id === "manager") { - return ( - - - - ); - } - if (id === "nanodns") return ; - if (id === "ports") { - return ( - - - - ); - } - if (id === "shadowmount") { - return ( - - - - ); - } + if (id === "ps5") return ; return ; }; @@ -142,6 +94,7 @@ export default function PayloadsScreen() { titleKey="payloads" titleFallback="Payloads" tabs={tabs} + defaultTab={defaultPayloadTab(native, connected)} renderPanel={renderPanel} /> ); diff --git a/client/src/screens/Payloads/payloadTabs.test.ts b/client/src/screens/Payloads/payloadTabs.test.ts index b5089e9c..8403970d 100644 --- a/client/src/screens/Payloads/payloadTabs.test.ts +++ b/client/src/screens/Payloads/payloadTabs.test.ts @@ -3,7 +3,12 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { NAV_ITEMS } from "../../layout/navItems"; -import { payloadTabsFor } from "./payloadTabs"; +import { + defaultPayloadTab, + legacyPayloadsTarget, + payloadCardLink, + payloadTabsFor, +} from "./payloadTabs"; const read = (rel: string): string => readFileSync(new URL(rel, import.meta.url).pathname, "utf8"); const app = read("../../App.tsx"); @@ -11,18 +16,17 @@ const shim = read("../../lib/browserInvoke.ts"); describe("Payloads in the browser build", () => { it("keeps every tab on the desktop", () => { - expect(payloadTabsFor(true)).toEqual([ - "catalog", - "send", - "manager", - "shadowmount", - "nanodns", - "ports", - ]); + expect(payloadTabsFor(true)).toEqual(["catalog", "send", "ps5"]); }); - it("offers only the tabs that work through the engine", () => { - expect(payloadTabsFor(false)).toEqual(["manager", "shadowmount", "nanodns", "ports"]); + it("offers only On your PS5, which works through the engine", () => { + expect(payloadTabsFor(false)).toEqual(["ps5"]); + }); + + it("opens on On your PS5 once a console is connected", () => { + expect(defaultPayloadTab(true, true)).toBe("ps5"); + expect(defaultPayloadTab(true, false)).toBe("catalog"); + expect(defaultPayloadTab(false, false)).toBe("ps5"); }); it("is reachable: in the nav and not behind NativeOnlyRoute", () => { @@ -31,8 +35,49 @@ describe("Payloads in the browser build", () => { }); it("relies only on calls the browser build maps", () => { - for (const cmd of ["smp_status", "fs_read_preview", "fs_write_bytes_run"]) { + for (const cmd of [ + "smp_status", + "fs_read_preview", + "fs_write_bytes_run", + "process_list_get", + "process_kill_pid", + "ps5_list_dir", + ]) { expect(shim, cmd).toContain(`case "${cmd}":`); } }); }); + +describe("old Payloads links", () => { + it("open the integration's card on On your PS5", () => { + expect(legacyPayloadsTarget("manager")).toBe("/payloads?tab=ps5&open=payload-manager"); + expect(legacyPayloadsTarget("shadowmount")).toBe("/payloads?tab=ps5&open=shadowmountplus"); + expect(legacyPayloadsTarget("nanodns")).toBe("/payloads?tab=ps5&open=nanodns"); + }); + + it("send Ports to Processes & ports", () => { + expect(legacyPayloadsTarget("ports")).toBe("/processes?tab=ports"); + }); + + it("leave current tabs alone", () => { + for (const t of [null, "catalog", "send", "ps5", "nonsense"]) { + expect(legacyPayloadsTarget(t)).toBeNull(); + } + }); + + it("are routed in the app", () => { + for (const [from, card] of [ + ["/nanodns", "nanodns"], + ["/nano-dns", "nanodns"], + ["/shadowmount", "shadowmountplus"], + ["/payloads/manager", "payload-manager"], + ["/payloads/shadowmount", "shadowmountplus"], + ["/payloads/nanodns", "nanodns"], + ["/payloads/hearbridge", "hearbridge"], + ]) { + expect(app, from).toContain(`path="${from}" element={}`); + } + expect(app).toContain(`path="/payloads/ports" element={}`); + expect(payloadCardLink("payload-manager")).toBe("/payloads?tab=ps5&open=payload-manager"); + }); +}); diff --git a/client/src/screens/Payloads/payloadTabs.ts b/client/src/screens/Payloads/payloadTabs.ts index a7a491cb..85732f87 100644 --- a/client/src/screens/Payloads/payloadTabs.ts +++ b/client/src/screens/Payloads/payloadTabs.ts @@ -1,27 +1,46 @@ -export type PayloadTabId = "catalog" | "send" | "manager" | "shadowmount" | "nanodns" | "ports"; +export type PayloadTabId = "catalog" | "send" | "ps5"; -const ALL_TABS: readonly PayloadTabId[] = [ - "catalog", - "send", - "manager", - "shadowmount", - "nanodns", - "ports", -]; +const ALL_TABS: readonly PayloadTabId[] = ["catalog", "send", "ps5"]; /** * The Payloads tabs this build can use. * * Catalog and Send download ELFs to this machine and write them to the * console's loader socket, which the browser build cannot do (payload_send and - * payloads_* have no browser mapping). ShadowMount+ and nanoDNS only read and - * write files on the console through the engine (smp_status, fs_read_preview, - * fs_write_bytes_run), so the web UI keeps those two. Payload Manager talks to the - * console's :8084 through the engine, so the web UI keeps it as well, and Ports is one engine - * call (net.ports). + * payloads_* have no browser mapping). "On your PS5" only talks to the console + * through the engine (process list, file reads, the integrations' engine + * routes), so it is the one tab the web UI keeps. */ export function payloadTabsFor(native: boolean): readonly PayloadTabId[] { - return native - ? ALL_TABS - : ALL_TABS.filter((t) => t === "manager" || t === "shadowmount" || t === "nanodns" || t === "ports"); + return native ? ALL_TABS : ALL_TABS.filter((t) => t === "ps5"); +} + +/** The tab a bare /payloads opens on: what is on the console once one is connected, + * the catalogue before that. */ +export function defaultPayloadTab(native: boolean, connected: boolean): PayloadTabId { + if (!native || connected) return "ps5"; + return "catalog"; +} + +/** The card id each retired per-integration tab now opens on "On your PS5". */ +const LEGACY_CARD: Record = { + manager: "payload-manager", + shadowmount: "shadowmountplus", + nanodns: "nanodns", + hearbridge: "hearbridge", +}; + +/** Where an old Payloads tab (`?tab=manager|shadowmount|nanodns|ports`) lives now, or null + * for a current tab. Integrations became cards on "On your PS5"; Ports moved next to + * Processes. */ +export function legacyPayloadsTarget(tab: string | null): string | null { + if (!tab) return null; + if (tab === "ports") return "/processes?tab=ports"; + const card = LEGACY_CARD[tab]; + return card ? payloadCardLink(card) : null; +} + +/** The link that opens "On your PS5" with one card's Manage panel expanded. */ +export function payloadCardLink(cardId: string): string { + return `/payloads?tab=ps5&open=${encodeURIComponent(cardId)}`; } diff --git a/client/src/screens/Payloads/PortsPanel.tsx b/client/src/screens/Processes/PortsPanel.tsx similarity index 100% rename from client/src/screens/Payloads/PortsPanel.tsx rename to client/src/screens/Processes/PortsPanel.tsx diff --git a/client/src/screens/Processes/index.tsx b/client/src/screens/Processes/index.tsx index c142571c..f7d4d830 100644 --- a/client/src/screens/Processes/index.tsx +++ b/client/src/screens/Processes/index.tsx @@ -1,12 +1,15 @@ import { usePoll, useTransferBusy } from "../../lib/usePoll"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Cpu, RefreshCw, RotateCw, Skull, TriangleAlert, Layers } from "lucide-react"; +import { Cpu, RefreshCw, RotateCw, Skull, TriangleAlert, Layers, Network } from "lucide-react"; +import { useSearchParams } from "react-router"; + +import TabbedShell, { type TabbedShellTab } from "../../layout/TabbedShell"; +import PortsPanel from "./PortsPanel"; import { useConnectionStore } from "../../state/connection"; import { mgmtAddr, transferAddr } from "../../lib/addr"; import { useDocumentVisible } from "../../lib/visibility"; import { - PageHeader, Button, EmptyState, ErrorCard, @@ -56,7 +59,63 @@ const RESTART_RELAUNCH_DELAY_MS = 1200; type SortKey = "memory" | "pid" | "name" | "threads"; +/** + * "Processes & ports": the running processes and, beside them, the ports in use (a port is + * found by binding it, so it can't be tied to a pid; the two lists stay separate). + */ export default function ProcessesScreen() { + const tr = useTr(); + const [count, setCount] = useState(undefined); + const [searchParams] = useSearchParams(); + const tab: ProcessesTabId = searchParams.get("tab") === "ports" ? "ports" : "processes"; + const tabs: ReadonlyArray> = [ + { + id: "processes", + icon: Layers, + key: "processes_title", + fallback: "Processes", + description: tr( + "processes_subtitle_v2", + undefined, + "The programs running on the PS5 right now. You can stop or restart one; take care with system processes, since stopping the wrong one can freeze the console.", + ), + }, + { + id: "ports", + icon: Network, + key: "payloads_tab_ports", + fallback: "Ports", + description: tr( + "payloads_description_ports", + undefined, + "The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.", + ), + }, + ]; + return ( + + id === "ports" ? ( + + + + ) : ( + + ) + } + /> + ); +} + +type ProcessesTabId = "processes" | "ports"; + +function ProcessesPanel({ onCount }: { onCount: (n: number | undefined) => void }) { const tr = useTr(); const host = useConnectionStore((s) => s.host); const payloadStatus = useConnectionStore((s) => s.payloadStatus); @@ -261,20 +320,12 @@ export default function ProcessesScreen() { }, [procs, showSystem, sortKey]); const systemCount = procs.filter((p) => p.kind === "system").length; + useEffect(() => { + onCount(procs.length || undefined); + }, [procs.length, onCount]); return ( -
    - - + <>
    )} -
    + ); } diff --git a/engine/crates/ps5upload-core/src/hearbridge.rs b/engine/crates/ps5upload-core/src/hearbridge.rs new file mode 100644 index 00000000..e57ccc00 --- /dev/null +++ b/engine/crates/ps5upload-core/src/hearbridge.rs @@ -0,0 +1,710 @@ +//! HearBridge (X-F1REBALL-X/HearBridge-PS5): Bluetooth headset audio for the PS5, with its +//! own small HTTP API. The app reads and drives it through the engine because the page +//! sends no CORS headers, so a browser page on another origin can't call it directly. +//! +//! Its API, read from its source (src/http.c): +//! HTTP/1.1, `Connection: close`, on TCP 8090; when that port is taken it tries the next +//! five (8091..8095). +//! GET /api/status one JSON object: version, token, state, device, gain_pct, muted, +//! codec, latency{target_ms,estimate_ms}, battery{pct}, link{score, +//! rssi}, night{on}, lat_extra{auto}, game{id,name,profile}, events[]… +//! GET /api/saved {"devices":[{addr,name,kind,current}]} paired headsets +//! GET /api/devices {"version":1,"devices":[{index,addr,name,kind,rssi}]} last scan +//! POST /api/volume?pct=0..500 /api/mute?on=0|1 /api/night?on=0|1 +//! /api/latency?auto=0|1 or ?ms=40..400 /api/reconnect /api/stop +//! /api/select?index=N or ?addr=XX:XX:.. /api/scan?user=1 (without user=1 a +//! scan is ignored: an inquiry holds the radio the DualSense shares) +//! Every POST needs header `X-HB-Token` with the token from /api/status (403 otherwise). +//! +//! Nothing is cached on disk. The port a console's HearBridge answered on is remembered in +//! memory so the next call tries it first; the token is read fresh for every change. +//! +//! Plain HTTP over a TcpStream, like `payload_manager`: the Android engine has no HTTP +//! client crate. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::Value; + +/// HearBridge's port, then the five it falls back to. +pub const HEARBRIDGE_PORTS: [u16; 6] = [8090, 8091, 8092, 8093, 8094, 8095]; + +const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500); +const IO_TIMEOUT: Duration = Duration::from_secs(5); +/// Finding HearBridge (every port, if need be) never takes longer than this. +const FIND_DEADLINE: Duration = Duration::from_secs(8); +/// /api/status is at most 32 KiB (its own buffer); saved/devices at most 8 KiB. +const MAX_RESPONSE: usize = 256 * 1024; + +fn port_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A HearBridge that answered: its port, its status (without the token) and the token. +#[derive(Debug, Clone)] +pub struct Found { + pub port: u16, + pub status: Value, + token: String, +} + +/// Find HearBridge on `host` and read its status. +pub fn status(host: &str) -> Result { + find_on(host, &HEARBRIDGE_PORTS) +} + +fn find_on(host: &str, ports: &[u16]) -> Result { + check_host(host)?; + let deadline = Instant::now() + FIND_DEADLINE; + let cached = port_cache().lock().ok().and_then(|c| c.get(host).copied()); + // The port it answered on last time first, then HearBridge's own order. + let mut order: Vec = Vec::with_capacity(ports.len()); + if let Some(p) = cached.filter(|p| ports.contains(p)) { + order.push(p); + } + order.extend(ports.iter().copied().filter(|p| Some(*p) != cached)); + let mut last = String::new(); + for port in order { + if Instant::now() >= deadline { + last = format!("gave up after {}s", FIND_DEADLINE.as_secs()); + break; + } + match status_at(host, port, deadline) { + Ok(found) => { + if let Ok(mut c) = port_cache().lock() { + c.insert(host.to_string(), port); + } + return Ok(found); + } + Err(e) => { + last = e.msg; + // Nothing answered at all (the console is off or unreachable): the other + // ports are on the same host, so they would only time out the same way. + if e.connect_timed_out { + break; + } + } + } + } + if let Ok(mut c) = port_cache().lock() { + c.remove(host); + } + let range = match (ports.first(), ports.last()) { + (Some(a), Some(b)) if a != b => format!("{a}-{b}"), + (Some(a), _) => a.to_string(), + _ => String::new(), + }; + Err(format!( + "HearBridge didn't answer on {host} port {range} ({last})" + )) +} + +/// A console address the routes accept: no whitespace, control characters or URL syntax +/// that could change where the request goes or what it says. +pub fn check_host(host: &str) -> Result<(), String> { + let bad = host.is_empty() + || host.len() > 255 + || host.chars().any(|c| { + c.is_whitespace() || c.is_control() || matches!(c, '/' | '\\' | '?' | '#' | '@') + }); + if bad { + return Err(format!("not a console address: {host:?}")); + } + Ok(()) +} + +fn status_at(host: &str, port: u16, deadline: Instant) -> Result { + let (code, body) = request(host, port, "GET", "/api/status", None, deadline)?; + if code != 200 { + return Err(format!(":{port} answered HTTP {code}").into()); + } + parse_status(&body) + .map(|(status, token)| Found { + port, + status, + token, + }) + .map_err(HttpErr::from) +} + +/// The status object without its token, and the token. Anything that isn't a JSON object +/// with a `version` string is not HearBridge (another service on that port). +fn parse_status(body: &[u8]) -> Result<(Value, String), String> { + let mut v: Value = + serde_json::from_slice(body).map_err(|_| "not HearBridge (no JSON status)".to_string())?; + let obj = v + .as_object_mut() + .ok_or_else(|| "not HearBridge (status is not an object)".to_string())?; + if !obj.get("version").is_some_and(Value::is_string) { + return Err("not HearBridge (no version in its status)".into()); + } + let token = match obj.remove("token") { + Some(Value::String(t)) => t, + _ => String::new(), + }; + Ok((v, token)) +} + +/// Which list to read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceList { + /// Paired headsets (`/api/saved`). + Saved, + /// The last scan's results (`/api/devices`). + Scanned, +} + +/// `{"devices":[…]}` from HearBridge; an empty list when it has none or sent something else. +pub fn devices(host: &str, which: DeviceList) -> Result { + devices_on(host, which, &HEARBRIDGE_PORTS) +} + +fn devices_on(host: &str, which: DeviceList, ports: &[u16]) -> Result { + let found = find_on(host, ports)?; + let path = match which { + DeviceList::Saved => "/api/saved", + DeviceList::Scanned => "/api/devices", + }; + let (code, body) = request(host, found.port, "GET", path, None, io_deadline())?; + if code != 200 { + return Err(format!("HearBridge could not list devices (HTTP {code})")); + } + Ok(parse_devices(&body)) +} + +fn parse_devices(body: &[u8]) -> Value { + let list = serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("devices").filter(|d| d.is_array()).cloned()) + .unwrap_or_else(|| Value::Array(Vec::new())); + serde_json::json!({ "devices": list }) +} + +/// An action's parameters, as the app sends them. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ActionParams { + pub pct: Option, + pub on: Option, + pub index: Option, + pub addr: Option, + pub auto: Option, + pub ms: Option, +} + +/// The changes the app makes. Pairing, EQ, backups and the rest stay on HearBridge's page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Volume(u16), + Mute(bool), + Reconnect, + SelectIndex(u8), + SelectAddr(String), + Scan, + Night(bool), + LatencyAuto(bool), + LatencyMs(u16), + Stop, +} + +fn is_mac(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 17 + && b.iter().enumerate().all(|(i, c)| { + if i % 3 == 2 { + *c == b':' + } else { + c.is_ascii_hexdigit() + } + }) +} + +impl Action { + /// The action `name` with its parameters checked against the ranges HearBridge takes. + /// Anything else is refused here, so only these requests ever reach the console. + pub fn parse(name: &str, p: &ActionParams) -> Result { + let need_on = || p.on.ok_or_else(|| format!("{name} needs on")); + Ok(match name { + "volume" => match p.pct { + Some(v) if (0..=500).contains(&v) => Action::Volume(v as u16), + _ => return Err("volume needs pct 0-500".into()), + }, + "mute" => Action::Mute(need_on()?), + "night" => Action::Night(need_on()?), + "reconnect" => Action::Reconnect, + "scan" => Action::Scan, + "stop" => Action::Stop, + "select" => match (&p.addr, p.index) { + (Some(a), _) if is_mac(a) => Action::SelectAddr(a.to_ascii_uppercase()), + (Some(_), _) => return Err("select needs a Bluetooth address".into()), + (None, Some(i)) if (0..64).contains(&i) => Action::SelectIndex(i as u8), + _ => return Err("select needs index 0-63 or addr".into()), + }, + "latency" => match (p.auto, p.ms) { + (Some(a), _) => Action::LatencyAuto(a), + (None, Some(ms)) if (40..=400).contains(&ms) => Action::LatencyMs(ms as u16), + _ => return Err("latency needs auto or ms 40-400".into()), + }, + other => return Err(format!("unknown HearBridge action {other:?}")), + }) + } + + fn path(&self) -> String { + let b = |on: bool| if on { 1 } else { 0 }; + match self { + Action::Volume(v) => format!("/api/volume?pct={v}"), + Action::Mute(on) => format!("/api/mute?on={}", b(*on)), + Action::Night(on) => format!("/api/night?on={}", b(*on)), + Action::Reconnect => "/api/reconnect".into(), + Action::Scan => "/api/scan?user=1".into(), + Action::Stop => "/api/stop".into(), + Action::SelectIndex(i) => format!("/api/select?index={i}"), + Action::SelectAddr(a) => format!("/api/select?addr={a}"), + Action::LatencyAuto(a) => format!("/api/latency?auto={}", b(*a)), + Action::LatencyMs(ms) => format!("/api/latency?ms={ms}"), + } + } +} + +/// Send `action` to HearBridge on `host`. Returns its answer: the new status (token removed) +/// for most actions, `{"ok":1}` for select/scan/reconnect. +pub fn act(host: &str, action: &Action) -> Result { + act_on(host, action, &HEARBRIDGE_PORTS) +} + +fn act_on(host: &str, action: &Action, ports: &[u16]) -> Result { + let path = action.path(); + let mut found = find_on(host, ports)?; + for attempt in 0..2 { + if found.token.is_empty() { + return Err("HearBridge gave no token; it may be too old for changes from here".into()); + } + let (code, body) = request( + host, + found.port, + "POST", + &path, + Some(&found.token), + io_deadline(), + )?; + // 403: the token changed (HearBridge restarted between the read and the change). + if code == 403 && attempt == 0 { + found = match status_at(host, found.port, io_deadline()) { + Ok(f) => f, + Err(_) => find_on(host, ports)?, + }; + continue; + } + if code != 200 { + let msg = String::from_utf8_lossy(&body); + return Err(format!( + "HearBridge refused {path} (HTTP {code}: {})", + msg.trim() + )); + } + let mut v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + if let Some(o) = v.as_object_mut() { + o.remove("token"); + } + return Ok(v); + } + Err("HearBridge refused the token".into()) +} + +/// A failed request, and whether it failed because nothing accepted the connection in time. +#[derive(Debug)] +struct HttpErr { + msg: String, + connect_timed_out: bool, +} + +impl From for HttpErr { + fn from(msg: String) -> Self { + HttpErr { + msg, + connect_timed_out: false, + } + } +} + +impl From for String { + fn from(e: HttpErr) -> Self { + e.msg + } +} + +/// The deadline for one request outside discovery. +fn io_deadline() -> Instant { + Instant::now() + IO_TIMEOUT +} + +fn request( + host: &str, + port: u16, + method: &str, + path: &str, + token: Option<&str>, + deadline: Instant, +) -> Result<(u16, Vec), HttpErr> { + let left = || deadline.saturating_duration_since(Instant::now()); + let addr = crate::payload_lifecycle::join_host_port(host, port); + let sock = std::net::ToSocketAddrs::to_socket_addrs(&addr) + .map_err(|e| format!("resolve {addr}: {e}"))? + .next() + .ok_or_else(|| format!("resolve {addr}: no address"))?; + let connect_for = CONNECT_TIMEOUT.min(left()); + if connect_for.is_zero() { + return Err(format!("no time left to connect to {addr}").into()); + } + let mut stream = TcpStream::connect_timeout(&sock, connect_for).map_err(|e| HttpErr { + connect_timed_out: e.kind() == std::io::ErrorKind::TimedOut, + msg: format!("connect {addr}: {e}"), + })?; + let io_for = IO_TIMEOUT.min(left()).max(Duration::from_millis(1)); + stream.set_read_timeout(Some(io_for)).ok(); + stream.set_write_timeout(Some(io_for)).ok(); + stream + .write_all(request_head(method, path, &addr, token).as_bytes()) + .map_err(|e| format!("send to {addr}: {e}"))?; + // Read to the end, but never past the deadline: a server that trickles bytes would + // otherwise reset the per-read timeout forever. + let mut resp = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + if Instant::now() >= deadline { + return Err(format!("{addr} took too long to answer").into()); + } + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + resp.extend_from_slice(&buf[..n]); + if resp.len() > MAX_RESPONSE { + return Err(format!("{addr} sent more than {MAX_RESPONSE} bytes").into()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(format!("read from {addr}: {e}").into()), + } + } + parse_response(&resp).ok_or_else(|| format!("{addr} sent a response that is not HTTP").into()) +} + +/// The request line and headers. The User-Agent starts with "curl" on purpose: HearBridge +/// takes a status read from anything else as its page being open in a browser +/// (`page_seen_ms`), which may start a link wake meant for someone looking at that page +/// (http.c `/api/status`, main.c `lw_tick`). It does not avoid everything: every status read +/// still counts in `status_polls`, which lets HearBridge re-query the headset's volume while +/// reads keep coming, so the app polls slowly unless the Manage panel is open. +fn request_head(method: &str, path: &str, addr: &str, token: Option<&str>) -> String { + let mut head = format!( + "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: curl-compatible ps5upload\r\n" + ); + if let Some(t) = token { + head.push_str(&format!("X-HB-Token: {t}\r\n")); + } + if method == "POST" { + head.push_str("Content-Length: 0\r\n"); + } + head.push_str("\r\n"); + head +} + +fn parse_response(resp: &[u8]) -> Option<(u16, Vec)> { + let split = resp.windows(4).position(|w| w == b"\r\n\r\n")?; + let head = std::str::from_utf8(&resp[..split]).ok()?; + let status: u16 = head + .lines() + .next()? + .split_whitespace() + .nth(1)? + .parse() + .ok()?; + Some((status, resp[split + 4..].to_vec())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::sync::Arc; + use std::thread; + use std::time::Instant; + + /// One request as the fake server saw it. + #[derive(Debug, Clone)] + struct Seen { + line: String, + token: Option, + ua: Option, + } + + /// A fake HearBridge on an ephemeral port: answers each request with `reply(line)`. + fn fake( + reply: impl Fn(&str, Option<&str>) -> (u16, String) + Send + Sync + 'static, + ) -> (u16, Arc>>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let seen2 = seen.clone(); + let reply = Arc::new(reply); + thread::spawn(move || { + for s in l.incoming() { + let Ok(mut s) = s else { continue }; + let mut buf = [0u8; 4096]; + let mut got = Vec::new(); + while !got.windows(4).any(|w| w == b"\r\n\r\n") { + match s.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => got.extend_from_slice(&buf[..n]), + } + } + let text = String::from_utf8_lossy(&got).to_string(); + let line = text.lines().next().unwrap_or("").to_string(); + let header = |name: &str| { + text.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case(name).then(|| v.trim().to_string()) + }) + }; + let token = header("X-HB-Token"); + seen2.lock().unwrap().push(Seen { + line: line.clone(), + token: token.clone(), + ua: header("User-Agent"), + }); + let (code, body) = reply(&line, token.as_deref()); + let _ = write!( + s, + "HTTP/1.1 {code} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + (port, seen) + } + + fn closed_port() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + } + + const STATUS: &str = r#"{"version":"1.9.0","token":"abc123","connected":1,"state":"streaming","device":"WH-1000XM4","gain_pct":120,"muted":0,"codec":"SBC","latency":{"target_ms":120,"estimate_ms":180},"battery":{"status":"ok","level":3,"pct":80},"link":{"score":92,"rssi":-50},"events":["Connected"]}"#; + + #[test] + fn reads_the_status_without_handing_out_the_token() { + let (port, seen) = fake(|_, _| (200, STATUS.into())); + let f = find_on("127.0.0.1", &[port]).unwrap(); + assert_eq!(f.port, port); + assert_eq!(f.status["device"], "WH-1000XM4"); + assert!(f.status.get("token").is_none()); + assert_eq!(f.token, "abc123"); + let s = seen.lock().unwrap(); + assert_eq!(s[0].line, "GET /api/status HTTP/1.1"); + // Not counted as HearBridge's page being open (see request_head). + assert!(s[0].ua.as_deref().unwrap().starts_with("curl")); + } + + #[test] + fn tolerates_an_older_status_with_few_fields() { + let (status, token) = parse_status(br#"{"version":"0.3"}"#).unwrap(); + assert_eq!(status["version"], "0.3"); + assert_eq!(token, ""); + assert!(parse_status(b"").is_err()); + assert!(parse_status(br#"{"ok":1}"#).is_err()); + assert!(parse_status(br#"[1]"#).is_err()); + } + + #[test] + fn falls_back_to_the_next_port_like_hearbridge_does() { + let dead = closed_port(); + // Something else on the second port: answers, but isn't HearBridge. + let (other, _) = fake(|_, _| (200, "hi".into())); + let (hb, _) = fake(|_, _| (200, STATUS.into())); + let host = "127.0.0.1"; + let f = find_on(host, &[dead, other, hb]).unwrap(); + assert_eq!(f.port, hb); + let err = find_on(host, &[dead]).unwrap_err(); + assert!(err.contains("HearBridge didn't answer"), "{err}"); + } + + #[test] + fn sends_the_token_with_every_change() { + let (port, seen) = fake(|line, token| { + if line.starts_with("GET /api/status") { + (200, STATUS.into()) + } else if token == Some("abc123") { + ( + 200, + r#"{"version":"1.9.0","token":"abc123","gain_pct":200}"#.into(), + ) + } else { + (403, r#"{"error":"token"}"#.into()) + } + }); + let v = act_on("127.0.0.1", &Action::Volume(200), &[port]).unwrap(); + assert_eq!(v["gain_pct"], 200); + assert!(v.get("token").is_none()); + let s = seen.lock().unwrap(); + let post = s.iter().find(|r| r.line.starts_with("POST")).unwrap(); + assert_eq!(post.line, "POST /api/volume?pct=200 HTTP/1.1"); + assert_eq!(post.token.as_deref(), Some("abc123")); + } + + #[test] + fn rereads_the_token_once_when_hearbridge_restarted() { + let calls = Arc::new(Mutex::new(0)); + let c2 = calls.clone(); + let (port, _) = fake(move |line, token| { + if line.starts_with("GET /api/status") { + let mut n = c2.lock().unwrap(); + *n += 1; + // First read hands out a token that is stale by the time it is used. + let t = if *n == 1 { "old" } else { "new" }; + (200, format!(r#"{{"version":"1.9.0","token":"{t}"}}"#)) + } else if token == Some("new") { + (200, r#"{"ok":1}"#.into()) + } else { + (403, r#"{"error":"token"}"#.into()) + } + }); + let v = act_on("127.0.0.1", &Action::Reconnect, &[port]).unwrap(); + assert_eq!(v["ok"], 1); + } + + #[test] + fn a_missing_token_is_a_clear_error() { + let (port, _) = fake(|_, _| (200, r#"{"version":"0.3"}"#.into())); + let e = act_on("127.0.0.1", &Action::Stop, &[port]).unwrap_err(); + assert!(e.contains("no token"), "{e}"); + } + + #[test] + fn lists_devices_and_survives_garbage() { + let (port, _) = fake(|line, _| { + if line.starts_with("GET /api/status") { + (200, STATUS.into()) + } else if line.starts_with("GET /api/saved") { + ( + 200, + r#"{"devices":[{"addr":"AA:BB:CC:DD:EE:FF","name":"Buds","kind":"audio","current":1}]}"# + .into(), + ) + } else { + (200, "not json".into()) + } + }); + let saved = devices_on("127.0.0.1", DeviceList::Saved, &[port]).unwrap(); + assert_eq!(saved["devices"][0]["name"], "Buds"); + let scanned = devices_on("127.0.0.1", DeviceList::Scanned, &[port]).unwrap(); + assert_eq!(scanned, serde_json::json!({ "devices": [] })); + } + + #[test] + fn refuses_console_addresses_that_are_not_one() { + for ok in ["192.168.86.99", "ps5.local", "[fe80::1]", "10.0.0.2:9120"] { + assert!(check_host(ok).is_ok(), "{ok}"); + } + for bad in [ + "", + "1.2.3.4 x", + "1.2.3.4\r\nX: y", + "1.2.3.4/api", + "a?b", + "u@h", + "a\\b", + ] { + assert!(check_host(bad).is_err(), "{bad:?}"); + assert!(find_on(bad, &[1]).is_err()); + } + } + + #[test] + fn an_unreachable_console_is_not_tried_on_every_port() { + // A blackholed address: the first connect times out (or the network says + // unreachable at once); either way the other five ports are not waited on. + let t = Instant::now(); + let e = find_on("10.255.255.1", &HEARBRIDGE_PORTS).unwrap_err(); + assert!( + t.elapsed() < Duration::from_secs(4), + "{:?} {e}", + t.elapsed() + ); + } + + #[test] + fn a_server_that_never_answers_is_given_up_on() { + // Accepts and then says nothing: the read times out, discovery moves on. + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let silent = l.local_addr().unwrap().port(); + thread::spawn(move || { + let _held: Vec<_> = l.incoming().take(4).collect(); + thread::sleep(Duration::from_secs(30)); + }); + let (hb, _) = fake(|_, _| (200, STATUS.into())); + let t = Instant::now(); + assert_eq!(find_on("127.0.0.1", &[silent, hb]).unwrap().port, hb); + assert!(t.elapsed() <= FIND_DEADLINE + Duration::from_secs(1)); + } + + #[test] + fn only_allowlisted_actions_with_sane_values_pass() { + let p = |f: fn(&mut ActionParams)| { + let mut a = ActionParams::default(); + f(&mut a); + a + }; + assert_eq!( + Action::parse("volume", &p(|a| a.pct = Some(500))) + .unwrap() + .path(), + "/api/volume?pct=500" + ); + assert!(Action::parse("volume", &p(|a| a.pct = Some(501))).is_err()); + assert!(Action::parse("volume", &p(|_| {})).is_err()); + assert_eq!( + Action::parse("mute", &p(|a| a.on = Some(true))) + .unwrap() + .path(), + "/api/mute?on=1" + ); + assert_eq!( + Action::parse("scan", &p(|_| {})).unwrap().path(), + "/api/scan?user=1" + ); + assert_eq!( + Action::parse("select", &p(|a| a.index = Some(3))) + .unwrap() + .path(), + "/api/select?index=3" + ); + assert_eq!( + Action::parse("select", &p(|a| a.addr = Some("aa:bb:cc:dd:ee:ff".into()))) + .unwrap() + .path(), + "/api/select?addr=AA:BB:CC:DD:EE:FF" + ); + assert!(Action::parse("select", &p(|a| a.addr = Some("x&y=1".into()))).is_err()); + assert!(Action::parse("select", &p(|a| a.index = Some(64))).is_err()); + assert_eq!( + Action::parse("latency", &p(|a| a.auto = Some(false))) + .unwrap() + .path(), + "/api/latency?auto=0" + ); + assert_eq!( + Action::parse("latency", &p(|a| a.ms = Some(40))) + .unwrap() + .path(), + "/api/latency?ms=40" + ); + assert!(Action::parse("latency", &p(|a| a.ms = Some(39))).is_err()); + for refused in ["forget", "eq", "restore", "backup", "hci", "../stop", ""] { + assert!(Action::parse(refused, &p(|_| {})).is_err(), "{refused}"); + } + } +} diff --git a/engine/crates/ps5upload-core/src/lib.rs b/engine/crates/ps5upload-core/src/lib.rs index 0b6040e7..9262ee8c 100644 --- a/engine/crates/ps5upload-core/src/lib.rs +++ b/engine/crates/ps5upload-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod fs_ops; pub mod fw_spoof; pub mod game_meta; pub mod health; +pub mod hearbridge; pub mod hw; pub mod installer_client; pub mod local_image; diff --git a/engine/crates/ps5upload-engine/src/hearbridge_api.rs b/engine/crates/ps5upload-engine/src/hearbridge_api.rs new file mode 100644 index 00000000..284fcb42 --- /dev/null +++ b/engine/crates/ps5upload-engine/src/hearbridge_api.rs @@ -0,0 +1,203 @@ +//! `/api/ps5/hearbridge/*` — the console's HearBridge (Bluetooth headset audio, HTTP on +//! :8090..8095) as the app sees it: its status, its saved and scanned headsets, and the +//! few changes the app makes. Every call is a plain HTTP request to the console +//! (`ps5upload_core::hearbridge`); its token never leaves the engine. + +use axum::{ + extract::Query, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use ps5upload_core::hearbridge as hb; + +/// The routes, for any router state (the handlers need none). +pub(crate) fn routes() -> Router { + Router::new() + .route("/api/ps5/hearbridge/status", get(status)) + .route("/api/ps5/hearbridge/saved", get(saved)) + .route("/api/ps5/hearbridge/devices", get(devices)) + .route("/api/ps5/hearbridge/action", post(action)) +} + +#[derive(Deserialize)] +pub(crate) struct HostParams { + #[serde(alias = "addr")] + host: String, +} + +fn bad_gateway(e: String) -> Response { + crate::json_err(StatusCode::BAD_GATEWAY, e).into_response() +} + +/// The console's address, or a 400 for something that isn't one (whitespace, control +/// characters, URL syntax), refused before anything is sent anywhere. +#[allow(clippy::result_large_err)] +fn host(h: &str) -> Result { + let h = h.trim(); + hb::check_host(h).map_err(|e| crate::json_err(StatusCode::BAD_REQUEST, e).into_response())?; + Ok(ps5upload_core::events::console_host(h)) +} + +/// GET /api/ps5/hearbridge/status?host= — `{ok, present:true, port, status}`, or +/// `{ok, present:false, error}` when nothing answers on 8090..8095. +pub(crate) async fn status(Query(q): Query) -> Response { + let h = match host(&q.host) { + Ok(h) => h, + Err(r) => return r, + }; + match tokio::task::spawn_blocking(move || hb::status(&h)).await { + Ok(Ok(f)) => Json(serde_json::json!({ + "ok": true, + "present": true, + "port": f.port, + "status": f.status, + })) + .into_response(), + Ok(Err(e)) => { + Json(serde_json::json!({ "ok": true, "present": false, "error": e })).into_response() + } + Err(e) => bad_gateway(format!("{e}")), + } +} + +async fn list(q: HostParams, which: hb::DeviceList) -> Response { + let h = match host(&q.host) { + Ok(h) => h, + Err(r) => return r, + }; + match tokio::task::spawn_blocking(move || hb::devices(&h, which)).await { + Ok(Ok(v)) => { + Json(serde_json::json!({ "ok": true, "devices": v["devices"] })).into_response() + } + Ok(Err(e)) => bad_gateway(e), + Err(e) => bad_gateway(format!("{e}")), + } +} + +/// GET /api/ps5/hearbridge/saved?host= — the paired headsets `{ok, devices}`. +pub(crate) async fn saved(Query(q): Query) -> Response { + list(q, hb::DeviceList::Saved).await +} + +/// GET /api/ps5/hearbridge/devices?host= — the last scan's results `{ok, devices}`. +pub(crate) async fn devices(Query(q): Query) -> Response { + list(q, hb::DeviceList::Scanned).await +} + +#[derive(Deserialize)] +pub(crate) struct ActionReq { + /// The console (`addr` here is the headset's Bluetooth address, see `ActionParams`). + host: String, + action: String, + #[serde(flatten)] + params: hb::ActionParams, +} + +/// POST /api/ps5/hearbridge/action `{host, action, pct?|on?|index?|addr?|auto?|ms?}` — +/// one of volume, mute, reconnect, select, scan, night, latency, stop. Answers +/// `{ok, result}` with HearBridge's reply (its new status, token removed). +pub(crate) async fn action(Json(req): Json) -> Response { + let a = match hb::Action::parse(&req.action, &req.params) { + Ok(a) => a, + Err(e) => return crate::json_err(StatusCode::BAD_REQUEST, e).into_response(), + }; + let h = match host(&req.host) { + Ok(h) => h, + Err(r) => return r, + }; + crate::log_info!("hearbridge {}: host={h}", req.action); + match tokio::task::spawn_blocking(move || hb::act(&h, &a)).await { + Ok(Ok(v)) => Json(serde_json::json!({ "ok": true, "result": v })).into_response(), + Ok(Err(e)) => bad_gateway(e), + Err(e) => bad_gateway(format!("{e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// Serve the routes on an ephemeral port and send one raw request; the status code and + /// body come back. + async fn call(method: &str, path: &str, body: Option<&str>) -> (u16, String) { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(l, routes::<()>()).await.unwrap(); + }); + let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let body = body.unwrap_or(""); + let req = format!( + "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + s.write_all(req.as_bytes()).await.unwrap(); + let mut out = Vec::new(); + s.read_to_end(&mut out).await.unwrap(); + let text = String::from_utf8_lossy(&out).to_string(); + let code = text + .split_whitespace() + .nth(1) + .and_then(|c| c.parse().ok()) + .unwrap_or(0); + let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string(); + (code, body) + } + + #[tokio::test] + async fn an_unknown_action_is_refused_before_anything_is_sent() { + // A blackholed console: any request to it would take seconds, so a fast 400 shows + // nothing was tried. + let t = Instant::now(); + let (code, body) = call( + "POST", + "/api/ps5/hearbridge/action", + Some(r#"{"host":"10.255.255.1","action":"forget","addr":"AA:BB:CC:DD:EE:FF"}"#), + ) + .await; + assert_eq!(code, 400, "{body}"); + assert!(body.contains("unknown HearBridge action"), "{body}"); + let (code, body) = call( + "POST", + "/api/ps5/hearbridge/action", + Some(r#"{"host":"10.255.255.1","action":"volume","pct":9000}"#), + ) + .await; + assert_eq!(code, 400, "{body}"); + assert!(t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed()); + } + + #[tokio::test] + async fn a_host_that_is_not_an_address_is_refused() { + let (code, _) = call( + "POST", + "/api/ps5/hearbridge/action", + Some(r#"{"host":"1.2.3.4/x","action":"stop"}"#), + ) + .await; + assert_eq!(code, 400); + // `addr` is accepted for `host` on the reads, and checked the same way. + let (code, body) = call("GET", "/api/ps5/hearbridge/status?addr=1.2.3.4%20x", None).await; + assert_eq!(code, 400, "{body}"); + let (code, _) = call("GET", "/api/ps5/hearbridge/saved?host=a%0d%0ab", None).await; + assert_eq!(code, 400); + } + + #[tokio::test] + async fn status_by_addr_reports_an_absent_hearbridge() { + let (code, body) = call("GET", "/api/ps5/hearbridge/status?addr=127.0.0.1", None).await; + assert_eq!(code, 200, "{body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["ok"], true); + // Something may listen on 8090 on a dev machine; either way no token is handed out. + assert!(v.get("token").is_none() && v["status"].get("token").is_none()); + } +} diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs index 8094948a..bb777e91 100644 --- a/engine/crates/ps5upload-engine/src/lib.rs +++ b/engine/crates/ps5upload-engine/src/lib.rs @@ -51,6 +51,7 @@ mod folder_build; mod fpkg_api; mod fpkg_firmware; mod games_api; +mod hearbridge_api; mod helper_mirror; mod helper_stop; mod icon_cache; @@ -10701,6 +10702,7 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> { .route("/api/ps5/pldmgr/delete", post(pldmgr_api::delete)) .route("/api/ps5/pldmgr/autoload", post(pldmgr_api::autoload)) .route("/api/ps5/pldmgr/store", post(pldmgr_api::store)) + .merge(hearbridge_api::routes()) .route("/api/ps5/elfldr/ensure", post(ps5_elfldr_ensure)) .route("/api/ps5/process/kill", post(ps5_process_kill)) .route("/api/ps5/power/control", post(ps5_power_control)) diff --git a/scripts/i18n-known-missing.json b/scripts/i18n-known-missing.json index a068940b..15debca6 100644 --- a/scripts/i18n-known-missing.json +++ b/scripts/i18n-known-missing.json @@ -8,7 +8,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "smp_meta_json" ] }, @@ -21,7 +20,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "screenshots_png_hdr", "screenshots_png_sdr", @@ -40,7 +38,6 @@ "ffpkg_inspect_sce_sys", "game_hub_addons", "health_cat_remoteplay", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "remotePlay", @@ -58,7 +55,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "remotePlay", @@ -73,8 +69,7 @@ "br_issue_link_placeholder", "br_payloads_placeholder", "br_where_discord", - "cw_settings_app_name", - "payloads_tab_manager" + "cw_settings_app_name" ] }, "fr": { @@ -87,7 +82,6 @@ "cw_settings_app_name", "ffpkg_inspect_sce_sys", "hardware_systime_via", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "queue_console_total", @@ -105,7 +99,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "screenshots_png_hdr", "screenshots_png_sdr", @@ -124,7 +117,6 @@ "ffpkg_inspect_sce_sys", "health_cat_remoteplay", "payloads_prerelease", - "payloads_tab_manager", "pkglib.auth.fake", "power_wake_signin_rpkey", "processes_pid", @@ -148,7 +140,6 @@ "fs_item_one", "hardware_systime_via", "health_cat_remoteplay", - "payloads_tab_manager", "processes_pid", "remotePlay", "remotePlay_slot", @@ -165,7 +156,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "remotePlay", "remotePlay_slot", @@ -184,7 +174,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "screenshots_png_sdr", "smp_meta_json" @@ -199,7 +188,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "screenshots_png_hdr", "screenshots_png_sdr", @@ -215,7 +203,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "smp_meta_json" @@ -232,7 +219,6 @@ "ffpkg_inspect_sce_sys", "fs_item_one", "hardware_systime_via", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "queue_console_total", @@ -251,7 +237,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "screenshots_png_hdr", @@ -268,7 +253,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "smp_meta_json" ] @@ -282,7 +266,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "remotePlay", @@ -300,7 +283,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "power_wake_signin_rpkey", "processes_pid", "remotePlay", @@ -317,7 +299,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "screenshots_png_sdr", "smp_meta_json" @@ -332,7 +313,6 @@ "br_where_discord", "cw_settings_app_name", "ffpkg_inspect_sce_sys", - "payloads_tab_manager", "processes_pid", "screenshots_png_sdr", "smp_meta_json"