mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Merge Payloads hub, HearBridge and Processes & ports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
commit
a2d0aa8886
44 files changed
+4338
-303
No files matched your search
+14
-4
@@ -6,6 +6,7 @@ import {
|
||||
useConnectionStore,
|
||||
} from "./state/connection";
|
||||
import { Navigate, Route, Routes, useLocation, type Location } from "react-router";
|
||||
import { payloadCardLink } from "./screens/Payloads/payloadTabs";
|
||||
|
||||
import { lazyWithReload } from "./lib/lazyWithReload";
|
||||
import AppShell from "./layout/AppShell";
|
||||
@@ -396,16 +397,25 @@ function AppRoutes({ location }: { location: Location }) {
|
||||
<Route
|
||||
path="/payloads"
|
||||
element={
|
||||
// The browser build gets the ShadowMount+ and nanoDNS tabs only
|
||||
// The browser build gets the "On your PS5" tab only
|
||||
// (screens/Payloads/payloadTabs.ts).
|
||||
<Suspense fallback={<ScreenLoader />}>
|
||||
<PayloadsScreen />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/nanodns" element={<Navigate to="/payloads?tab=nanodns" replace />} />
|
||||
<Route path="/nano-dns" element={<Navigate to="/payloads?tab=nanodns" replace />} />
|
||||
<Route path="/shadowmount" element={<Navigate to="/payloads?tab=shadowmount" replace />} />
|
||||
{/* Integrations were tabs (and before that screens); they are cards on "On your
|
||||
PS5" now, and Ports sits with Processes. Old links open the card. */}
|
||||
<Route path="/nanodns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
|
||||
<Route path="/nano-dns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
|
||||
<Route path="/shadowmount" element={<Navigate to={payloadCardLink("shadowmountplus")} replace />} />
|
||||
<Route path="/hearbridge" element={<Navigate to={payloadCardLink("hearbridge")} replace />} />
|
||||
<Route path="/payloads/manager" element={<Navigate to={payloadCardLink("payload-manager")} replace />} />
|
||||
<Route path="/payloads/shadowmount" element={<Navigate to={payloadCardLink("shadowmountplus")} replace />} />
|
||||
<Route path="/payloads/nanodns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
|
||||
<Route path="/payloads/hearbridge" element={<Navigate to={payloadCardLink("hearbridge")} replace />} />
|
||||
<Route path="/payloads/ports" element={<Navigate to="/processes?tab=ports" replace />} />
|
||||
<Route path="/ports" element={<Navigate to="/processes?tab=ports" replace />} />
|
||||
{/* The wizard's whole point is step 2: download the payload ELFs to
|
||||
this machine and send them to the console over a raw socket.
|
||||
Neither is possible from a browser (the Payloads screen there has
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// The console's HearBridge (Bluetooth headset audio; its own web page on :8090..8095)
|
||||
// through the engine's /api/ps5/hearbridge routes. HearBridge sends no CORS headers, so
|
||||
// the app never calls it directly; the engine finds its port, holds its token and passes
|
||||
// only the changes below. Plain fetches against the engine origin, so the desktop app, the
|
||||
// browser build and the Docker build take the same path.
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
import { hostOf } from "../lib/addr";
|
||||
|
||||
/** HearBridge's status, read tolerantly: an older or newer HearBridge may leave any field
|
||||
* out, and a missing field is null rather than a guess. */
|
||||
export interface HbStatus {
|
||||
version: string;
|
||||
/** "streaming", "connecting", "scanning", "reconnecting", "disconnected", "error", … */
|
||||
state: string;
|
||||
/** The headset's name, when one is connected or being connected. */
|
||||
device: string;
|
||||
/** Audio volume boost, 0..500 %. */
|
||||
gainPct: number | null;
|
||||
muted: boolean | null;
|
||||
codec: string;
|
||||
latencyTargetMs: number | null;
|
||||
latencyEstimateMs: number | null;
|
||||
latencyAuto: boolean | null;
|
||||
batteryPct: number | null;
|
||||
/** Link quality 0..100. */
|
||||
linkScore: number | null;
|
||||
night: boolean | null;
|
||||
/** The running game HearBridge has a profile for, if any. */
|
||||
gameName: string;
|
||||
gameProfile: boolean;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface HbSavedDevice {
|
||||
addr: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface HbScannedDevice {
|
||||
index: number;
|
||||
addr: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
rssi: number | null;
|
||||
}
|
||||
|
||||
export type HbOverview =
|
||||
| { present: true; port: number; status: HbStatus }
|
||||
| { present: false; error: string };
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {});
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const flag = (v: unknown): boolean | null =>
|
||||
typeof v === "boolean" ? v : typeof v === "number" ? v !== 0 : null;
|
||||
|
||||
export function parseHbStatus(raw: unknown): HbStatus {
|
||||
const s = obj(raw);
|
||||
const latency = obj(s.latency);
|
||||
const battery = obj(s.battery);
|
||||
const link = obj(s.link);
|
||||
const game = obj(s.game);
|
||||
const pct = num(battery.pct);
|
||||
const score = num(link.score);
|
||||
return {
|
||||
version: str(s.version),
|
||||
state: str(s.state),
|
||||
device: str(s.device),
|
||||
gainPct: num(s.gain_pct),
|
||||
muted: flag(s.muted),
|
||||
codec: str(s.codec),
|
||||
latencyTargetMs: num(latency.target_ms),
|
||||
latencyEstimateMs: num(latency.estimate_ms),
|
||||
latencyAuto: flag(obj(s.lat_extra).auto),
|
||||
// -1 is HearBridge's "not known".
|
||||
batteryPct: pct !== null && pct >= 0 ? pct : null,
|
||||
linkScore: score !== null && score >= 0 ? score : null,
|
||||
night: flag(obj(s.night).on),
|
||||
gameName: str(game.name),
|
||||
gameProfile: flag(game.profile) === true,
|
||||
events: Array.isArray(s.events) ? s.events.filter((e): e is string => typeof e === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSaved(raw: unknown): HbSavedDevice[] {
|
||||
const list = obj(raw).devices;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map(obj)
|
||||
.filter((d) => str(d.addr))
|
||||
.map((d) => ({ addr: str(d.addr), name: str(d.name), kind: str(d.kind), current: flag(d.current) === true }));
|
||||
}
|
||||
|
||||
export function parseScanned(raw: unknown): HbScannedDevice[] {
|
||||
const list = obj(raw).devices;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map(obj)
|
||||
.filter((d) => num(d.index) !== null)
|
||||
.map((d) => ({
|
||||
index: num(d.index) as number,
|
||||
addr: str(d.addr),
|
||||
name: str(d.name),
|
||||
kind: str(d.kind),
|
||||
rssi: num(d.rssi),
|
||||
}));
|
||||
}
|
||||
|
||||
async function read(res: Response): Promise<Obj> {
|
||||
const body = obj(await res.json().catch(() => ({})));
|
||||
if (!res.ok) throw new Error(str(body.error) || `request failed (${res.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
const q = (host: string) => `host=${encodeURIComponent(hostOf(host))}`;
|
||||
|
||||
/** Whether HearBridge answers on `host`, on which port, and its status. */
|
||||
export async function hbOverview(host: string): Promise<HbOverview> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/status?${q(host)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const w = await read(res);
|
||||
if (!w.present) return { present: false, error: str(w.error) || "not running" };
|
||||
return { present: true, port: num(w.port) ?? 8090, status: parseHbStatus(w.status) };
|
||||
}
|
||||
|
||||
export async function hbSaved(host: string): Promise<HbSavedDevice[]> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/saved?${q(host)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
return parseSaved(await read(res));
|
||||
}
|
||||
|
||||
export async function hbScanned(host: string): Promise<HbScannedDevice[]> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/devices?${q(host)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
return parseScanned(await read(res));
|
||||
}
|
||||
|
||||
export type HbAction =
|
||||
| { action: "volume"; pct: number }
|
||||
| { action: "mute"; on: boolean }
|
||||
| { action: "night"; on: boolean }
|
||||
| { action: "latency"; auto: boolean }
|
||||
| { action: "latency"; ms: number }
|
||||
| { action: "select"; addr: string }
|
||||
| { action: "select"; index: number }
|
||||
| { action: "reconnect" }
|
||||
| { action: "scan" }
|
||||
| { action: "stop" };
|
||||
|
||||
/** Make one change. Returns HearBridge's new status when its answer carries one. */
|
||||
export async function hbAct(host: string, a: HbAction): Promise<HbStatus | null> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/action`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ host: hostOf(host), ...a }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const w = await read(res);
|
||||
const result = obj(w.result);
|
||||
return typeof result.version === "string" ? parseHbStatus(result) : null;
|
||||
}
|
||||
@@ -37,7 +37,8 @@ const NAV_KEYWORDS: Record<string, string[]> = {
|
||||
"/volumes": ["disk", "drives"],
|
||||
"/files": ["browse", "file system"],
|
||||
"/console": ["hardware", "temps", "power"],
|
||||
"/payloads": ["homebrew catalog", "payload library", "kstuff", "shadowmount", "etahen"],
|
||||
"/payloads": ["homebrew catalog", "payload library", "kstuff", "shadowmount", "etahen", "nanodns", "hearbridge", "payload manager"],
|
||||
"/processes": ["ports", "task manager", "kill"],
|
||||
"/tasks": ["transfer log", "jobs"],
|
||||
"/bug-report": ["report", "problem", "issue", "discord", "github"],
|
||||
"/shell": ["terminal"],
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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-এর প্রতিটি পোর্ট পরীক্ষা করা হচ্ছে…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 के हर पोर्ट की जाँच हो रही है…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 の全ポートを確認中…",
|
||||
|
||||
@@ -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의 모든 포트를 확인하는 중…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 的所有端口…",
|
||||
|
||||
@@ -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 的所有連接埠…",
|
||||
|
||||
@@ -60,6 +60,11 @@ export interface TabbedShellProps<Id extends string> {
|
||||
/** Ordered list of tabs. The first one is the default when no
|
||||
* `?tab=...` is present in the URL. */
|
||||
tabs: ReadonlyArray<TabbedShellTab<Id>>;
|
||||
/** The tab shown when the URL names none (or an unknown one). Defaults to the first
|
||||
* tab; ignored when it is not one of `tabs`. */
|
||||
defaultTab?: Id;
|
||||
/** Shown beside the title (e.g. a count), like PageHeader's `count`. */
|
||||
count?: number;
|
||||
/** Render the active tab's content. Called once per render with the
|
||||
* active id; caller switches on id and returns the panel JSX. */
|
||||
renderPanel: (activeId: Id) => ReactNode;
|
||||
@@ -74,10 +79,12 @@ export interface TabbedShellProps<Id extends string> {
|
||||
function coerceTabId<Id extends string>(
|
||||
raw: string | null,
|
||||
tabs: ReadonlyArray<TabbedShellTab<Id>>,
|
||||
fallback?: Id,
|
||||
): Id {
|
||||
if (raw && tabs.some((t) => t.id === raw)) {
|
||||
return raw as Id;
|
||||
}
|
||||
if (fallback && tabs.some((t) => t.id === fallback)) return fallback;
|
||||
return tabs[0].id;
|
||||
}
|
||||
|
||||
@@ -87,12 +94,14 @@ export default function TabbedShell<Id extends string>({
|
||||
titleKey,
|
||||
titleFallback,
|
||||
tabs,
|
||||
defaultTab,
|
||||
count,
|
||||
renderPanel,
|
||||
}: TabbedShellProps<Id>) {
|
||||
const tr = useTr();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const activeTab = coerceTabId<Id>(searchParams.get("tab"), tabs);
|
||||
const activeTab = coerceTabId<Id>(searchParams.get("tab"), tabs, defaultTab);
|
||||
const setActiveTab = (next: Id) => {
|
||||
if (next === activeTab) return;
|
||||
// A tab here is a full user-facing view, not a transient filter. Push it
|
||||
@@ -120,6 +129,7 @@ export default function TabbedShell<Id extends string>({
|
||||
<PageHeader
|
||||
icon={IconForTitle}
|
||||
title={tr(titleKey, undefined, titleFallback)}
|
||||
count={count}
|
||||
description={description}
|
||||
/>
|
||||
|
||||
|
||||
@@ -205,8 +205,8 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
{
|
||||
to: "/processes",
|
||||
key: "processes",
|
||||
fallback: "Processes",
|
||||
key: "processes_and_ports",
|
||||
fallback: "Processes & ports",
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -41,6 +41,7 @@ import { recordPmStore } from "../../lib/pmUpdates";
|
||||
import { hostOf } from "../../lib/addr";
|
||||
import { useTr } from "../../state/lang";
|
||||
import UsbAutoloaderModal from "./UsbAutoloaderModal";
|
||||
import { payloadCardLink } from "./payloadTabs";
|
||||
|
||||
/**
|
||||
* Catalog tab of the Payloads screen — curated catalogue of
|
||||
@@ -339,11 +340,11 @@ export default function CatalogPanel() {
|
||||
withConsolePrefix(h, tr("payloads_pm_stored_title", { name: filename }, `Stored ${filename} in Payload Manager`)),
|
||||
{
|
||||
body: tr(
|
||||
"payloads_pm_stored_body",
|
||||
"payloads_pm_stored_body_v2",
|
||||
{ folder: r.folder },
|
||||
`It is in Payload Manager's "${r.folder}" folder. The Payload Manager tab launches it or loads it at boot.`,
|
||||
`It is in Payload Manager's "${r.folder}" folder. Payload Manager on the On your PS5 tab launches it or loads it at boot.`,
|
||||
),
|
||||
link: "/payloads?tab=manager",
|
||||
link: payloadCardLink("payload-manager"),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cpu,
|
||||
Download,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Skull,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge, Button, ConnectionGate, EmptyState, ErrorCard, SkeletonRows } from "../../../components";
|
||||
import { useConfirm } from "../../../components/ConfirmDialog";
|
||||
import {
|
||||
fsListDir,
|
||||
payloadsCatalog,
|
||||
payloadsDownload,
|
||||
payloadsLocalInventory,
|
||||
payloadsRelease,
|
||||
processKill,
|
||||
processList,
|
||||
sendPayload,
|
||||
type PayloadInfo,
|
||||
type ProcessInfo,
|
||||
} from "../../../api/ps5";
|
||||
import { mgmtAddr, withPort } from "../../../lib/addr";
|
||||
import { openExternalUrl } from "../../../lib/openExternalUrl";
|
||||
import { compareVersions } from "../../../lib/semver";
|
||||
import { isTauriEnv } from "../../../lib/tauriEnv";
|
||||
import { usePoll, useTransferBusy } from "../../../lib/usePoll";
|
||||
import { useDocumentVisible } from "../../../lib/visibility";
|
||||
import { useConnectionStore } from "../../../state/connection";
|
||||
import { useTr } from "../../../state/lang";
|
||||
import { log } from "../../../state/logs";
|
||||
import { pushNotification } from "../../../state/notifications";
|
||||
import { withConsolePrefix } from "../../../state/roster";
|
||||
import { killPrompt } from "../../Processes/killPrompt";
|
||||
import NanoDnsScreen from "../../NanoDns";
|
||||
import SmpPanel from "../../Library/SmpPanel";
|
||||
import PayloadManagerPanel from "../PayloadManagerPanel";
|
||||
import { PmSummary, SmpSummary } from "./summaries";
|
||||
import { HbSummary, HearBridgePanel, useHearBridge } from "./hearbridge";
|
||||
import { hbAct } from "../../../api/hearbridge";
|
||||
import {
|
||||
detectHubCards,
|
||||
hubEntries,
|
||||
markerFolders,
|
||||
type HubCard,
|
||||
type HubEntry,
|
||||
type IntegrationId,
|
||||
type IntegrationReport,
|
||||
} from "./registry";
|
||||
|
||||
/** The process list is re-read this often while the tab is open. Slower than Processes'
|
||||
* 3 s: cards change state rarely, and this shares the helper with everything else. */
|
||||
const REFRESH_MS = 10_000;
|
||||
|
||||
/** Which marker files exist. A folder is listed once; a folder whose parent listing lacks
|
||||
* it is not asked for at all (most consoles have none of these). */
|
||||
async function findMarkers(addr: string, entries: readonly HubEntry[]): Promise<Set<string>> {
|
||||
const found = new Set<string>();
|
||||
const listed = new Map<string, Set<string> | null>();
|
||||
const list = async (dir: string): Promise<Set<string> | null> => {
|
||||
if (listed.has(dir)) return listed.get(dir) ?? null;
|
||||
const slash = dir.lastIndexOf("/");
|
||||
if (slash > 0) {
|
||||
const parent = dir.slice(0, slash);
|
||||
const siblings = await list(parent);
|
||||
if (siblings && !siblings.has(dir.slice(slash + 1))) {
|
||||
listed.set(dir, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let names: Set<string> | null;
|
||||
try {
|
||||
names = new Set((await fsListDir(addr, dir)).map((e) => e.name));
|
||||
} catch {
|
||||
names = null;
|
||||
}
|
||||
listed.set(dir, names);
|
||||
return names;
|
||||
};
|
||||
const folders = [...markerFolders(entries).entries()].sort((a, b) => a[0].length - b[0].length);
|
||||
for (const [dir, paths] of folders) {
|
||||
const names = await list(dir);
|
||||
if (!names) continue;
|
||||
for (const p of paths) if (names.has(p.slice(p.lastIndexOf("/") + 1))) found.add(p);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* "On your PS5": one card per payload running or installed on the selected console, with
|
||||
* its web page, an update when the catalogue has a newer release, Stop, and (for
|
||||
* ShadowMount+, nanoDNS, Payload Manager and HearBridge) its own panel under Manage.
|
||||
*/
|
||||
export default function OnPs5Panel() {
|
||||
return (
|
||||
<ConnectionGate require="payload">
|
||||
<OnPs5Body />
|
||||
</ConnectionGate>
|
||||
);
|
||||
}
|
||||
|
||||
function OnPs5Body() {
|
||||
const tr = useTr();
|
||||
const native = isTauriEnv();
|
||||
const navigate = useNavigate();
|
||||
const host = useConnectionStore((s) => s.host);
|
||||
const payloadStatus = useConnectionStore((s) => s.payloadStatus);
|
||||
const online = payloadStatus === "up";
|
||||
const addr = mgmtAddr(host);
|
||||
const windowVisible = useDocumentVisible();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const openId = params.get("open");
|
||||
|
||||
const [catalog, setCatalog] = useState<PayloadInfo[] | null>(null);
|
||||
const [procs, setProcs] = useState<ProcessInfo[]>([]);
|
||||
const [procsLoaded, setProcsLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [markers, setMarkers] = useState<Set<string>>(new Set());
|
||||
const [markersLoaded, setMarkersLoaded] = useState(false);
|
||||
const [reports, setReports] = useState<Record<string, IntegrationReport>>({});
|
||||
const [latest, setLatest] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const { confirm, dialog } = useConfirm();
|
||||
// Bumped after an Update, so the integrations' summaries read their version again.
|
||||
const [summaryRev, setSummaryRev] = useState(0);
|
||||
// The catalogue has been read (or failed): markers are checked once, against the full list.
|
||||
const [catalogDone, setCatalogDone] = useState(!native);
|
||||
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
let cancelled = false;
|
||||
payloadsCatalog()
|
||||
.then((c) => {
|
||||
if (!cancelled) setCatalog(c);
|
||||
})
|
||||
.catch((e) => log.warn("payloads", `catalogue: ${String(e)}`))
|
||||
.finally(() => {
|
||||
if (!cancelled) setCatalogDone(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [native]);
|
||||
|
||||
const entries = useMemo(() => hubEntries(catalog), [catalog]);
|
||||
|
||||
// Another console's cards are not this one's.
|
||||
const genRef = useRef(0);
|
||||
useEffect(() => {
|
||||
genRef.current++;
|
||||
setProcs([]);
|
||||
setProcsLoaded(false);
|
||||
setMarkers(new Set());
|
||||
setMarkersLoaded(false);
|
||||
setReports({});
|
||||
setError(null);
|
||||
}, [addr]);
|
||||
|
||||
const inFlight = useRef(false);
|
||||
const refreshProcs = useCallback(async () => {
|
||||
if (!online || inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const gen = genRef.current;
|
||||
try {
|
||||
const r = await processList(addr);
|
||||
if (gen !== genRef.current) return;
|
||||
setProcs(r.processes);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (gen !== genRef.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (gen === genRef.current) setProcsLoaded(true);
|
||||
}
|
||||
}, [addr, online]);
|
||||
usePoll(refreshProcs, REFRESH_MS, { host: addr, enabled: online && windowVisible });
|
||||
|
||||
// Marker checks list folders on the console: like the pollers, they stand aside while a
|
||||
// transfer to this console runs, and catch up when it ends.
|
||||
const transferring = useTransferBusy(addr);
|
||||
const refreshMarkers = useCallback(async () => {
|
||||
if (!online || !catalogDone || transferring) return;
|
||||
const gen = genRef.current;
|
||||
const found = await findMarkers(addr, entries);
|
||||
if (gen !== genRef.current) return;
|
||||
setMarkers(found);
|
||||
setMarkersLoaded(true);
|
||||
}, [addr, entries, online, catalogDone, transferring]);
|
||||
useEffect(() => {
|
||||
void refreshMarkers();
|
||||
}, [refreshMarkers]);
|
||||
|
||||
// HearBridge's own API is read while its card is on screen (or a link opened it); an
|
||||
// answer also counts as running, should its process go by another name.
|
||||
const hbWanted =
|
||||
openId === "hearbridge" ||
|
||||
detectHubCards({ entries, processes: procs, markers }).some((c) => c.integration === "hearbridge");
|
||||
// Every status read lets HearBridge re-query the headset volume, so read slowly unless
|
||||
// its Manage panel is open.
|
||||
const hb = useHearBridge(host, online && hbWanted, openId === "hearbridge");
|
||||
const hbPresent = hb.view?.present === true;
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
detectHubCards({
|
||||
entries,
|
||||
processes: procs,
|
||||
markers,
|
||||
answering: hbPresent ? new Set(["hearbridge"]) : undefined,
|
||||
forceShow: openId,
|
||||
}),
|
||||
[entries, procs, markers, openId, hbPresent],
|
||||
);
|
||||
const hbVersion = hb.view?.present ? hb.view.status.version || null : null;
|
||||
const hbPort = hb.view?.present ? hb.view.port : null;
|
||||
|
||||
const report = useCallback((id: string, r: IntegrationReport) => {
|
||||
setReports((prev) => {
|
||||
const cur = prev[id];
|
||||
if (cur && cur.version === r.version && cur.webPort === r.webPort) return prev;
|
||||
return { ...prev, [id]: r };
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (hbVersion || hbPort) report("hearbridge", { version: hbVersion, webPort: hbPort });
|
||||
}, [hbVersion, hbPort, report]);
|
||||
|
||||
// The newest stable release of each card whose running version is known, so the card
|
||||
// can offer an update. Desktop only (the catalogue's GitHub lookups live there).
|
||||
const versionKey = cards
|
||||
.map((c) => `${c.id}=${reports[c.id]?.version ?? ""}`)
|
||||
.join("|");
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
let cancelled = false;
|
||||
for (const c of cards) {
|
||||
const v = reports[c.id]?.version;
|
||||
if (!v || c.entryIds.length !== 1 || latest[c.id] !== undefined) continue;
|
||||
payloadsRelease(c.id)
|
||||
.then((r) => {
|
||||
if (!cancelled && r.picked_asset_url) setLatest((prev) => ({ ...prev, [c.id]: r.tag }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// versionKey stands for cards + reports here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [native, versionKey]);
|
||||
|
||||
// A link that opened a card (an old tab's URL, a notification) scrolls to it once.
|
||||
const scrolledTo = useRef<string | null>(null);
|
||||
const openShown = !!openId && cards.some((c) => c.id === openId);
|
||||
useEffect(() => {
|
||||
if (!openId || !openShown || scrolledTo.current === openId) return;
|
||||
scrolledTo.current = openId;
|
||||
document
|
||||
.querySelector(`[data-card="${CSS.escape(openId)}"]`)
|
||||
?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}, [openId, openShown]);
|
||||
|
||||
const setOpen = (id: string | null) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (id) next.set("open", id);
|
||||
else next.delete("open");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const stop = async (card: HubCard) => {
|
||||
if (card.integration === "hearbridge" && hbPresent) return stopHearBridge();
|
||||
const p = card.processes[0];
|
||||
if (!p) return;
|
||||
const kind = killPrompt(p);
|
||||
if (!kind) return;
|
||||
const ok = await confirm({
|
||||
title: tr("processes_kill_payload_title", { name: card.name }, `Stop payload "${card.name}"?`),
|
||||
message: tr(
|
||||
"processes_kill_payload_body",
|
||||
{ name: card.name },
|
||||
`This kills the payload "${card.name}" (kstuff, etaHEN and the like). Whatever it provides stops until you send it again, and games that need it may stop starting.`,
|
||||
),
|
||||
confirmLabel: tr("processes_kill", undefined, "Kill"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(card.id);
|
||||
try {
|
||||
for (const proc of card.processes) {
|
||||
const ack = await processKill(addr, proc.pid);
|
||||
if (!ack.ok) throw new Error(ack.err || `pid ${proc.pid}`);
|
||||
}
|
||||
log.info("payloads", `stopped ${card.name}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
await refreshProcs();
|
||||
}
|
||||
};
|
||||
|
||||
/** HearBridge has a clean stop of its own (it closes the headset link first). */
|
||||
const stopHearBridge = async () => {
|
||||
const ok = await confirm({
|
||||
title: tr("hb_stop_title", undefined, "Stop HearBridge?"),
|
||||
message: tr(
|
||||
"hb_stop_body",
|
||||
undefined,
|
||||
"HearBridge closes and the headset disconnects. Send it again to start it.",
|
||||
),
|
||||
confirmLabel: tr("hub_stop", undefined, "Stop"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy("hearbridge");
|
||||
try {
|
||||
await hbAct(host, { action: "stop" });
|
||||
log.info("payloads", "stopped HearBridge");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
await hb.refresh();
|
||||
await refreshProcs();
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (card: HubCard, tag: string) => {
|
||||
const ok = await confirm({
|
||||
title: tr("hub_update_confirm_title", { name: card.name, version: tag }, `Update ${card.name} to ${tag}?`),
|
||||
message: tr(
|
||||
"hub_update_confirm_body",
|
||||
{ version: tag },
|
||||
`Downloads ${tag} and sends it to the PS5. Check the PS5 for its notification.`,
|
||||
),
|
||||
confirmLabel: tr("hub_update", { version: tag }, `Update to ${tag}`),
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(card.id);
|
||||
try {
|
||||
const rel = await payloadsRelease(card.id);
|
||||
const inv = await payloadsLocalInventory();
|
||||
let local = inv.find((e) => e.payload_id === card.id && e.version === rel.tag);
|
||||
if (!local) local = await payloadsDownload(card.id, rel.picked_asset_url, rel.tag);
|
||||
await sendPayload(host.trim(), local.path);
|
||||
pushNotification(
|
||||
"success",
|
||||
withConsolePrefix(host, tr("hub_updated", { name: card.name, version: rel.tag }, `Sent ${card.name} ${rel.tag}`)),
|
||||
);
|
||||
setReports((prev) => ({ ...prev, [card.id]: { ...prev[card.id], version: null } }));
|
||||
setSummaryRev((n) => n + 1);
|
||||
if (card.integration === "hearbridge") void hb.refresh();
|
||||
} catch (e) {
|
||||
pushNotification(
|
||||
"error",
|
||||
withConsolePrefix(host, tr("hub_update_failed", { name: card.name }, `Couldn't update ${card.name}`)),
|
||||
{ body: String(e) },
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = !procsLoaded || !markersLoaded;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm text-[var(--color-muted)]">
|
||||
{loading
|
||||
? tr("hub_checking", undefined, "Checking what is on this PS5…")
|
||||
: tr("hub_count", { n: cards.length }, `${cards.length} on this PS5`)}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<RefreshCw size={14} />}
|
||||
onClick={() => {
|
||||
void refreshProcs();
|
||||
void refreshMarkers();
|
||||
}}
|
||||
>
|
||||
{tr("refresh", undefined, "Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<ErrorCard
|
||||
title={tr("hub_error", undefined, "Couldn't read what is running on the PS5")}
|
||||
detail={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && cards.length === 0 ? (
|
||||
<SkeletonRows rows={3} />
|
||||
) : cards.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title={tr("hub_empty_title", undefined, "Nothing found on this PS5")}
|
||||
message={
|
||||
native
|
||||
? tr(
|
||||
"hub_empty_body",
|
||||
undefined,
|
||||
"No payload from the catalogue is running or installed on this console. Catalog has them, and Send loads any payload file.",
|
||||
)
|
||||
: tr("hub_empty_body_web", undefined, "No known payload is running or installed on this console.")
|
||||
}
|
||||
action={
|
||||
native ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button size="sm" leftIcon={<Boxes size={14} />} onClick={() => navigate("/payloads?tab=catalog")}>
|
||||
{tr("payloads_tab_catalog", undefined, "Catalog")}
|
||||
</Button>
|
||||
<Button size="sm" leftIcon={<Rocket size={14} />} onClick={() => navigate("/payloads?tab=send")}>
|
||||
{tr("payloads_tab_send", undefined, "Send payload")}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{cards.map((c) => {
|
||||
const rep = reports[c.id];
|
||||
const tag = latest[c.id];
|
||||
const newer =
|
||||
native && rep?.version && tag && compareVersions(rep.version, tag) === -1 ? tag : null;
|
||||
return (
|
||||
<HubCardView
|
||||
key={c.id}
|
||||
card={c}
|
||||
host={host}
|
||||
version={rep?.version ?? null}
|
||||
webPort={rep?.webPort ?? c.webPort}
|
||||
newer={newer}
|
||||
catalogued={catalog !== null}
|
||||
busy={busy === c.id}
|
||||
expanded={openId === c.id}
|
||||
onToggle={() => setOpen(openId === c.id ? null : c.id)}
|
||||
onStop={() => void stop(c)}
|
||||
onUpdate={(t) => void update(c, t)}
|
||||
canStop={c.integration === "hearbridge" && hbPresent ? true : undefined}
|
||||
summary={
|
||||
c.integration === "hearbridge"
|
||||
? () => <HbSummary view={hb.view} />
|
||||
: c.integration
|
||||
? (fallback) => (
|
||||
<Summary
|
||||
key={summaryRev}
|
||||
id={c.integration!}
|
||||
host={host}
|
||||
onReport={report}
|
||||
fallback={fallback}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
}
|
||||
panel={
|
||||
c.integration === "hearbridge" ? (
|
||||
<HearBridgePanel host={host} view={hb.view} apply={hb.apply} refresh={hb.refresh} />
|
||||
) : c.integration ? (
|
||||
<IntegrationPanel id={c.integration} host={host} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one-line live summary an integration shows on its card. */
|
||||
function Summary({
|
||||
id,
|
||||
host,
|
||||
onReport,
|
||||
fallback,
|
||||
}: {
|
||||
id: IntegrationId;
|
||||
host: string;
|
||||
onReport: (id: string, r: IntegrationReport) => void;
|
||||
fallback: ReactNode;
|
||||
}) {
|
||||
if (id === "payload-manager") {
|
||||
return <PmSummary host={host} onReport={(r) => onReport(id, r)} fallback={fallback} />;
|
||||
}
|
||||
if (id === "shadowmountplus") return <SmpSummary host={host} fallback={fallback} />;
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
/** The integration's own panel, as its tab showed it before. */
|
||||
function IntegrationPanel({ id, host }: { id: IntegrationId; host: string }) {
|
||||
if (id === "payload-manager") return <PayloadManagerPanel />;
|
||||
if (id === "shadowmountplus") return <SmpPanel mgmtAddr={host.trim() ? mgmtAddr(host.trim()) : null} />;
|
||||
if (id === "nanodns") return <NanoDnsScreen />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function HubCardView({
|
||||
card,
|
||||
host,
|
||||
version,
|
||||
webPort,
|
||||
newer,
|
||||
catalogued,
|
||||
canStop: canStopOverride,
|
||||
busy,
|
||||
expanded,
|
||||
onToggle,
|
||||
onStop,
|
||||
onUpdate,
|
||||
summary,
|
||||
panel,
|
||||
}: {
|
||||
card: HubCard;
|
||||
host: string;
|
||||
version: string | null;
|
||||
webPort: number | null;
|
||||
newer: string | null;
|
||||
/** The catalogue was read, so "not in the catalogue" can be said (not in the web UI). */
|
||||
catalogued: boolean;
|
||||
/** Overrides the process-based Stop (an integration with its own clean stop). */
|
||||
canStop?: boolean;
|
||||
busy: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onStop: () => void;
|
||||
onUpdate: (tag: string) => void;
|
||||
summary: ((fallback: ReactNode) => ReactNode) | null;
|
||||
panel: ReactNode;
|
||||
}) {
|
||||
const tr = useTr();
|
||||
const running = card.state === "running";
|
||||
const link = running && webPort ? `http://${withPort(host.trim(), webPort)}/` : null;
|
||||
const proc = card.processes[0];
|
||||
const canStop = canStopOverride ?? (running && !!proc && killPrompt(proc) !== null);
|
||||
const stateBadge =
|
||||
card.state === "running" ? (
|
||||
<Badge tone="good" dot>
|
||||
{tr("hub_state_running", undefined, "Running")}
|
||||
</Badge>
|
||||
) : card.state === "installed" ? (
|
||||
<Badge tone="neutral">{tr("hub_state_installed", undefined, "Installed")}</Badge>
|
||||
) : (
|
||||
<Badge tone="warn">{tr("hub_state_unknown", undefined, "Not found")}</Badge>
|
||||
);
|
||||
const detail =
|
||||
card.state === "running" && proc
|
||||
? tr(
|
||||
"hub_running_detail",
|
||||
{ name: proc.comm || proc.name, pid: proc.pid, mb: proc.memory_mib.toFixed(1) },
|
||||
`${proc.comm || proc.name} · pid ${proc.pid} · ${proc.memory_mib.toFixed(1)} MB`,
|
||||
)
|
||||
: card.state === "installed"
|
||||
? tr("hub_installed_detail", undefined, "On the console, not running.")
|
||||
: card.state === "unknown"
|
||||
? tr("hub_unknown_detail", undefined, "Not found on this PS5.")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="rounded-[var(--radius-card)] border border-[var(--glass-edge)] bg-[var(--color-surface-raised)] shadow-[var(--edge-highlight),var(--shadow-1)]"
|
||||
data-card={card.id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-3 py-2.5 sm:px-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[var(--color-surface-3)]">
|
||||
<Cpu size={17} className="text-[var(--color-muted)]" />
|
||||
</div>
|
||||
<div className="min-w-[min(100%,12rem)] flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate font-medium">{card.name}</span>
|
||||
{stateBadge}
|
||||
{version && (
|
||||
<Badge tone="neutral" variant="outline">
|
||||
{version.startsWith("v") ? version : `v${version}`}
|
||||
</Badge>
|
||||
)}
|
||||
{catalogued && card.id.startsWith("proc:") && (
|
||||
<Badge tone="neutral" variant="outline">
|
||||
{tr("hub_not_catalogued", undefined, "Not in the catalogue")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-muted)]">{summary ? summary(detail) : detail}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
{link && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ExternalLink size={14} />}
|
||||
onClick={() => void openExternalUrl(link)}
|
||||
title={link}
|
||||
>
|
||||
{tr("hub_open_page", undefined, "Open web page")}
|
||||
</Button>
|
||||
)}
|
||||
{newer && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<Download size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onUpdate(newer)}
|
||||
>
|
||||
{tr("hub_update", { version: newer }, `Update to ${newer}`)}
|
||||
</Button>
|
||||
)}
|
||||
{canStop && (
|
||||
<Button variant="ghost" size="sm" leftIcon={<Skull size={14} />} disabled={busy} onClick={onStop}>
|
||||
{tr("hub_stop", undefined, "Stop")}
|
||||
</Button>
|
||||
)}
|
||||
{panel && (
|
||||
<Button
|
||||
variant={expanded ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
rightIcon={expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{tr("hub_manage", undefined, "Manage")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && panel && (
|
||||
<div className="border-t border-[var(--glass-edge)] px-3 py-3 sm:px-4">{panel}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseHbStatus, type HbOverview } from "../../../api/hearbridge";
|
||||
import { HbSummary, HearBridgePanel, hbPageUrl, hbSummaryParts } from "./hearbridge";
|
||||
|
||||
// No DOM here (vitest runs in node): static markup, asserting on what is shown.
|
||||
const tr = ((_k: string, vars: Record<string, unknown> | undefined, fallback: string) =>
|
||||
fallback.replace(/\{(\w+)\}/g, (_, n) => String(vars?.[n] ?? ""))) as never;
|
||||
|
||||
const streaming = parseHbStatus({
|
||||
version: "1.9.0",
|
||||
state: "streaming",
|
||||
device: "WH-1000XM4",
|
||||
gain_pct: 140,
|
||||
muted: 0,
|
||||
codec: "SBC",
|
||||
latency: { target_ms: 120, estimate_ms: 185 },
|
||||
lat_extra: { auto: 0 },
|
||||
battery: { pct: 70 },
|
||||
link: { score: 88 },
|
||||
night: { on: 0 },
|
||||
game: { name: "Astro Bot", profile: 1 },
|
||||
events: ["Connected", "Streaming"],
|
||||
});
|
||||
|
||||
const present: HbOverview = { present: true, port: 8091, status: streaming };
|
||||
const noop = () => undefined;
|
||||
const noopAsync = async () => undefined;
|
||||
|
||||
describe("HearBridge card", () => {
|
||||
it("sums up the headset in one line", () => {
|
||||
expect(hbSummaryParts(streaming, tr)).toEqual([
|
||||
"Streaming",
|
||||
"WH-1000XM4",
|
||||
"Battery 70%",
|
||||
"Link 88%",
|
||||
"185 ms delay",
|
||||
"SBC",
|
||||
"Volume 140%",
|
||||
"Profile: Astro Bot",
|
||||
]);
|
||||
});
|
||||
|
||||
it("says less while waiting, and nothing it doesn't know", () => {
|
||||
const waiting = parseHbStatus({ version: "0.3", state: "disconnected", muted: 1 });
|
||||
expect(hbSummaryParts(waiting, tr)).toEqual(["Waiting for a headset", "Muted"]);
|
||||
});
|
||||
|
||||
it("has its own line while the first status read is out", () => {
|
||||
expect(renderToStaticMarkup(<HbSummary view={null} />)).toContain("Reading HearBridge");
|
||||
});
|
||||
|
||||
it("says HearBridge didn't answer instead of failing", () => {
|
||||
expect(renderToStaticMarkup(<HbSummary view={{ present: false, error: "x" }} />)).toContain(
|
||||
"HearBridge didn't answer",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens HearBridge's page on the port it answered on", () => {
|
||||
expect(hbPageUrl("192.168.86.99", 8091)).toBe("http://192.168.86.99:8091/");
|
||||
expect(hbPageUrl("192.168.86.99", null)).toBe("http://192.168.86.99:8090/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HearBridge Manage panel", () => {
|
||||
it("has the everyday controls and points to HearBridge's page for the rest", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HearBridgePanel host="192.168.86.99" view={present} apply={noop} refresh={noopAsync} />,
|
||||
);
|
||||
expect(html).toContain('max="500"'); // volume, HearBridge's 0..500 %
|
||||
expect(html).toContain('max="400"'); // fixed latency, 40..400 ms
|
||||
expect(html).toContain("Night mode");
|
||||
expect(html).toContain("Reconnect");
|
||||
expect(html).toContain("Scan for headsets");
|
||||
expect(html).toContain("Recent events");
|
||||
expect(html).toContain("Open HearBridge page");
|
||||
});
|
||||
|
||||
it("falls back to the page link when HearBridge doesn't answer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HearBridgePanel
|
||||
host="192.168.86.99"
|
||||
view={{ present: false, error: "HearBridge didn't answer on 192.168.86.99 port 8090-8095" }}
|
||||
apply={noop}
|
||||
refresh={noopAsync}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Open HearBridge page");
|
||||
expect(html).not.toContain('type="range"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bluetooth, ExternalLink, RefreshCw, Search } from "lucide-react";
|
||||
|
||||
import { Badge, Button, Callout, Toggle } from "../../../components";
|
||||
import { useConfirm } from "../../../components/ConfirmDialog";
|
||||
import {
|
||||
hbAct,
|
||||
hbOverview,
|
||||
hbSaved,
|
||||
hbScanned,
|
||||
type HbAction,
|
||||
type HbOverview,
|
||||
type HbSavedDevice,
|
||||
type HbScannedDevice,
|
||||
type HbStatus,
|
||||
} from "../../../api/hearbridge";
|
||||
import { withPort } from "../../../lib/addr";
|
||||
import { openExternalUrl } from "../../../lib/openExternalUrl";
|
||||
import { usePoll } from "../../../lib/usePoll";
|
||||
import { useDocumentVisible } from "../../../lib/visibility";
|
||||
import { useTr } from "../../../state/lang";
|
||||
import { log } from "../../../state/logs";
|
||||
|
||||
/** HearBridge's status is re-read this often while its card is on screen. Each read
|
||||
* counts as its page being open on the console side only for the volume re-query, so
|
||||
* keep it unhurried. */
|
||||
const POLL_MS = 5000;
|
||||
/** …and this often while the card is collapsed. */
|
||||
const POLL_SLOW_MS = 15_000;
|
||||
/** HearBridge's scan runs about 12 s (its own page says so). */
|
||||
const SCAN_MS = 13_000;
|
||||
|
||||
export const HB_DEFAULT_PORT = 8090;
|
||||
|
||||
/** HearBridge's status on `host`, kept fresh while `active`. `null` until first read. */
|
||||
export function useHearBridge(host: string, active: boolean, fast = false) {
|
||||
const visible = useDocumentVisible();
|
||||
const [view, setView] = useState<HbOverview | null>(null);
|
||||
const gen = useRef(0);
|
||||
useEffect(() => {
|
||||
gen.current++;
|
||||
setView(null);
|
||||
}, [host]);
|
||||
const refresh = useCallback(async () => {
|
||||
if (!host.trim()) return;
|
||||
const g = gen.current;
|
||||
try {
|
||||
const v = await hbOverview(host);
|
||||
if (g === gen.current) setView(v);
|
||||
} catch (e) {
|
||||
if (g === gen.current) setView({ present: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}, [host]);
|
||||
usePoll(refresh, fast ? POLL_MS : POLL_SLOW_MS, { host, enabled: active && visible && !!host.trim() });
|
||||
/** Use the status HearBridge sent back with a change, so the panel doesn't wait a poll. */
|
||||
const apply = useCallback((s: HbStatus | null) => {
|
||||
if (!s) return;
|
||||
setView((v) => (v && v.present ? { ...v, status: s } : v));
|
||||
}, []);
|
||||
return { view, refresh, apply };
|
||||
}
|
||||
|
||||
/** The card's one-line summary: state, headset, battery, link, latency, codec, volume, game. */
|
||||
export function HbSummary({ view }: { view: HbOverview | null }) {
|
||||
const tr = useTr();
|
||||
if (!view) return <>{tr("hb_checking", undefined, "Reading HearBridge's status…")}</>;
|
||||
if (!view.present) return <>{tr("hb_no_answer", undefined, "HearBridge didn't answer")}</>;
|
||||
return <>{hbSummaryParts(view.status, tr).join(" · ")}</>;
|
||||
}
|
||||
|
||||
type Tr = ReturnType<typeof useTr>;
|
||||
|
||||
export function hbStateLabel(state: string, tr: Tr): string {
|
||||
switch (state) {
|
||||
case "streaming":
|
||||
return tr("hb_state_streaming", undefined, "Streaming");
|
||||
case "connecting":
|
||||
return tr("hb_state_connecting", undefined, "Connecting");
|
||||
case "reconnecting":
|
||||
return tr("hb_state_reconnecting", undefined, "Reconnecting");
|
||||
case "scanning":
|
||||
return tr("hb_state_scanning", undefined, "Scanning");
|
||||
case "disconnected":
|
||||
return tr("hb_state_waiting", undefined, "Waiting for a headset");
|
||||
case "error":
|
||||
return tr("hb_state_error", undefined, "Error");
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function hbSummaryParts(s: HbStatus, tr: Tr): string[] {
|
||||
const parts: string[] = [];
|
||||
if (s.state) parts.push(hbStateLabel(s.state, tr));
|
||||
if (s.device) parts.push(s.device);
|
||||
if (s.batteryPct !== null) parts.push(tr("hb_battery", { pct: s.batteryPct }, `Battery ${s.batteryPct}%`));
|
||||
if (s.linkScore !== null && s.state === "streaming") {
|
||||
parts.push(tr("hb_link", { score: s.linkScore }, `Link ${s.linkScore}%`));
|
||||
}
|
||||
const ms = s.latencyEstimateMs ?? s.latencyTargetMs;
|
||||
if (ms !== null && s.state === "streaming") parts.push(tr("hb_latency", { ms }, `${ms} ms delay`));
|
||||
if (s.codec && s.state === "streaming") parts.push(s.codec);
|
||||
if (s.muted) parts.push(tr("hb_muted", undefined, "Muted"));
|
||||
else if (s.gainPct !== null) parts.push(tr("hb_volume", { pct: s.gainPct }, `Volume ${s.gainPct}%`));
|
||||
if (s.gameProfile && s.gameName) parts.push(tr("hb_game", { name: s.gameName }, `Profile: ${s.gameName}`));
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** HearBridge's page on the console (pairing, EQ, backups and the rest live there). */
|
||||
export function hbPageUrl(host: string, port: number | null | undefined): string {
|
||||
return `http://${withPort(host.trim(), port ?? HB_DEFAULT_PORT)}/`;
|
||||
}
|
||||
|
||||
/** Manage: the everyday controls. Everything else stays on HearBridge's own page. */
|
||||
export function HearBridgePanel({
|
||||
host,
|
||||
view,
|
||||
apply,
|
||||
refresh,
|
||||
}: {
|
||||
host: string;
|
||||
view: HbOverview | null;
|
||||
apply: (s: HbStatus | null) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}) {
|
||||
const tr = useTr();
|
||||
const { confirm, dialog } = useConfirm();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState<HbSavedDevice[] | null>(null);
|
||||
const [scanned, setScanned] = useState<HbScannedDevice[] | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Slider values while dragging; sent on release so a drag is one change, not dozens.
|
||||
const [gainDraft, setGainDraft] = useState<number | null>(null);
|
||||
const [latDraft, setLatDraft] = useState<number | null>(null);
|
||||
const present = view?.present === true;
|
||||
const port = view && view.present ? view.port : HB_DEFAULT_PORT;
|
||||
const s = view && view.present ? view.status : null;
|
||||
|
||||
const loadSaved = useCallback(async () => {
|
||||
try {
|
||||
setSaved(await hbSaved(host));
|
||||
} catch {
|
||||
setSaved(null);
|
||||
}
|
||||
}, [host]);
|
||||
useEffect(() => {
|
||||
if (present) void loadSaved();
|
||||
}, [present, loadSaved]);
|
||||
|
||||
/** Make one change; false when HearBridge didn't take it (the error is shown). */
|
||||
const run = async (a: HbAction): Promise<boolean> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
apply(await hbAct(host, a));
|
||||
log.info("hearbridge", `${a.action} on ${host}`);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scan = async () => {
|
||||
if (s?.state === "streaming") {
|
||||
const ok = await confirm({
|
||||
title: tr("hb_scan_confirm_title", undefined, "Scan for headsets?"),
|
||||
message: tr(
|
||||
"hb_scan_confirm_body",
|
||||
undefined,
|
||||
"Scanning stops the headset's audio while it runs (about 12 seconds).",
|
||||
),
|
||||
confirmLabel: tr("hb_scan", undefined, "Scan for headsets"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setScanning(true);
|
||||
setScanned(null);
|
||||
if (!(await run({ action: "scan" }))) {
|
||||
setScanning(false);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
hbScanned(host)
|
||||
.then(setScanned)
|
||||
.catch(() => setScanned([]))
|
||||
.finally(() => setScanning(false));
|
||||
}, SCAN_MS);
|
||||
};
|
||||
|
||||
const openPage = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<ExternalLink size={14} />}
|
||||
onClick={() => void openExternalUrl(hbPageUrl(host, port))}
|
||||
>
|
||||
{tr("hb_open_page", undefined, "Open HearBridge page")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!view) return null;
|
||||
if (!present || !s) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Callout tone="warn" title={tr("hb_no_answer", undefined, "HearBridge didn't answer")}>
|
||||
{view.present ? null : view.error}
|
||||
</Callout>
|
||||
<div>{openPage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gain = gainDraft ?? s.gainPct ?? 100;
|
||||
const lat = latDraft ?? s.latencyTargetMs ?? 120;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
{error && (
|
||||
<Callout tone="error" title={tr("hb_action_failed", undefined, "HearBridge didn't take that change")}>
|
||||
{error}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex min-w-[min(100%,18rem)] flex-1 items-center gap-3">
|
||||
<span className="w-24 shrink-0">{tr("hb_volume_label", undefined, "Volume")}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={500}
|
||||
step={10}
|
||||
value={gain}
|
||||
disabled={busy || s.gainPct === null}
|
||||
onChange={(e) => setGainDraft(Number(e.target.value))}
|
||||
onPointerUp={() => {
|
||||
if (gainDraft !== null) void run({ action: "volume", pct: gainDraft }).finally(() => setGainDraft(null));
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
if (gainDraft !== null) void run({ action: "volume", pct: gainDraft }).finally(() => setGainDraft(null));
|
||||
}}
|
||||
className="flex-1"
|
||||
aria-label={tr("hb_volume_label", undefined, "Volume")}
|
||||
/>
|
||||
<span className="w-14 text-right font-mono tabular-nums">{gain}%</span>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={!!s.muted}
|
||||
onChange={(on) => void run({ action: "mute", on })}
|
||||
label={tr("hb_mute", undefined, "Mute")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="w-24 shrink-0">{tr("hb_latency_label", undefined, "Latency")}</span>
|
||||
<Toggle
|
||||
checked={!!s.latencyAuto}
|
||||
onChange={(auto) => void run({ action: "latency", auto })}
|
||||
label={tr("hb_latency_auto", undefined, "Automatic")}
|
||||
/>
|
||||
{!s.latencyAuto && (
|
||||
<label className="flex min-w-[min(100%,14rem)] flex-1 items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={40}
|
||||
max={400}
|
||||
step={10}
|
||||
value={lat}
|
||||
disabled={busy}
|
||||
onChange={(e) => setLatDraft(Number(e.target.value))}
|
||||
onPointerUp={() => {
|
||||
if (latDraft !== null) void run({ action: "latency", ms: latDraft }).finally(() => setLatDraft(null));
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
if (latDraft !== null) void run({ action: "latency", ms: latDraft }).finally(() => setLatDraft(null));
|
||||
}}
|
||||
className="flex-1"
|
||||
aria-label={tr("hb_latency_label", undefined, "Latency")}
|
||||
/>
|
||||
<span className="w-16 text-right font-mono tabular-nums">{lat} ms</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!s.night}
|
||||
onChange={(on) => void run({ action: "night", on })}
|
||||
label={tr("hb_night", undefined, "Night mode")}
|
||||
hint={tr("hb_night_hint", undefined, "Quieter explosions, clearer voices.")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="font-medium">{tr("hb_headsets", undefined, "Headsets")}</h4>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<RefreshCw size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => void run({ action: "reconnect" })}
|
||||
>
|
||||
{tr("hb_reconnect", undefined, "Reconnect")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<Search size={14} />}
|
||||
loading={scanning}
|
||||
disabled={busy || scanning}
|
||||
onClick={() => void scan()}
|
||||
>
|
||||
{tr("hb_scan", undefined, "Scan for headsets")}
|
||||
</Button>
|
||||
</div>
|
||||
{saved && saved.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{saved.map((d) => (
|
||||
<li key={d.addr} className="flex flex-wrap items-center gap-2">
|
||||
<Bluetooth size={14} className="text-[var(--color-muted)]" />
|
||||
<span>{d.name || d.addr}</span>
|
||||
{d.current ? (
|
||||
<Badge tone="good">{tr("hb_current", undefined, "In use")}</Badge>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void run({ action: "select", addr: d.addr })}
|
||||
>
|
||||
{tr("hb_switch", undefined, "Switch to this one")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{saved && saved.length === 0 && (
|
||||
<p className="text-xs text-[var(--color-muted)]">
|
||||
{tr("hb_saved_empty", undefined, "No saved headsets yet. Scan for one, or pair it on HearBridge's page.")}
|
||||
</p>
|
||||
)}
|
||||
{scanned && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{scanned.length
|
||||
? tr("hb_scan_found", undefined, "Found in the last scan:")
|
||||
: tr("hb_scan_empty", undefined, "Nothing found. Put the headset in pairing mode and scan again.")}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{scanned.map((d) => (
|
||||
<li key={`${d.index}-${d.addr}`} className="flex flex-wrap items-center gap-2">
|
||||
<Bluetooth size={14} className="text-[var(--color-muted)]" />
|
||||
<span>{d.name || d.addr}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void run({ action: "select", index: d.index })}
|
||||
>
|
||||
{tr("hb_connect", undefined, "Connect")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{s.events.length > 0 && (
|
||||
<section className="flex flex-col gap-1">
|
||||
<h4 className="font-medium">{tr("hb_events", undefined, "Recent events")}</h4>
|
||||
<ul className="font-mono text-xs text-[var(--color-muted)]">
|
||||
{s.events.slice(-8).reverse().map((e, i) => (
|
||||
<li key={`${i}-${e}`}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-wrap items-center gap-3">
|
||||
{openPage}
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{tr(
|
||||
"hb_page_hint",
|
||||
undefined,
|
||||
"Pairing a new headset, the equalizer, backups and every other setting are on HearBridge's own page.",
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
{dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PayloadInfo, ProcessInfo } from "../../../api/ps5";
|
||||
import { detectHubCards, entryFromCatalog, hubEntries, markerFolders, normProcName } from "./registry";
|
||||
|
||||
const proc = (pid: number, comm: string, kind: ProcessInfo["kind"] = "payload", extra: Partial<ProcessInfo> = {}): ProcessInfo => ({
|
||||
pid,
|
||||
name: comm,
|
||||
comm,
|
||||
title_id: "",
|
||||
app_id: 0,
|
||||
memory_mib: 4,
|
||||
threads: 3,
|
||||
kind,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const info = (id: string, display: string, hint: string | null, marker: string | null, ports: number[] = []): PayloadInfo => ({
|
||||
id,
|
||||
display_name: display,
|
||||
role: "",
|
||||
description: "",
|
||||
repo_owner: "o",
|
||||
repo_name: "r",
|
||||
on_console_marker_path: marker,
|
||||
process_name_hint: hint,
|
||||
ports,
|
||||
autoload_priority: 0,
|
||||
autoload_delay_ms: 0,
|
||||
homepage: "",
|
||||
});
|
||||
|
||||
// A slice of the desktop catalogue (commands/payloads.rs).
|
||||
const CATALOG: PayloadInfo[] = [
|
||||
info("kstuff-echostretch", "kstuff", null, "/data/kstuff.elf"),
|
||||
info("kstuff-lite-drakmor", "kstuff-lite", null, "/data/kstuff.elf"),
|
||||
info("shadowmountplus", "ShadowMount+", "shadowmountplus", "/data/shadowmount/debug.log"),
|
||||
info("ftpsrv", "ftpsrv", "ftpsrv", null, [2121]),
|
||||
info("payload-manager", "Payload Manager", null, "/data/pldmgr/pldmgr_config.txt", [8084]),
|
||||
info("nanodns", "nanoDNS", "nanodns.elf", "/data/nanodns/nanodns.ini", [53]),
|
||||
info("hearbridge", "HearBridge", "hearbridge.elf", "/data/hearbridge/hearbridge.log", [8090]),
|
||||
info("klogsrv", "klogsrv", "klogsrv.elf", "/data/klog/klog.log", [3232]),
|
||||
];
|
||||
|
||||
// The FW 13.60 Phat's process list (payloads + the helper), 2026-10-18.
|
||||
const PHAT: ProcessInfo[] = [
|
||||
proc(130, "ps5upload-tile", "system", { is_self: true }),
|
||||
proc(129, "shadowmountplus.elf"),
|
||||
proc(128, "kstuff.elf"),
|
||||
proc(127, "elfldr.elf"),
|
||||
proc(107, "kstuff.elf"),
|
||||
proc(97, "ftpsrv.elf"),
|
||||
proc(96, "nanodns.elf"),
|
||||
proc(90, "pldmgr.elf"),
|
||||
proc(60, "SceShellUI", "system"),
|
||||
];
|
||||
|
||||
describe("On your PS5 detection", () => {
|
||||
it("finds every catalogued payload running on the Phat, integrations first", () => {
|
||||
const cards = detectHubCards({ entries: hubEntries(CATALOG), processes: PHAT, markers: new Set() });
|
||||
expect(cards.map((c) => [c.id, c.state])).toEqual([
|
||||
["nanodns", "running"],
|
||||
["payload-manager", "running"],
|
||||
["shadowmountplus", "running"],
|
||||
["proc:elfldr", "running"],
|
||||
["ftpsrv", "running"],
|
||||
["kstuff-echostretch", "running"],
|
||||
]);
|
||||
const kstuff = cards.find((c) => c.id === "kstuff-echostretch")!;
|
||||
// Two catalogue builds, one process name: one card naming both, both pids.
|
||||
expect(kstuff.entryIds).toEqual(["kstuff-echostretch", "kstuff-lite-drakmor"]);
|
||||
expect(kstuff.name).toBe("kstuff / kstuff-lite");
|
||||
expect(kstuff.processes.map((p) => p.pid)).toEqual([128, 107]);
|
||||
expect(cards.find((c) => c.id === "payload-manager")!.webPort).toBe(8084);
|
||||
expect(cards.find((c) => c.id === "proc:elfldr")!.name).toBe("elfldr.elf");
|
||||
});
|
||||
|
||||
it("never shows the helper itself or system processes", () => {
|
||||
const cards = detectHubCards({ entries: hubEntries(CATALOG), processes: PHAT, markers: new Set() });
|
||||
expect(cards.some((c) => c.processes.some((p) => p.is_self || p.kind === "system"))).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a payload whose marker is on the console as installed", () => {
|
||||
const cards = detectHubCards({
|
||||
entries: hubEntries(CATALOG),
|
||||
processes: [],
|
||||
markers: new Set(["/data/hearbridge/hearbridge.log", "/data/klog/klog.log"]),
|
||||
});
|
||||
expect(cards.map((c) => [c.id, c.state])).toEqual([
|
||||
["hearbridge", "installed"],
|
||||
["klogsrv", "installed"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out what is neither running nor installed, unless a link opened it", () => {
|
||||
const entries = hubEntries(CATALOG);
|
||||
expect(detectHubCards({ entries, processes: [], markers: new Set() })).toEqual([]);
|
||||
const forced = detectHubCards({ entries, processes: [], markers: new Set(), forceShow: "hearbridge" });
|
||||
expect(forced.map((c) => [c.id, c.state])).toEqual([["hearbridge", "unknown"]]);
|
||||
});
|
||||
|
||||
it("counts an integration whose own API answered as running", () => {
|
||||
const cards = detectHubCards({
|
||||
entries: hubEntries(CATALOG),
|
||||
processes: [],
|
||||
markers: new Set(["/data/hearbridge/hearbridge.log"]),
|
||||
answering: new Set(["hearbridge"]),
|
||||
});
|
||||
expect(cards.map((c) => [c.id, c.state, c.integration, c.webPort])).toEqual([
|
||||
["hearbridge", "running", "hearbridge", 8090],
|
||||
]);
|
||||
});
|
||||
|
||||
it("works without a catalogue (the browser build) for the integrations", () => {
|
||||
const cards = detectHubCards({ entries: hubEntries(null), processes: PHAT, markers: new Set() });
|
||||
expect(cards.map((c) => c.id)).toEqual([
|
||||
"nanodns",
|
||||
"payload-manager",
|
||||
"shadowmountplus",
|
||||
"proc:elfldr",
|
||||
"proc:ftpsrv",
|
||||
"proc:kstuff",
|
||||
]);
|
||||
expect(cards.find((c) => c.id === "proc:kstuff")!.processes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("compares process names without case or extension", () => {
|
||||
expect(normProcName("HearBridge.ELF")).toBe("hearbridge");
|
||||
expect(normProcName("etaHEN")).toBe("etahen");
|
||||
expect(entryFromCatalog(CATALOG[4]).processNames).toEqual(["pldmgr"]);
|
||||
expect(entryFromCatalog(CATALOG[5]).webPort).toBeNull();
|
||||
});
|
||||
|
||||
it("lists each marker folder once", () => {
|
||||
const folders = markerFolders(hubEntries(CATALOG));
|
||||
expect(folders.get("/data")).toEqual(["/data/kstuff.elf"]);
|
||||
expect(folders.get("/data/pldmgr")).toEqual(["/data/pldmgr/pldmgr_config.txt"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
// "On your PS5": which payloads are on the console, as cards. Pure: the panel feeds it the
|
||||
// catalogue, the process list and which marker files exist, and renders what comes back.
|
||||
//
|
||||
// A payload is "running" when a process with its name is up, "installed" when its marker
|
||||
// file is on the console. Payloads show up in the process list under their ELF's file name
|
||||
// ("shadowmountplus.elf", "nanodns.elf", "pldmgr.elf"; measured on FW 13.60), so names are
|
||||
// compared without case or extension.
|
||||
|
||||
import type { PayloadInfo, ProcessInfo } from "../../../api/ps5";
|
||||
|
||||
/** The payloads with a panel of their own on the card ("Manage"). Adding one = a registry
|
||||
* entry here plus its panel in OnPs5Panel; never a new tab. */
|
||||
export type IntegrationId = "shadowmountplus" | "nanodns" | "payload-manager" | "hearbridge";
|
||||
|
||||
export const INTEGRATION_IDS: readonly IntegrationId[] = [
|
||||
"shadowmountplus",
|
||||
"nanodns",
|
||||
"payload-manager",
|
||||
"hearbridge",
|
||||
];
|
||||
|
||||
export function isIntegration(id: string): id is IntegrationId {
|
||||
return (INTEGRATION_IDS as readonly string[]).includes(id);
|
||||
}
|
||||
|
||||
/** What an integration's card summary learned from its own API. */
|
||||
export interface IntegrationReport {
|
||||
version?: string | null;
|
||||
/** The port its web page answered on, when that differs from the catalogue's. */
|
||||
webPort?: number | null;
|
||||
}
|
||||
|
||||
/** What the hub needs to know about one payload. */
|
||||
export interface HubEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Process names it runs under (any case, with or without ".elf"). */
|
||||
processNames: string[];
|
||||
/** A file only it leaves on the console. */
|
||||
marker: string | null;
|
||||
/** Its web page's port, if it has one. */
|
||||
webPort: number | null;
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
/** Ports the catalogue lists that serve a web page (diagnostics::known_port_is_web). */
|
||||
const WEB_PORTS = new Set([8080, 8084, 8090]);
|
||||
|
||||
/** Process names the catalogue doesn't carry (it has no hint for these), measured on the
|
||||
* console's process list. */
|
||||
const EXTRA_PROCESS_NAMES: Record<string, string[]> = {
|
||||
"payload-manager": ["pldmgr"],
|
||||
"kstuff-echostretch": ["kstuff"],
|
||||
"kstuff-lite-drakmor": ["kstuff"],
|
||||
};
|
||||
|
||||
/** The integrations as the browser build knows them: it has no catalogue (that lives in the
|
||||
* desktop app), so these four stand in for it. Mirrors commands/payloads.rs. */
|
||||
export const BUILTIN_ENTRIES: readonly HubEntry[] = [
|
||||
{
|
||||
id: "shadowmountplus",
|
||||
name: "ShadowMount+",
|
||||
processNames: ["shadowmountplus"],
|
||||
marker: "/data/shadowmount/debug.log",
|
||||
webPort: null,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: "nanodns",
|
||||
name: "nanoDNS",
|
||||
processNames: ["nanodns"],
|
||||
marker: "/data/nanodns/nanodns.ini",
|
||||
webPort: null,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: "payload-manager",
|
||||
name: "Payload Manager",
|
||||
processNames: ["pldmgr"],
|
||||
marker: "/data/pldmgr/pldmgr_config.txt",
|
||||
webPort: 8084,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: "hearbridge",
|
||||
name: "HearBridge",
|
||||
processNames: ["hearbridge"],
|
||||
marker: "/data/hearbridge/hearbridge.log",
|
||||
webPort: 8090,
|
||||
isCustom: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function entryFromCatalog(p: PayloadInfo): HubEntry {
|
||||
const names = [
|
||||
...(p.process_name_hint ? [p.process_name_hint] : []),
|
||||
...(EXTRA_PROCESS_NAMES[p.id] ?? []),
|
||||
];
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.display_name,
|
||||
processNames: names,
|
||||
marker: p.on_console_marker_path,
|
||||
webPort: p.ports.find((port) => WEB_PORTS.has(port)) ?? null,
|
||||
isCustom: !!p.is_custom,
|
||||
};
|
||||
}
|
||||
|
||||
/** The entries to detect: the catalogue when there is one, plus any integration it lacks. */
|
||||
export function hubEntries(catalog: readonly PayloadInfo[] | null): HubEntry[] {
|
||||
const out = (catalog ?? []).map(entryFromCatalog);
|
||||
for (const b of BUILTIN_ENTRIES) if (!out.some((e) => e.id === b.id)) out.push(b);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normProcName(s: string): string {
|
||||
return s.trim().toLowerCase().replace(/\.(elf|bin)$/, "");
|
||||
}
|
||||
|
||||
export type HubState = "running" | "installed" | "unknown";
|
||||
|
||||
export interface HubCard {
|
||||
/** The catalogue id, or `proc:<name>` for a payload the catalogue doesn't know. */
|
||||
id: string;
|
||||
/** Every catalogue entry this card stands for (two kstuff builds share one process name). */
|
||||
entryIds: string[];
|
||||
name: string;
|
||||
state: HubState;
|
||||
/** Its running processes (empty when installed but not running). */
|
||||
processes: ProcessInfo[];
|
||||
webPort: number | null;
|
||||
integration: IntegrationId | null;
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
export interface DetectInput {
|
||||
entries: readonly HubEntry[];
|
||||
processes: readonly ProcessInfo[];
|
||||
/** Marker paths found on the console. */
|
||||
markers: ReadonlySet<string>;
|
||||
/** Integrations whose own API answered (Payload Manager on :8084, HearBridge): running
|
||||
* even when the process list hasn't been read yet. */
|
||||
answering?: ReadonlySet<string>;
|
||||
/** A card to show even when nothing was found (a deep link opened it). */
|
||||
forceShow?: string | null;
|
||||
}
|
||||
|
||||
/** One card per payload that is running or installed, then one per running payload the
|
||||
* catalogue doesn't know. Running before installed, integrations first, then by name. */
|
||||
export function detectHubCards({
|
||||
entries,
|
||||
processes,
|
||||
markers,
|
||||
answering,
|
||||
forceShow,
|
||||
}: DetectInput): HubCard[] {
|
||||
const claimed = new Set<number>();
|
||||
const byEvidence = new Map<string, HubCard>();
|
||||
const cards: HubCard[] = [];
|
||||
for (const e of entries) {
|
||||
const names = new Set(e.processNames.map(normProcName).filter(Boolean));
|
||||
const procs = processes.filter(
|
||||
(p) => !p.is_self && (names.has(normProcName(p.comm)) || names.has(normProcName(p.name))),
|
||||
);
|
||||
const integration = isIntegration(e.id) ? e.id : null;
|
||||
const answered = !!answering?.has(e.id);
|
||||
const installed = !!e.marker && markers.has(e.marker);
|
||||
const forced = forceShow === e.id;
|
||||
if (procs.length === 0 && !installed && !answered && !forced) continue;
|
||||
for (const p of procs) claimed.add(p.pid);
|
||||
const state: HubState = procs.length > 0 || answered ? "running" : installed ? "installed" : "unknown";
|
||||
// Two catalogue entries seen through the same process or marker are one thing on the
|
||||
// console (kstuff's two builds both run as kstuff.elf): one card naming both.
|
||||
const evidence = procs.length
|
||||
? `p:${procs.map((p) => p.pid).sort((a, b) => a - b).join(",")}`
|
||||
: installed && !answered
|
||||
? `m:${e.marker}`
|
||||
: `id:${e.id}`;
|
||||
const same = byEvidence.get(evidence);
|
||||
if (same && !integration && !same.integration) {
|
||||
same.entryIds.push(e.id);
|
||||
same.name = `${same.name} / ${e.name}`;
|
||||
continue;
|
||||
}
|
||||
const card: HubCard = {
|
||||
id: e.id,
|
||||
entryIds: [e.id],
|
||||
name: e.name,
|
||||
state,
|
||||
processes: procs,
|
||||
webPort: e.webPort,
|
||||
integration,
|
||||
isCustom: e.isCustom,
|
||||
};
|
||||
byEvidence.set(evidence, card);
|
||||
cards.push(card);
|
||||
}
|
||||
// Payloads nobody catalogued, seen running (one card per name).
|
||||
const unknown = new Map<string, HubCard>();
|
||||
for (const p of processes) {
|
||||
if (p.kind !== "payload" || p.is_self || claimed.has(p.pid)) continue;
|
||||
const label = p.comm || p.name;
|
||||
const key = normProcName(label);
|
||||
if (!key) continue;
|
||||
const card = unknown.get(key);
|
||||
if (card) {
|
||||
card.processes.push(p);
|
||||
continue;
|
||||
}
|
||||
unknown.set(key, {
|
||||
id: `proc:${key}`,
|
||||
entryIds: [],
|
||||
name: label,
|
||||
state: "running",
|
||||
processes: [p],
|
||||
webPort: null,
|
||||
integration: null,
|
||||
isCustom: true,
|
||||
});
|
||||
}
|
||||
const rank = (c: HubCard) =>
|
||||
(c.state === "running" ? 0 : c.state === "installed" ? 2 : 4) + (c.integration ? 0 : 1);
|
||||
return [...cards, ...unknown.values()].sort(
|
||||
(a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
/** Group marker paths by folder, so each folder is listed once. */
|
||||
export function markerFolders(entries: readonly HubEntry[]): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const e of entries) {
|
||||
const m = e.marker?.replace(/\/+$/, "");
|
||||
if (!m) continue;
|
||||
const slash = m.lastIndexOf("/");
|
||||
if (slash < 0) continue;
|
||||
const dir = slash === 0 ? "/" : m.slice(0, slash);
|
||||
const list = out.get(dir) ?? [];
|
||||
if (!list.includes(m)) list.push(m);
|
||||
out.set(dir, list);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { pmOverview } from "../../../api/payloadManager";
|
||||
import { smpStatus } from "../../../api/ps5";
|
||||
import { mgmtAddr } from "../../../lib/addr";
|
||||
import { useTr } from "../../../state/lang";
|
||||
import type { IntegrationReport } from "./registry";
|
||||
|
||||
/** Payload Manager's card line: its version and how many payloads it keeps. Read once per
|
||||
* console; its own panel (Manage) has the live view. */
|
||||
export function PmSummary({
|
||||
host,
|
||||
onReport,
|
||||
fallback,
|
||||
}: {
|
||||
host: string;
|
||||
onReport: (r: IntegrationReport) => void;
|
||||
fallback: ReactNode;
|
||||
}) {
|
||||
const tr = useTr();
|
||||
const [line, setLine] = useState<string | null>(null);
|
||||
const reportRef = useRef(onReport);
|
||||
useEffect(() => {
|
||||
reportRef.current = onReport;
|
||||
});
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLine(null);
|
||||
if (!host.trim()) return;
|
||||
pmOverview(host)
|
||||
.then((v) => {
|
||||
if (cancelled || !v.present) return;
|
||||
reportRef.current({ version: v.version || null });
|
||||
setLine(
|
||||
tr("hub_pm_summary", { n: v.payloads.length }, `Payloads stored: ${v.payloads.length}`),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// tr is stable per language; re-reading on a language switch is not needed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [host]);
|
||||
return <>{line ?? fallback}</>;
|
||||
}
|
||||
|
||||
/** ShadowMount+'s card line: how many game images it has mounted. */
|
||||
export function SmpSummary({ host, fallback }: { host: string; fallback: ReactNode }) {
|
||||
const tr = useTr();
|
||||
const [mounted, setMounted] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setMounted(null);
|
||||
if (!host.trim()) return;
|
||||
smpStatus(mgmtAddr(host.trim()))
|
||||
.then((s) => {
|
||||
if (!cancelled) setMounted(s.mounted_images.length);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host]);
|
||||
if (mounted === null) return <>{fallback}</>;
|
||||
return <>{tr("hub_smp_summary", { n: mounted }, `Game images mounted: ${mounted}`)}</>;
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Boxes, Globe, HardDrive, Network, Package, Rocket } from "lucide-react";
|
||||
import { Boxes, Gamepad2, Rocket } from "lucide-react";
|
||||
import { Navigate, useSearchParams } from "react-router";
|
||||
|
||||
import TabbedShell, { type TabbedShellTab } from "../../layout/TabbedShell";
|
||||
import { useTr } from "../../state/lang";
|
||||
import CatalogPanel from "./CatalogPanel";
|
||||
import SendPanel from "./SendPanel";
|
||||
import PayloadManagerPanel from "./PayloadManagerPanel";
|
||||
import PortsPanel from "./PortsPanel";
|
||||
import NanoDnsScreen from "../NanoDns";
|
||||
import SmpPanel from "../Library/SmpPanel";
|
||||
import { ConnectionGate } from "../../components";
|
||||
import OnPs5Panel from "./hub/OnPs5Panel";
|
||||
import { useConnectionStore } from "../../state/connection";
|
||||
import { mgmtAddr } from "../../lib/addr";
|
||||
import { isTauriEnv } from "../../lib/tauriEnv";
|
||||
import { payloadTabsFor, type PayloadTabId } from "./payloadTabs";
|
||||
import {
|
||||
defaultPayloadTab,
|
||||
legacyPayloadsTarget,
|
||||
payloadTabsFor,
|
||||
type PayloadTabId,
|
||||
} from "./payloadTabs";
|
||||
|
||||
/**
|
||||
* Payloads screen — URL-routed tabs:
|
||||
@@ -20,24 +21,29 @@ import { payloadTabsFor, type PayloadTabId } from "./payloadTabs";
|
||||
* - **catalog**: curated GitHub-released third-party homebrew.
|
||||
* - **send**: arbitrary ELF/BIN/JS/LUA/JAR picker. Includes
|
||||
* playlists and recent-sends history.
|
||||
* - **shadowmount** / **nanodns**: those payloads' status and config on
|
||||
* the console. The only two the browser build shows (payloadTabsFor).
|
||||
* - **ps5** ("On your PS5"): a card per payload running or installed on the
|
||||
* console; ShadowMount+, nanoDNS, Payload Manager and HearBridge open
|
||||
* their own panel from the card. The only tab the browser build shows.
|
||||
*
|
||||
* (Historically split across /payloads and the old /send-payload route;
|
||||
* merged under ?tab=send for a cleaner sidebar. Legacy redirects remain
|
||||
* for old bookmarks.)
|
||||
*
|
||||
* The shell (URL contract + tablist + a11y + keyboard nav + page
|
||||
* header) lives in `layout/TabbedShell`; this file is just tab
|
||||
* metadata and a panel switch.
|
||||
* The integrations used to be tabs of their own; `?tab=manager|shadowmount|
|
||||
* nanodns` now opens their card, and `?tab=ports` moved to Processes.
|
||||
*/
|
||||
|
||||
type TabId = PayloadTabId;
|
||||
|
||||
export default function PayloadsScreen() {
|
||||
const tr = useTr();
|
||||
const host = useConnectionStore((state) => state.host);
|
||||
const available = payloadTabsFor(isTauriEnv());
|
||||
const [params] = useSearchParams();
|
||||
const host = useConnectionStore((s) => s.host);
|
||||
const payloadStatus = useConnectionStore((s) => s.payloadStatus);
|
||||
// "Connected" for picking the default tab: a console is set and not known to be down.
|
||||
// (Waiting for "up" would flip a bare /payloads from Catalog to On your PS5 mid-look.)
|
||||
const connected = !!host?.trim() && payloadStatus !== "down";
|
||||
const native = isTauriEnv();
|
||||
const legacy = legacyPayloadsTarget(params.get("tab"));
|
||||
if (legacy) return <Navigate to={legacy} replace />;
|
||||
|
||||
const available = payloadTabsFor(native);
|
||||
const allTabs: ReadonlyArray<TabbedShellTab<TabId>> = [
|
||||
{
|
||||
id: "catalog",
|
||||
@@ -62,47 +68,14 @@ export default function PayloadsScreen() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manager",
|
||||
icon: Package,
|
||||
key: "payloads_tab_manager",
|
||||
fallback: "Payload Manager",
|
||||
id: "ps5",
|
||||
icon: Gamepad2,
|
||||
key: "payloads_tab_ps5",
|
||||
fallback: "On your PS5",
|
||||
description: tr(
|
||||
"payloads_description_manager",
|
||||
"payloads_description_ps5",
|
||||
undefined,
|
||||
"The payloads kept on the PS5 by Payload Manager (itsPLK): launch or delete them, and choose which ones it loads at boot and in what order. Store a catalogue payload there from the Catalog tab.",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "shadowmount",
|
||||
icon: HardDrive,
|
||||
key: "payloads_tab_shadowmount",
|
||||
fallback: "ShadowMount+",
|
||||
description: tr(
|
||||
"payloads_description_shadowmount",
|
||||
undefined,
|
||||
"Inspect ShadowMount+ status, mounted game images, configuration, and diagnostics on the selected PS5.",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "nanodns",
|
||||
icon: Globe,
|
||||
key: "payloads_tab_nanodns",
|
||||
fallback: "nanoDNS",
|
||||
description: tr(
|
||||
"payloads_description_nanodns",
|
||||
undefined,
|
||||
"Configure the nanoDNS payload, verify its running version, and apply safe config migrations.",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ports",
|
||||
icon: Network,
|
||||
key: "payloads_tab_ports",
|
||||
fallback: "Ports",
|
||||
description: tr(
|
||||
"payloads_description_ports",
|
||||
undefined,
|
||||
"The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.",
|
||||
"The payloads running or installed on the selected PS5. Open a payload's web page, update it, stop it, or manage it.",
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -110,28 +83,7 @@ export default function PayloadsScreen() {
|
||||
|
||||
const renderPanel = (id: TabId) => {
|
||||
if (id === "send") return <SendPanel />;
|
||||
if (id === "manager") {
|
||||
return (
|
||||
<ConnectionGate require="host">
|
||||
<PayloadManagerPanel />
|
||||
</ConnectionGate>
|
||||
);
|
||||
}
|
||||
if (id === "nanodns") return <NanoDnsScreen />;
|
||||
if (id === "ports") {
|
||||
return (
|
||||
<ConnectionGate require="payload">
|
||||
<PortsPanel />
|
||||
</ConnectionGate>
|
||||
);
|
||||
}
|
||||
if (id === "shadowmount") {
|
||||
return (
|
||||
<ConnectionGate require="payload">
|
||||
<SmpPanel mgmtAddr={host?.trim() ? mgmtAddr(host.trim()) : null} />
|
||||
</ConnectionGate>
|
||||
);
|
||||
}
|
||||
if (id === "ps5") return <OnPs5Panel />;
|
||||
return <CatalogPanel />;
|
||||
};
|
||||
|
||||
@@ -142,6 +94,7 @@ export default function PayloadsScreen() {
|
||||
titleKey="payloads"
|
||||
titleFallback="Payloads"
|
||||
tabs={tabs}
|
||||
defaultTab={defaultPayloadTab(native, connected)}
|
||||
renderPanel={renderPanel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,12 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { NAV_ITEMS } from "../../layout/navItems";
|
||||
import { payloadTabsFor } from "./payloadTabs";
|
||||
import {
|
||||
defaultPayloadTab,
|
||||
legacyPayloadsTarget,
|
||||
payloadCardLink,
|
||||
payloadTabsFor,
|
||||
} from "./payloadTabs";
|
||||
|
||||
const read = (rel: string): string => readFileSync(new URL(rel, import.meta.url).pathname, "utf8");
|
||||
const app = read("../../App.tsx");
|
||||
@@ -11,18 +16,17 @@ const shim = read("../../lib/browserInvoke.ts");
|
||||
|
||||
describe("Payloads in the browser build", () => {
|
||||
it("keeps every tab on the desktop", () => {
|
||||
expect(payloadTabsFor(true)).toEqual([
|
||||
"catalog",
|
||||
"send",
|
||||
"manager",
|
||||
"shadowmount",
|
||||
"nanodns",
|
||||
"ports",
|
||||
]);
|
||||
expect(payloadTabsFor(true)).toEqual(["catalog", "send", "ps5"]);
|
||||
});
|
||||
|
||||
it("offers only the tabs that work through the engine", () => {
|
||||
expect(payloadTabsFor(false)).toEqual(["manager", "shadowmount", "nanodns", "ports"]);
|
||||
it("offers only On your PS5, which works through the engine", () => {
|
||||
expect(payloadTabsFor(false)).toEqual(["ps5"]);
|
||||
});
|
||||
|
||||
it("opens on On your PS5 once a console is connected", () => {
|
||||
expect(defaultPayloadTab(true, true)).toBe("ps5");
|
||||
expect(defaultPayloadTab(true, false)).toBe("catalog");
|
||||
expect(defaultPayloadTab(false, false)).toBe("ps5");
|
||||
});
|
||||
|
||||
it("is reachable: in the nav and not behind NativeOnlyRoute", () => {
|
||||
@@ -31,8 +35,49 @@ describe("Payloads in the browser build", () => {
|
||||
});
|
||||
|
||||
it("relies only on calls the browser build maps", () => {
|
||||
for (const cmd of ["smp_status", "fs_read_preview", "fs_write_bytes_run"]) {
|
||||
for (const cmd of [
|
||||
"smp_status",
|
||||
"fs_read_preview",
|
||||
"fs_write_bytes_run",
|
||||
"process_list_get",
|
||||
"process_kill_pid",
|
||||
"ps5_list_dir",
|
||||
]) {
|
||||
expect(shim, cmd).toContain(`case "${cmd}":`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("old Payloads links", () => {
|
||||
it("open the integration's card on On your PS5", () => {
|
||||
expect(legacyPayloadsTarget("manager")).toBe("/payloads?tab=ps5&open=payload-manager");
|
||||
expect(legacyPayloadsTarget("shadowmount")).toBe("/payloads?tab=ps5&open=shadowmountplus");
|
||||
expect(legacyPayloadsTarget("nanodns")).toBe("/payloads?tab=ps5&open=nanodns");
|
||||
});
|
||||
|
||||
it("send Ports to Processes & ports", () => {
|
||||
expect(legacyPayloadsTarget("ports")).toBe("/processes?tab=ports");
|
||||
});
|
||||
|
||||
it("leave current tabs alone", () => {
|
||||
for (const t of [null, "catalog", "send", "ps5", "nonsense"]) {
|
||||
expect(legacyPayloadsTarget(t)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("are routed in the app", () => {
|
||||
for (const [from, card] of [
|
||||
["/nanodns", "nanodns"],
|
||||
["/nano-dns", "nanodns"],
|
||||
["/shadowmount", "shadowmountplus"],
|
||||
["/payloads/manager", "payload-manager"],
|
||||
["/payloads/shadowmount", "shadowmountplus"],
|
||||
["/payloads/nanodns", "nanodns"],
|
||||
["/payloads/hearbridge", "hearbridge"],
|
||||
]) {
|
||||
expect(app, from).toContain(`path="${from}" element={<Navigate to={payloadCardLink("${card}")} replace />}`);
|
||||
}
|
||||
expect(app).toContain(`path="/payloads/ports" element={<Navigate to="/processes?tab=ports" replace />}`);
|
||||
expect(payloadCardLink("payload-manager")).toBe("/payloads?tab=ps5&open=payload-manager");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,46 @@
|
||||
export type PayloadTabId = "catalog" | "send" | "manager" | "shadowmount" | "nanodns" | "ports";
|
||||
export type PayloadTabId = "catalog" | "send" | "ps5";
|
||||
|
||||
const ALL_TABS: readonly PayloadTabId[] = [
|
||||
"catalog",
|
||||
"send",
|
||||
"manager",
|
||||
"shadowmount",
|
||||
"nanodns",
|
||||
"ports",
|
||||
];
|
||||
const ALL_TABS: readonly PayloadTabId[] = ["catalog", "send", "ps5"];
|
||||
|
||||
/**
|
||||
* The Payloads tabs this build can use.
|
||||
*
|
||||
* Catalog and Send download ELFs to this machine and write them to the
|
||||
* console's loader socket, which the browser build cannot do (payload_send and
|
||||
* payloads_* have no browser mapping). ShadowMount+ and nanoDNS only read and
|
||||
* write files on the console through the engine (smp_status, fs_read_preview,
|
||||
* fs_write_bytes_run), so the web UI keeps those two. Payload Manager talks to the
|
||||
* console's :8084 through the engine, so the web UI keeps it as well, and Ports is one engine
|
||||
* call (net.ports).
|
||||
* payloads_* have no browser mapping). "On your PS5" only talks to the console
|
||||
* through the engine (process list, file reads, the integrations' engine
|
||||
* routes), so it is the one tab the web UI keeps.
|
||||
*/
|
||||
export function payloadTabsFor(native: boolean): readonly PayloadTabId[] {
|
||||
return native
|
||||
? ALL_TABS
|
||||
: ALL_TABS.filter((t) => t === "manager" || t === "shadowmount" || t === "nanodns" || t === "ports");
|
||||
return native ? ALL_TABS : ALL_TABS.filter((t) => t === "ps5");
|
||||
}
|
||||
|
||||
/** The tab a bare /payloads opens on: what is on the console once one is connected,
|
||||
* the catalogue before that. */
|
||||
export function defaultPayloadTab(native: boolean, connected: boolean): PayloadTabId {
|
||||
if (!native || connected) return "ps5";
|
||||
return "catalog";
|
||||
}
|
||||
|
||||
/** The card id each retired per-integration tab now opens on "On your PS5". */
|
||||
const LEGACY_CARD: Record<string, string> = {
|
||||
manager: "payload-manager",
|
||||
shadowmount: "shadowmountplus",
|
||||
nanodns: "nanodns",
|
||||
hearbridge: "hearbridge",
|
||||
};
|
||||
|
||||
/** Where an old Payloads tab (`?tab=manager|shadowmount|nanodns|ports`) lives now, or null
|
||||
* for a current tab. Integrations became cards on "On your PS5"; Ports moved next to
|
||||
* Processes. */
|
||||
export function legacyPayloadsTarget(tab: string | null): string | null {
|
||||
if (!tab) return null;
|
||||
if (tab === "ports") return "/processes?tab=ports";
|
||||
const card = LEGACY_CARD[tab];
|
||||
return card ? payloadCardLink(card) : null;
|
||||
}
|
||||
|
||||
/** The link that opens "On your PS5" with one card's Manage panel expanded. */
|
||||
export function payloadCardLink(cardId: string): string {
|
||||
return `/payloads?tab=ps5&open=${encodeURIComponent(cardId)}`;
|
||||
}
|
||||
File renamed without changes.
@@ -1,12 +1,15 @@
|
||||
import { usePoll, useTransferBusy } from "../../lib/usePoll";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Cpu, RefreshCw, RotateCw, Skull, TriangleAlert, Layers } from "lucide-react";
|
||||
import { Cpu, RefreshCw, RotateCw, Skull, TriangleAlert, Layers, Network } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import TabbedShell, { type TabbedShellTab } from "../../layout/TabbedShell";
|
||||
import PortsPanel from "./PortsPanel";
|
||||
|
||||
import { useConnectionStore } from "../../state/connection";
|
||||
import { mgmtAddr, transferAddr } from "../../lib/addr";
|
||||
import { useDocumentVisible } from "../../lib/visibility";
|
||||
import {
|
||||
PageHeader,
|
||||
Button,
|
||||
EmptyState,
|
||||
ErrorCard,
|
||||
@@ -56,7 +59,63 @@ const RESTART_RELAUNCH_DELAY_MS = 1200;
|
||||
|
||||
type SortKey = "memory" | "pid" | "name" | "threads";
|
||||
|
||||
/**
|
||||
* "Processes & ports": the running processes and, beside them, the ports in use (a port is
|
||||
* found by binding it, so it can't be tied to a pid; the two lists stay separate).
|
||||
*/
|
||||
export default function ProcessesScreen() {
|
||||
const tr = useTr();
|
||||
const [count, setCount] = useState<number | undefined>(undefined);
|
||||
const [searchParams] = useSearchParams();
|
||||
const tab: ProcessesTabId = searchParams.get("tab") === "ports" ? "ports" : "processes";
|
||||
const tabs: ReadonlyArray<TabbedShellTab<ProcessesTabId>> = [
|
||||
{
|
||||
id: "processes",
|
||||
icon: Layers,
|
||||
key: "processes_title",
|
||||
fallback: "Processes",
|
||||
description: tr(
|
||||
"processes_subtitle_v2",
|
||||
undefined,
|
||||
"The programs running on the PS5 right now. You can stop or restart one; take care with system processes, since stopping the wrong one can freeze the console.",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ports",
|
||||
icon: Network,
|
||||
key: "payloads_tab_ports",
|
||||
fallback: "Ports",
|
||||
description: tr(
|
||||
"payloads_description_ports",
|
||||
undefined,
|
||||
"The ports in use on the PS5 and what usually listens on each, so you can tell which payload is where. Open the ones with a web page in your browser.",
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<TabbedShell
|
||||
idPrefix="processes"
|
||||
titleIcon={Layers}
|
||||
titleKey="processes_and_ports"
|
||||
titleFallback="Processes & ports"
|
||||
count={tab === "processes" ? count : undefined}
|
||||
tabs={tabs}
|
||||
renderPanel={(id) =>
|
||||
id === "ports" ? (
|
||||
<ConnectionGate require="payload">
|
||||
<PortsPanel />
|
||||
</ConnectionGate>
|
||||
) : (
|
||||
<ProcessesPanel onCount={setCount} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ProcessesTabId = "processes" | "ports";
|
||||
|
||||
function ProcessesPanel({ onCount }: { onCount: (n: number | undefined) => void }) {
|
||||
const tr = useTr();
|
||||
const host = useConnectionStore((s) => s.host);
|
||||
const payloadStatus = useConnectionStore((s) => s.payloadStatus);
|
||||
@@ -261,20 +320,12 @@ export default function ProcessesScreen() {
|
||||
}, [procs, showSystem, sortKey]);
|
||||
|
||||
const systemCount = procs.filter((p) => p.kind === "system").length;
|
||||
useEffect(() => {
|
||||
onCount(procs.length || undefined);
|
||||
}, [procs.length, onCount]);
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<PageHeader
|
||||
icon={Layers}
|
||||
title={tr("processes_title", undefined, "Processes")}
|
||||
count={procs.length || undefined}
|
||||
description={tr(
|
||||
"processes_subtitle_v2",
|
||||
undefined,
|
||||
"The programs running on the PS5 right now. You can stop or restart one; take care with system processes, since stopping the wrong one can freeze the console.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<>
|
||||
<ConnectionGate require="payload">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Toggle
|
||||
@@ -447,7 +498,7 @@ export default function ProcessesScreen() {
|
||||
</Modal>
|
||||
)}
|
||||
</ConnectionGate>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
//! HearBridge (X-F1REBALL-X/HearBridge-PS5): Bluetooth headset audio for the PS5, with its
|
||||
//! own small HTTP API. The app reads and drives it through the engine because the page
|
||||
//! sends no CORS headers, so a browser page on another origin can't call it directly.
|
||||
//!
|
||||
//! Its API, read from its source (src/http.c):
|
||||
//! HTTP/1.1, `Connection: close`, on TCP 8090; when that port is taken it tries the next
|
||||
//! five (8091..8095).
|
||||
//! GET /api/status one JSON object: version, token, state, device, gain_pct, muted,
|
||||
//! codec, latency{target_ms,estimate_ms}, battery{pct}, link{score,
|
||||
//! rssi}, night{on}, lat_extra{auto}, game{id,name,profile}, events[]…
|
||||
//! GET /api/saved {"devices":[{addr,name,kind,current}]} paired headsets
|
||||
//! GET /api/devices {"version":1,"devices":[{index,addr,name,kind,rssi}]} last scan
|
||||
//! POST /api/volume?pct=0..500 /api/mute?on=0|1 /api/night?on=0|1
|
||||
//! /api/latency?auto=0|1 or ?ms=40..400 /api/reconnect /api/stop
|
||||
//! /api/select?index=N or ?addr=XX:XX:.. /api/scan?user=1 (without user=1 a
|
||||
//! scan is ignored: an inquiry holds the radio the DualSense shares)
|
||||
//! Every POST needs header `X-HB-Token` with the token from /api/status (403 otherwise).
|
||||
//!
|
||||
//! Nothing is cached on disk. The port a console's HearBridge answered on is remembered in
|
||||
//! memory so the next call tries it first; the token is read fresh for every change.
|
||||
//!
|
||||
//! Plain HTTP over a TcpStream, like `payload_manager`: the Android engine has no HTTP
|
||||
//! client crate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// HearBridge's port, then the five it falls back to.
|
||||
pub const HEARBRIDGE_PORTS: [u16; 6] = [8090, 8091, 8092, 8093, 8094, 8095];
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Finding HearBridge (every port, if need be) never takes longer than this.
|
||||
const FIND_DEADLINE: Duration = Duration::from_secs(8);
|
||||
/// /api/status is at most 32 KiB (its own buffer); saved/devices at most 8 KiB.
|
||||
const MAX_RESPONSE: usize = 256 * 1024;
|
||||
|
||||
fn port_cache() -> &'static Mutex<HashMap<String, u16>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, u16>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A HearBridge that answered: its port, its status (without the token) and the token.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Found {
|
||||
pub port: u16,
|
||||
pub status: Value,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Find HearBridge on `host` and read its status.
|
||||
pub fn status(host: &str) -> Result<Found, String> {
|
||||
find_on(host, &HEARBRIDGE_PORTS)
|
||||
}
|
||||
|
||||
fn find_on(host: &str, ports: &[u16]) -> Result<Found, String> {
|
||||
check_host(host)?;
|
||||
let deadline = Instant::now() + FIND_DEADLINE;
|
||||
let cached = port_cache().lock().ok().and_then(|c| c.get(host).copied());
|
||||
// The port it answered on last time first, then HearBridge's own order.
|
||||
let mut order: Vec<u16> = Vec::with_capacity(ports.len());
|
||||
if let Some(p) = cached.filter(|p| ports.contains(p)) {
|
||||
order.push(p);
|
||||
}
|
||||
order.extend(ports.iter().copied().filter(|p| Some(*p) != cached));
|
||||
let mut last = String::new();
|
||||
for port in order {
|
||||
if Instant::now() >= deadline {
|
||||
last = format!("gave up after {}s", FIND_DEADLINE.as_secs());
|
||||
break;
|
||||
}
|
||||
match status_at(host, port, deadline) {
|
||||
Ok(found) => {
|
||||
if let Ok(mut c) = port_cache().lock() {
|
||||
c.insert(host.to_string(), port);
|
||||
}
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => {
|
||||
last = e.msg;
|
||||
// Nothing answered at all (the console is off or unreachable): the other
|
||||
// ports are on the same host, so they would only time out the same way.
|
||||
if e.connect_timed_out {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut c) = port_cache().lock() {
|
||||
c.remove(host);
|
||||
}
|
||||
let range = match (ports.first(), ports.last()) {
|
||||
(Some(a), Some(b)) if a != b => format!("{a}-{b}"),
|
||||
(Some(a), _) => a.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Err(format!(
|
||||
"HearBridge didn't answer on {host} port {range} ({last})"
|
||||
))
|
||||
}
|
||||
|
||||
/// A console address the routes accept: no whitespace, control characters or URL syntax
|
||||
/// that could change where the request goes or what it says.
|
||||
pub fn check_host(host: &str) -> Result<(), String> {
|
||||
let bad = host.is_empty()
|
||||
|| host.len() > 255
|
||||
|| host.chars().any(|c| {
|
||||
c.is_whitespace() || c.is_control() || matches!(c, '/' | '\\' | '?' | '#' | '@')
|
||||
});
|
||||
if bad {
|
||||
return Err(format!("not a console address: {host:?}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_at(host: &str, port: u16, deadline: Instant) -> Result<Found, HttpErr> {
|
||||
let (code, body) = request(host, port, "GET", "/api/status", None, deadline)?;
|
||||
if code != 200 {
|
||||
return Err(format!(":{port} answered HTTP {code}").into());
|
||||
}
|
||||
parse_status(&body)
|
||||
.map(|(status, token)| Found {
|
||||
port,
|
||||
status,
|
||||
token,
|
||||
})
|
||||
.map_err(HttpErr::from)
|
||||
}
|
||||
|
||||
/// The status object without its token, and the token. Anything that isn't a JSON object
|
||||
/// with a `version` string is not HearBridge (another service on that port).
|
||||
fn parse_status(body: &[u8]) -> Result<(Value, String), String> {
|
||||
let mut v: Value =
|
||||
serde_json::from_slice(body).map_err(|_| "not HearBridge (no JSON status)".to_string())?;
|
||||
let obj = v
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "not HearBridge (status is not an object)".to_string())?;
|
||||
if !obj.get("version").is_some_and(Value::is_string) {
|
||||
return Err("not HearBridge (no version in its status)".into());
|
||||
}
|
||||
let token = match obj.remove("token") {
|
||||
Some(Value::String(t)) => t,
|
||||
_ => String::new(),
|
||||
};
|
||||
Ok((v, token))
|
||||
}
|
||||
|
||||
/// Which list to read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceList {
|
||||
/// Paired headsets (`/api/saved`).
|
||||
Saved,
|
||||
/// The last scan's results (`/api/devices`).
|
||||
Scanned,
|
||||
}
|
||||
|
||||
/// `{"devices":[…]}` from HearBridge; an empty list when it has none or sent something else.
|
||||
pub fn devices(host: &str, which: DeviceList) -> Result<Value, String> {
|
||||
devices_on(host, which, &HEARBRIDGE_PORTS)
|
||||
}
|
||||
|
||||
fn devices_on(host: &str, which: DeviceList, ports: &[u16]) -> Result<Value, String> {
|
||||
let found = find_on(host, ports)?;
|
||||
let path = match which {
|
||||
DeviceList::Saved => "/api/saved",
|
||||
DeviceList::Scanned => "/api/devices",
|
||||
};
|
||||
let (code, body) = request(host, found.port, "GET", path, None, io_deadline())?;
|
||||
if code != 200 {
|
||||
return Err(format!("HearBridge could not list devices (HTTP {code})"));
|
||||
}
|
||||
Ok(parse_devices(&body))
|
||||
}
|
||||
|
||||
fn parse_devices(body: &[u8]) -> Value {
|
||||
let list = serde_json::from_slice::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("devices").filter(|d| d.is_array()).cloned())
|
||||
.unwrap_or_else(|| Value::Array(Vec::new()));
|
||||
serde_json::json!({ "devices": list })
|
||||
}
|
||||
|
||||
/// An action's parameters, as the app sends them.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ActionParams {
|
||||
pub pct: Option<i64>,
|
||||
pub on: Option<bool>,
|
||||
pub index: Option<i64>,
|
||||
pub addr: Option<String>,
|
||||
pub auto: Option<bool>,
|
||||
pub ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// The changes the app makes. Pairing, EQ, backups and the rest stay on HearBridge's page.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Volume(u16),
|
||||
Mute(bool),
|
||||
Reconnect,
|
||||
SelectIndex(u8),
|
||||
SelectAddr(String),
|
||||
Scan,
|
||||
Night(bool),
|
||||
LatencyAuto(bool),
|
||||
LatencyMs(u16),
|
||||
Stop,
|
||||
}
|
||||
|
||||
fn is_mac(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
b.len() == 17
|
||||
&& b.iter().enumerate().all(|(i, c)| {
|
||||
if i % 3 == 2 {
|
||||
*c == b':'
|
||||
} else {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// The action `name` with its parameters checked against the ranges HearBridge takes.
|
||||
/// Anything else is refused here, so only these requests ever reach the console.
|
||||
pub fn parse(name: &str, p: &ActionParams) -> Result<Action, String> {
|
||||
let need_on = || p.on.ok_or_else(|| format!("{name} needs on"));
|
||||
Ok(match name {
|
||||
"volume" => match p.pct {
|
||||
Some(v) if (0..=500).contains(&v) => Action::Volume(v as u16),
|
||||
_ => return Err("volume needs pct 0-500".into()),
|
||||
},
|
||||
"mute" => Action::Mute(need_on()?),
|
||||
"night" => Action::Night(need_on()?),
|
||||
"reconnect" => Action::Reconnect,
|
||||
"scan" => Action::Scan,
|
||||
"stop" => Action::Stop,
|
||||
"select" => match (&p.addr, p.index) {
|
||||
(Some(a), _) if is_mac(a) => Action::SelectAddr(a.to_ascii_uppercase()),
|
||||
(Some(_), _) => return Err("select needs a Bluetooth address".into()),
|
||||
(None, Some(i)) if (0..64).contains(&i) => Action::SelectIndex(i as u8),
|
||||
_ => return Err("select needs index 0-63 or addr".into()),
|
||||
},
|
||||
"latency" => match (p.auto, p.ms) {
|
||||
(Some(a), _) => Action::LatencyAuto(a),
|
||||
(None, Some(ms)) if (40..=400).contains(&ms) => Action::LatencyMs(ms as u16),
|
||||
_ => return Err("latency needs auto or ms 40-400".into()),
|
||||
},
|
||||
other => return Err(format!("unknown HearBridge action {other:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn path(&self) -> String {
|
||||
let b = |on: bool| if on { 1 } else { 0 };
|
||||
match self {
|
||||
Action::Volume(v) => format!("/api/volume?pct={v}"),
|
||||
Action::Mute(on) => format!("/api/mute?on={}", b(*on)),
|
||||
Action::Night(on) => format!("/api/night?on={}", b(*on)),
|
||||
Action::Reconnect => "/api/reconnect".into(),
|
||||
Action::Scan => "/api/scan?user=1".into(),
|
||||
Action::Stop => "/api/stop".into(),
|
||||
Action::SelectIndex(i) => format!("/api/select?index={i}"),
|
||||
Action::SelectAddr(a) => format!("/api/select?addr={a}"),
|
||||
Action::LatencyAuto(a) => format!("/api/latency?auto={}", b(*a)),
|
||||
Action::LatencyMs(ms) => format!("/api/latency?ms={ms}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `action` to HearBridge on `host`. Returns its answer: the new status (token removed)
|
||||
/// for most actions, `{"ok":1}` for select/scan/reconnect.
|
||||
pub fn act(host: &str, action: &Action) -> Result<Value, String> {
|
||||
act_on(host, action, &HEARBRIDGE_PORTS)
|
||||
}
|
||||
|
||||
fn act_on(host: &str, action: &Action, ports: &[u16]) -> Result<Value, String> {
|
||||
let path = action.path();
|
||||
let mut found = find_on(host, ports)?;
|
||||
for attempt in 0..2 {
|
||||
if found.token.is_empty() {
|
||||
return Err("HearBridge gave no token; it may be too old for changes from here".into());
|
||||
}
|
||||
let (code, body) = request(
|
||||
host,
|
||||
found.port,
|
||||
"POST",
|
||||
&path,
|
||||
Some(&found.token),
|
||||
io_deadline(),
|
||||
)?;
|
||||
// 403: the token changed (HearBridge restarted between the read and the change).
|
||||
if code == 403 && attempt == 0 {
|
||||
found = match status_at(host, found.port, io_deadline()) {
|
||||
Ok(f) => f,
|
||||
Err(_) => find_on(host, ports)?,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if code != 200 {
|
||||
let msg = String::from_utf8_lossy(&body);
|
||||
return Err(format!(
|
||||
"HearBridge refused {path} (HTTP {code}: {})",
|
||||
msg.trim()
|
||||
));
|
||||
}
|
||||
let mut v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("token");
|
||||
}
|
||||
return Ok(v);
|
||||
}
|
||||
Err("HearBridge refused the token".into())
|
||||
}
|
||||
|
||||
/// A failed request, and whether it failed because nothing accepted the connection in time.
|
||||
#[derive(Debug)]
|
||||
struct HttpErr {
|
||||
msg: String,
|
||||
connect_timed_out: bool,
|
||||
}
|
||||
|
||||
impl From<String> for HttpErr {
|
||||
fn from(msg: String) -> Self {
|
||||
HttpErr {
|
||||
msg,
|
||||
connect_timed_out: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpErr> for String {
|
||||
fn from(e: HttpErr) -> Self {
|
||||
e.msg
|
||||
}
|
||||
}
|
||||
|
||||
/// The deadline for one request outside discovery.
|
||||
fn io_deadline() -> Instant {
|
||||
Instant::now() + IO_TIMEOUT
|
||||
}
|
||||
|
||||
fn request(
|
||||
host: &str,
|
||||
port: u16,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
deadline: Instant,
|
||||
) -> Result<(u16, Vec<u8>), HttpErr> {
|
||||
let left = || deadline.saturating_duration_since(Instant::now());
|
||||
let addr = crate::payload_lifecycle::join_host_port(host, port);
|
||||
let sock = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| format!("resolve {addr}: {e}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("resolve {addr}: no address"))?;
|
||||
let connect_for = CONNECT_TIMEOUT.min(left());
|
||||
if connect_for.is_zero() {
|
||||
return Err(format!("no time left to connect to {addr}").into());
|
||||
}
|
||||
let mut stream = TcpStream::connect_timeout(&sock, connect_for).map_err(|e| HttpErr {
|
||||
connect_timed_out: e.kind() == std::io::ErrorKind::TimedOut,
|
||||
msg: format!("connect {addr}: {e}"),
|
||||
})?;
|
||||
let io_for = IO_TIMEOUT.min(left()).max(Duration::from_millis(1));
|
||||
stream.set_read_timeout(Some(io_for)).ok();
|
||||
stream.set_write_timeout(Some(io_for)).ok();
|
||||
stream
|
||||
.write_all(request_head(method, path, &addr, token).as_bytes())
|
||||
.map_err(|e| format!("send to {addr}: {e}"))?;
|
||||
// Read to the end, but never past the deadline: a server that trickles bytes would
|
||||
// otherwise reset the per-read timeout forever.
|
||||
let mut resp = Vec::new();
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("{addr} took too long to answer").into());
|
||||
}
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
resp.extend_from_slice(&buf[..n]);
|
||||
if resp.len() > MAX_RESPONSE {
|
||||
return Err(format!("{addr} sent more than {MAX_RESPONSE} bytes").into());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(format!("read from {addr}: {e}").into()),
|
||||
}
|
||||
}
|
||||
parse_response(&resp).ok_or_else(|| format!("{addr} sent a response that is not HTTP").into())
|
||||
}
|
||||
|
||||
/// The request line and headers. The User-Agent starts with "curl" on purpose: HearBridge
|
||||
/// takes a status read from anything else as its page being open in a browser
|
||||
/// (`page_seen_ms`), which may start a link wake meant for someone looking at that page
|
||||
/// (http.c `/api/status`, main.c `lw_tick`). It does not avoid everything: every status read
|
||||
/// still counts in `status_polls`, which lets HearBridge re-query the headset's volume while
|
||||
/// reads keep coming, so the app polls slowly unless the Manage panel is open.
|
||||
fn request_head(method: &str, path: &str, addr: &str, token: Option<&str>) -> String {
|
||||
let mut head = format!(
|
||||
"{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: curl-compatible ps5upload\r\n"
|
||||
);
|
||||
if let Some(t) = token {
|
||||
head.push_str(&format!("X-HB-Token: {t}\r\n"));
|
||||
}
|
||||
if method == "POST" {
|
||||
head.push_str("Content-Length: 0\r\n");
|
||||
}
|
||||
head.push_str("\r\n");
|
||||
head
|
||||
}
|
||||
|
||||
fn parse_response(resp: &[u8]) -> Option<(u16, Vec<u8>)> {
|
||||
let split = resp.windows(4).position(|w| w == b"\r\n\r\n")?;
|
||||
let head = std::str::from_utf8(&resp[..split]).ok()?;
|
||||
let status: u16 = head
|
||||
.lines()
|
||||
.next()?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()?;
|
||||
Some((status, resp[split + 4..].to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
/// One request as the fake server saw it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Seen {
|
||||
line: String,
|
||||
token: Option<String>,
|
||||
ua: Option<String>,
|
||||
}
|
||||
|
||||
/// A fake HearBridge on an ephemeral port: answers each request with `reply(line)`.
|
||||
fn fake(
|
||||
reply: impl Fn(&str, Option<&str>) -> (u16, String) + Send + Sync + 'static,
|
||||
) -> (u16, Arc<Mutex<Vec<Seen>>>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen2 = seen.clone();
|
||||
let reply = Arc::new(reply);
|
||||
thread::spawn(move || {
|
||||
for s in l.incoming() {
|
||||
let Ok(mut s) = s else { continue };
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut got = Vec::new();
|
||||
while !got.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => got.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&got).to_string();
|
||||
let line = text.lines().next().unwrap_or("").to_string();
|
||||
let header = |name: &str| {
|
||||
text.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.eq_ignore_ascii_case(name).then(|| v.trim().to_string())
|
||||
})
|
||||
};
|
||||
let token = header("X-HB-Token");
|
||||
seen2.lock().unwrap().push(Seen {
|
||||
line: line.clone(),
|
||||
token: token.clone(),
|
||||
ua: header("User-Agent"),
|
||||
});
|
||||
let (code, body) = reply(&line, token.as_deref());
|
||||
let _ = write!(
|
||||
s,
|
||||
"HTTP/1.1 {code} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
});
|
||||
(port, seen)
|
||||
}
|
||||
|
||||
fn closed_port() -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
const STATUS: &str = r#"{"version":"1.9.0","token":"abc123","connected":1,"state":"streaming","device":"WH-1000XM4","gain_pct":120,"muted":0,"codec":"SBC","latency":{"target_ms":120,"estimate_ms":180},"battery":{"status":"ok","level":3,"pct":80},"link":{"score":92,"rssi":-50},"events":["Connected"]}"#;
|
||||
|
||||
#[test]
|
||||
fn reads_the_status_without_handing_out_the_token() {
|
||||
let (port, seen) = fake(|_, _| (200, STATUS.into()));
|
||||
let f = find_on("127.0.0.1", &[port]).unwrap();
|
||||
assert_eq!(f.port, port);
|
||||
assert_eq!(f.status["device"], "WH-1000XM4");
|
||||
assert!(f.status.get("token").is_none());
|
||||
assert_eq!(f.token, "abc123");
|
||||
let s = seen.lock().unwrap();
|
||||
assert_eq!(s[0].line, "GET /api/status HTTP/1.1");
|
||||
// Not counted as HearBridge's page being open (see request_head).
|
||||
assert!(s[0].ua.as_deref().unwrap().starts_with("curl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_an_older_status_with_few_fields() {
|
||||
let (status, token) = parse_status(br#"{"version":"0.3"}"#).unwrap();
|
||||
assert_eq!(status["version"], "0.3");
|
||||
assert_eq!(token, "");
|
||||
assert!(parse_status(b"<html>").is_err());
|
||||
assert!(parse_status(br#"{"ok":1}"#).is_err());
|
||||
assert!(parse_status(br#"[1]"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_next_port_like_hearbridge_does() {
|
||||
let dead = closed_port();
|
||||
// Something else on the second port: answers, but isn't HearBridge.
|
||||
let (other, _) = fake(|_, _| (200, "<html>hi</html>".into()));
|
||||
let (hb, _) = fake(|_, _| (200, STATUS.into()));
|
||||
let host = "127.0.0.1";
|
||||
let f = find_on(host, &[dead, other, hb]).unwrap();
|
||||
assert_eq!(f.port, hb);
|
||||
let err = find_on(host, &[dead]).unwrap_err();
|
||||
assert!(err.contains("HearBridge didn't answer"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_the_token_with_every_change() {
|
||||
let (port, seen) = fake(|line, token| {
|
||||
if line.starts_with("GET /api/status") {
|
||||
(200, STATUS.into())
|
||||
} else if token == Some("abc123") {
|
||||
(
|
||||
200,
|
||||
r#"{"version":"1.9.0","token":"abc123","gain_pct":200}"#.into(),
|
||||
)
|
||||
} else {
|
||||
(403, r#"{"error":"token"}"#.into())
|
||||
}
|
||||
});
|
||||
let v = act_on("127.0.0.1", &Action::Volume(200), &[port]).unwrap();
|
||||
assert_eq!(v["gain_pct"], 200);
|
||||
assert!(v.get("token").is_none());
|
||||
let s = seen.lock().unwrap();
|
||||
let post = s.iter().find(|r| r.line.starts_with("POST")).unwrap();
|
||||
assert_eq!(post.line, "POST /api/volume?pct=200 HTTP/1.1");
|
||||
assert_eq!(post.token.as_deref(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rereads_the_token_once_when_hearbridge_restarted() {
|
||||
let calls = Arc::new(Mutex::new(0));
|
||||
let c2 = calls.clone();
|
||||
let (port, _) = fake(move |line, token| {
|
||||
if line.starts_with("GET /api/status") {
|
||||
let mut n = c2.lock().unwrap();
|
||||
*n += 1;
|
||||
// First read hands out a token that is stale by the time it is used.
|
||||
let t = if *n == 1 { "old" } else { "new" };
|
||||
(200, format!(r#"{{"version":"1.9.0","token":"{t}"}}"#))
|
||||
} else if token == Some("new") {
|
||||
(200, r#"{"ok":1}"#.into())
|
||||
} else {
|
||||
(403, r#"{"error":"token"}"#.into())
|
||||
}
|
||||
});
|
||||
let v = act_on("127.0.0.1", &Action::Reconnect, &[port]).unwrap();
|
||||
assert_eq!(v["ok"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_token_is_a_clear_error() {
|
||||
let (port, _) = fake(|_, _| (200, r#"{"version":"0.3"}"#.into()));
|
||||
let e = act_on("127.0.0.1", &Action::Stop, &[port]).unwrap_err();
|
||||
assert!(e.contains("no token"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_devices_and_survives_garbage() {
|
||||
let (port, _) = fake(|line, _| {
|
||||
if line.starts_with("GET /api/status") {
|
||||
(200, STATUS.into())
|
||||
} else if line.starts_with("GET /api/saved") {
|
||||
(
|
||||
200,
|
||||
r#"{"devices":[{"addr":"AA:BB:CC:DD:EE:FF","name":"Buds","kind":"audio","current":1}]}"#
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
(200, "not json".into())
|
||||
}
|
||||
});
|
||||
let saved = devices_on("127.0.0.1", DeviceList::Saved, &[port]).unwrap();
|
||||
assert_eq!(saved["devices"][0]["name"], "Buds");
|
||||
let scanned = devices_on("127.0.0.1", DeviceList::Scanned, &[port]).unwrap();
|
||||
assert_eq!(scanned, serde_json::json!({ "devices": [] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_console_addresses_that_are_not_one() {
|
||||
for ok in ["192.168.86.99", "ps5.local", "[fe80::1]", "10.0.0.2:9120"] {
|
||||
assert!(check_host(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
"1.2.3.4 x",
|
||||
"1.2.3.4\r\nX: y",
|
||||
"1.2.3.4/api",
|
||||
"a?b",
|
||||
"u@h",
|
||||
"a\\b",
|
||||
] {
|
||||
assert!(check_host(bad).is_err(), "{bad:?}");
|
||||
assert!(find_on(bad, &[1]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_console_is_not_tried_on_every_port() {
|
||||
// A blackholed address: the first connect times out (or the network says
|
||||
// unreachable at once); either way the other five ports are not waited on.
|
||||
let t = Instant::now();
|
||||
let e = find_on("10.255.255.1", &HEARBRIDGE_PORTS).unwrap_err();
|
||||
assert!(
|
||||
t.elapsed() < Duration::from_secs(4),
|
||||
"{:?} {e}",
|
||||
t.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_never_answers_is_given_up_on() {
|
||||
// Accepts and then says nothing: the read times out, discovery moves on.
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let silent = l.local_addr().unwrap().port();
|
||||
thread::spawn(move || {
|
||||
let _held: Vec<_> = l.incoming().take(4).collect();
|
||||
thread::sleep(Duration::from_secs(30));
|
||||
});
|
||||
let (hb, _) = fake(|_, _| (200, STATUS.into()));
|
||||
let t = Instant::now();
|
||||
assert_eq!(find_on("127.0.0.1", &[silent, hb]).unwrap().port, hb);
|
||||
assert!(t.elapsed() <= FIND_DEADLINE + Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_allowlisted_actions_with_sane_values_pass() {
|
||||
let p = |f: fn(&mut ActionParams)| {
|
||||
let mut a = ActionParams::default();
|
||||
f(&mut a);
|
||||
a
|
||||
};
|
||||
assert_eq!(
|
||||
Action::parse("volume", &p(|a| a.pct = Some(500)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/volume?pct=500"
|
||||
);
|
||||
assert!(Action::parse("volume", &p(|a| a.pct = Some(501))).is_err());
|
||||
assert!(Action::parse("volume", &p(|_| {})).is_err());
|
||||
assert_eq!(
|
||||
Action::parse("mute", &p(|a| a.on = Some(true)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/mute?on=1"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("scan", &p(|_| {})).unwrap().path(),
|
||||
"/api/scan?user=1"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("select", &p(|a| a.index = Some(3)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/select?index=3"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("select", &p(|a| a.addr = Some("aa:bb:cc:dd:ee:ff".into())))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/select?addr=AA:BB:CC:DD:EE:FF"
|
||||
);
|
||||
assert!(Action::parse("select", &p(|a| a.addr = Some("x&y=1".into()))).is_err());
|
||||
assert!(Action::parse("select", &p(|a| a.index = Some(64))).is_err());
|
||||
assert_eq!(
|
||||
Action::parse("latency", &p(|a| a.auto = Some(false)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/latency?auto=0"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("latency", &p(|a| a.ms = Some(40)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/latency?ms=40"
|
||||
);
|
||||
assert!(Action::parse("latency", &p(|a| a.ms = Some(39))).is_err());
|
||||
for refused in ["forget", "eq", "restore", "backup", "hci", "../stop", ""] {
|
||||
assert!(Action::parse(refused, &p(|_| {})).is_err(), "{refused}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod fs_ops;
|
||||
pub mod fw_spoof;
|
||||
pub mod game_meta;
|
||||
pub mod health;
|
||||
pub mod hearbridge;
|
||||
pub mod hw;
|
||||
pub mod installer_client;
|
||||
pub mod local_image;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! `/api/ps5/hearbridge/*` — the console's HearBridge (Bluetooth headset audio, HTTP on
|
||||
//! :8090..8095) as the app sees it: its status, its saved and scanned headsets, and the
|
||||
//! few changes the app makes. Every call is a plain HTTP request to the console
|
||||
//! (`ps5upload_core::hearbridge`); its token never leaves the engine.
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use ps5upload_core::hearbridge as hb;
|
||||
|
||||
/// The routes, for any router state (the handlers need none).
|
||||
pub(crate) fn routes<S: Clone + Send + Sync + 'static>() -> Router<S> {
|
||||
Router::new()
|
||||
.route("/api/ps5/hearbridge/status", get(status))
|
||||
.route("/api/ps5/hearbridge/saved", get(saved))
|
||||
.route("/api/ps5/hearbridge/devices", get(devices))
|
||||
.route("/api/ps5/hearbridge/action", post(action))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct HostParams {
|
||||
#[serde(alias = "addr")]
|
||||
host: String,
|
||||
}
|
||||
|
||||
fn bad_gateway(e: String) -> Response {
|
||||
crate::json_err(StatusCode::BAD_GATEWAY, e).into_response()
|
||||
}
|
||||
|
||||
/// The console's address, or a 400 for something that isn't one (whitespace, control
|
||||
/// characters, URL syntax), refused before anything is sent anywhere.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn host(h: &str) -> Result<String, Response> {
|
||||
let h = h.trim();
|
||||
hb::check_host(h).map_err(|e| crate::json_err(StatusCode::BAD_REQUEST, e).into_response())?;
|
||||
Ok(ps5upload_core::events::console_host(h))
|
||||
}
|
||||
|
||||
/// GET /api/ps5/hearbridge/status?host= — `{ok, present:true, port, status}`, or
|
||||
/// `{ok, present:false, error}` when nothing answers on 8090..8095.
|
||||
pub(crate) async fn status(Query(q): Query<HostParams>) -> Response {
|
||||
let h = match host(&q.host) {
|
||||
Ok(h) => h,
|
||||
Err(r) => return r,
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || hb::status(&h)).await {
|
||||
Ok(Ok(f)) => Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"present": true,
|
||||
"port": f.port,
|
||||
"status": f.status,
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(Err(e)) => {
|
||||
Json(serde_json::json!({ "ok": true, "present": false, "error": e })).into_response()
|
||||
}
|
||||
Err(e) => bad_gateway(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(q: HostParams, which: hb::DeviceList) -> Response {
|
||||
let h = match host(&q.host) {
|
||||
Ok(h) => h,
|
||||
Err(r) => return r,
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || hb::devices(&h, which)).await {
|
||||
Ok(Ok(v)) => {
|
||||
Json(serde_json::json!({ "ok": true, "devices": v["devices"] })).into_response()
|
||||
}
|
||||
Ok(Err(e)) => bad_gateway(e),
|
||||
Err(e) => bad_gateway(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/ps5/hearbridge/saved?host= — the paired headsets `{ok, devices}`.
|
||||
pub(crate) async fn saved(Query(q): Query<HostParams>) -> Response {
|
||||
list(q, hb::DeviceList::Saved).await
|
||||
}
|
||||
|
||||
/// GET /api/ps5/hearbridge/devices?host= — the last scan's results `{ok, devices}`.
|
||||
pub(crate) async fn devices(Query(q): Query<HostParams>) -> Response {
|
||||
list(q, hb::DeviceList::Scanned).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ActionReq {
|
||||
/// The console (`addr` here is the headset's Bluetooth address, see `ActionParams`).
|
||||
host: String,
|
||||
action: String,
|
||||
#[serde(flatten)]
|
||||
params: hb::ActionParams,
|
||||
}
|
||||
|
||||
/// POST /api/ps5/hearbridge/action `{host, action, pct?|on?|index?|addr?|auto?|ms?}` —
|
||||
/// one of volume, mute, reconnect, select, scan, night, latency, stop. Answers
|
||||
/// `{ok, result}` with HearBridge's reply (its new status, token removed).
|
||||
pub(crate) async fn action(Json(req): Json<ActionReq>) -> Response {
|
||||
let a = match hb::Action::parse(&req.action, &req.params) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return crate::json_err(StatusCode::BAD_REQUEST, e).into_response(),
|
||||
};
|
||||
let h = match host(&req.host) {
|
||||
Ok(h) => h,
|
||||
Err(r) => return r,
|
||||
};
|
||||
crate::log_info!("hearbridge {}: host={h}", req.action);
|
||||
match tokio::task::spawn_blocking(move || hb::act(&h, &a)).await {
|
||||
Ok(Ok(v)) => Json(serde_json::json!({ "ok": true, "result": v })).into_response(),
|
||||
Ok(Err(e)) => bad_gateway(e),
|
||||
Err(e) => bad_gateway(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// Serve the routes on an ephemeral port and send one raw request; the status code and
|
||||
/// body come back.
|
||||
async fn call(method: &str, path: &str, body: Option<&str>) -> (u16, String) {
|
||||
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(l, routes::<()>()).await.unwrap();
|
||||
});
|
||||
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body.unwrap_or("");
|
||||
let req = format!(
|
||||
"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
s.write_all(req.as_bytes()).await.unwrap();
|
||||
let mut out = Vec::new();
|
||||
s.read_to_end(&mut out).await.unwrap();
|
||||
let text = String::from_utf8_lossy(&out).to_string();
|
||||
let code = text
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|c| c.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
|
||||
(code, body)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_action_is_refused_before_anything_is_sent() {
|
||||
// A blackholed console: any request to it would take seconds, so a fast 400 shows
|
||||
// nothing was tried.
|
||||
let t = Instant::now();
|
||||
let (code, body) = call(
|
||||
"POST",
|
||||
"/api/ps5/hearbridge/action",
|
||||
Some(r#"{"host":"10.255.255.1","action":"forget","addr":"AA:BB:CC:DD:EE:FF"}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 400, "{body}");
|
||||
assert!(body.contains("unknown HearBridge action"), "{body}");
|
||||
let (code, body) = call(
|
||||
"POST",
|
||||
"/api/ps5/hearbridge/action",
|
||||
Some(r#"{"host":"10.255.255.1","action":"volume","pct":9000}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 400, "{body}");
|
||||
assert!(t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_host_that_is_not_an_address_is_refused() {
|
||||
let (code, _) = call(
|
||||
"POST",
|
||||
"/api/ps5/hearbridge/action",
|
||||
Some(r#"{"host":"1.2.3.4/x","action":"stop"}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 400);
|
||||
// `addr` is accepted for `host` on the reads, and checked the same way.
|
||||
let (code, body) = call("GET", "/api/ps5/hearbridge/status?addr=1.2.3.4%20x", None).await;
|
||||
assert_eq!(code, 400, "{body}");
|
||||
let (code, _) = call("GET", "/api/ps5/hearbridge/saved?host=a%0d%0ab", None).await;
|
||||
assert_eq!(code, 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_by_addr_reports_an_absent_hearbridge() {
|
||||
let (code, body) = call("GET", "/api/ps5/hearbridge/status?addr=127.0.0.1", None).await;
|
||||
assert_eq!(code, 200, "{body}");
|
||||
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(v["ok"], true);
|
||||
// Something may listen on 8090 on a dev machine; either way no token is handed out.
|
||||
assert!(v.get("token").is_none() && v["status"].get("token").is_none());
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ mod folder_build;
|
||||
mod fpkg_api;
|
||||
mod fpkg_firmware;
|
||||
mod games_api;
|
||||
mod hearbridge_api;
|
||||
mod helper_mirror;
|
||||
mod helper_stop;
|
||||
mod icon_cache;
|
||||
@@ -10701,6 +10702,7 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
|
||||
.route("/api/ps5/pldmgr/delete", post(pldmgr_api::delete))
|
||||
.route("/api/ps5/pldmgr/autoload", post(pldmgr_api::autoload))
|
||||
.route("/api/ps5/pldmgr/store", post(pldmgr_api::store))
|
||||
.merge(hearbridge_api::routes())
|
||||
.route("/api/ps5/elfldr/ensure", post(ps5_elfldr_ensure))
|
||||
.route("/api/ps5/process/kill", post(ps5_process_kill))
|
||||
.route("/api/ps5/power/control", post(ps5_power_control))
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user