Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions client/src-tauri/src/commands/ps5_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,25 @@ pub async fn pkg_dpi_install(
post_json_long(&url, &body).await
}

/// Direct/streaming install (beta, #81): hand the DPI daemon the engine's
/// /pkg-host/ URL for an existing session instead of a staged PS5 path.
/// The daemon pulls the pkg over HTTP — no staging copy uploaded to the
/// PS5 first. The session must already be registered with the engine via
/// a prior `pkg_install_start` (which creates the pkg-host listener).
/// Long-deadline client — the installer ingests the pkg before replying.
#[tauri::command]
pub async fn pkg_dpi_direct_install(
ps5_addr: String,
session_id: String,
) -> Result<JsonValue, String> {
let url = format!("{}/api/pkg/dpi-direct-install", engine::url());
let body = serde_json::json!({
"ps5_addr": ps5_addr,
"session_id": session_id,
});
post_json_long(&url, &body).await
}

/// Poll an in-flight install for status. Cheap; called every 1-2s.
#[tauri::command]
pub async fn pkg_install_status(session: String) -> Result<JsonValue, String> {
Expand Down
1 change: 1 addition & 0 deletions client/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ pub fn run() {
commands::ffpkg_extract,
commands::pkg_install_start,
commands::pkg_dpi_install,
commands::pkg_dpi_direct_install,
commands::dpi_ensure,
commands::pkg_install_status,
commands::pkg_install_cancel,
Expand Down
53 changes: 52 additions & 1 deletion client/src/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import {
useConnectionStore,
EMPTY_HOST_RUNTIME,
PS5_LOADER_PORT,
PS5_PAYLOAD_PORT,
} from "../state/connection";
import { usePayloadPlaylistsStore } from "../state/payloadPlaylists";
import { log } from "../state/logs";
import { useUpdateStore } from "../state/update";
import { engineApi } from "../api/engine";
import { payloadCheck } from "../api/ps5";
import { payloadCheck, portCheck } from "../api/ps5";
import { installActivityWiring } from "../state/activityWiring";
import {
ensureRosterMigrated,
Expand Down Expand Up @@ -100,6 +101,12 @@ function useStatusPolling() {
// scan, momentary network jitter) shouldn't flash "Helper isn't running"
// when the helper is actually alive. Require N misses in a row first.
const missCountRef = useRef<Record<string, number>>({});
// Last transfer-port (:9113) liveness result per host. Used to log the
// up→down TRANSITION only (not every poll) when the transfer listener
// dies while mgmt stays up — the "uploads fail but the dot is green"
// wedge state. See HostRuntime.transferAlive for why this is tracked
// separately from the mgmt-port STATUS probe.
const transferAliveRef = useRef<Record<string, boolean>>({});
// Auto-loader: last wall-clock ms we auto-ran the playlist for a host, used
// to suppress re-triggering. The playlist itself sends ELFs to the loader,
// which momentarily drops the helper (down→up flap) — without a cooldown
Expand Down Expand Up @@ -167,6 +174,7 @@ function useStatusPolling() {
for (const ref of [
autoLoaderFiredAtRef,
missCountRef,
transferAliveRef,
warnedMismatchRef,
warnedNoUcredRef,
]) {
Expand Down Expand Up @@ -257,6 +265,43 @@ function useStatusPolling() {
});
// Clear the active console's "rechecking…" flag once its probe lands.
if (isActive(key)) setStatus({ payloadProbing: false });
// Transfer-port (:9113) liveness — only worth probing when the
// mgmt probe says the helper is up. A refused/timeout here while
// mgmt answers is the "uploads fail but the dot is green" wedge:
// the transfer listener died (or never came up) while the mgmt
// thread is still serving STATUS. Log the up→down TRANSITION so
// the bug bundle has a smoking gun before the user files an
// "upload refused" issue. Skipped entirely when mgmt is down —
// no point probing :9113 when :9114 is already dark.
if (s.reachable && newStatus === "up") {
try {
const alive = await portCheck(probedHost, PS5_PAYLOAD_PORT);
if (cancelled) return;
const was = transferAliveRef.current[key];
transferAliveRef.current[key] = alive;
setHostStatus(probedHost, { transferAlive: alive });
if (!alive && was !== false) {
log.warn(
"connection",
`transfer port :${PS5_PAYLOAD_PORT} DOWN on ${probedHost} (mgmt still up) — uploads will fail until the payload is redeployed`,
);
} else if (alive && was === false) {
log.info(
"connection",
`transfer port :${PS5_PAYLOAD_PORT} recovered on ${probedHost}`,
);
}
} catch {
// portCheck best-effort — leave transferAlive unchanged.
}
} else if (!s.reachable) {
// mgmt down ⇒ transfer port state unknown; clear so the next
// reachable poll reports a fresh transition.
if (transferAliveRef.current[key] !== undefined) {
delete transferAliveRef.current[key];
}
setHostStatus(probedHost, { transferAlive: null });
}
if (s.reachable) {
// Update the matching roster row's cached firmware/payload.
const roster = useRosterStore.getState();
Expand Down Expand Up @@ -314,6 +359,12 @@ function useStatusPolling() {
}
setHostStatus(probedHost, { payloadStatus: newStatus });
if (isActive(key)) setStatus({ payloadProbing: false });
// mgmt probe threw — transfer port state is unknown. Clear so a
// subsequent successful poll re-establishes the baseline.
if (transferAliveRef.current[key] !== undefined) {
delete transferAliveRef.current[key];
}
setHostStatus(probedHost, { transferAlive: null });
}
};
const tick = () => {
Expand Down
14 changes: 14 additions & 0 deletions client/src/lib/browserInvoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,20 @@ export async function browserInvoke<T>(cmd: string, args: AnyArgs = {}): Promise
/*long=*/ true,
);

case "pkg_dpi_direct_install":
// TS caller: { ps5Addr, sessionId } (Tauri 2 camelCase).
// Direct/streaming install (beta, #81): the engine serves the pkg
// at /pkg-host/{session}/ and the DPI daemon pulls it over HTTP —
// no staging copy uploaded to the PS5 first.
return postJson<T>(
"/api/pkg/dpi-direct-install",
{
ps5_addr: args["ps5Addr"],
session_id: args["sessionId"],
},
/*long=*/ true,
);

// ── Payload probe ────────────────────────────────────────────────────────

case "payload_check": {
Expand Down
2 changes: 2 additions & 0 deletions client/src/lib/uploadStreams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ describe("effectiveUploadStreams", () => {
ps5Kernel: null,
ucredElevated: true,
maxTransferStreams: 1,
transferAlive: null,
},
"192.168.86.100": {
payloadStatus: "up",
payloadVersion: "2.30.0",
ps5Kernel: null,
ucredElevated: true,
maxTransferStreams: 4,
transferAlive: null,
},
},
});
Expand Down
68 changes: 68 additions & 0 deletions client/src/screens/InstallPackage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export default function InstallPackageScreen() {
const addAndUpload = usePkgLibrary(host, (s) => s.addAndUpload);
const install = usePkgLibrary(host, (s) => s.install);
const installAll = usePkgLibrary(host, (s) => s.installAll);
const installStream = usePkgLibrary(host, (s) => s.installStream);
const cancelPendingInstall = usePkgLibrary(
host,
(s) => s.cancelPendingInstall,
Expand All @@ -327,6 +328,7 @@ export default function InstallPackageScreen() {
);
const [pickError, setPickError] = useState<string | null>(null);
const [picking, setPicking] = useState(false);
const [streaming, setStreaming] = useState(false);
const [dropActive, setDropActive] = useState(false);

const hostReady = !!host?.trim();
Expand Down Expand Up @@ -448,6 +450,45 @@ export default function InstallPackageScreen() {
}
}

// Stream-install (beta, #81): pick a single PC-side .pkg and install it
// WITHOUT staging it on the PS5 first — the engine serves the file over
// HTTP and the DPI daemon pulls it directly. Useful for a quick one-shot
// install when you don't want to wait out the staging upload (or don't
// have the disk space for it). Shares the `installing` lock with the
// regular install flow.
async function handleStreamPick() {
setPickError(null);
if (!host?.trim()) {
setPickError(
tr(
"install.error.noHost",
"Set a PS5 host on the Connection tab first.",
),
);
return;
}
setStreaming(true);
try {
const sel = isAndroid()
? await pickPath({
mode: "file",
filters: [{ name: "PS5 Package", extensions: ["pkg"] }],
})
: await openDialog({
multiple: false,
filters: [{ name: "PS5 Package", extensions: ["pkg"] }],
});
const p = Array.isArray(sel) ? sel[0] : sel;
if (!p) return;
const r = await installStream(p as string, host);
if (!r.ok && r.message) setPickError(r.message);
} catch (e) {
setPickError(`${e}`);
} finally {
setStreaming(false);
}
}

// Install-order guard. A base game (CATEGORY "gd") and its update ("gp") or
// DLC ("ac") share a ContentID AND a title_id — they never overwrite each
// other (the library stages them to separate sub-dirs; on the PS5 the base
Expand Down Expand Up @@ -621,6 +662,33 @@ export default function InstallPackageScreen() {
>
{tr("install.add", "Add .pkg")}
</Button>
<Button
variant="secondary"
size="sm"
leftIcon={
streaming ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)
}
onClick={handleStreamPick}
loading={streaming}
disabled={!hostReady || installing || installingAll}
title={
!hostReady
? tr(
"install.add.disabledHint",
"Set a PS5 host on the Connection tab first",
)
: tr(
"pkglib.stream.hint",
"Install a .pkg straight from this PC over HTTP — no staging upload (beta)",
)
}
>
{tr("pkglib.stream", undefined, "Stream (beta)")}
</Button>
</div>
}
/>
Expand Down
1 change: 1 addition & 0 deletions client/src/state/auditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type AuditKind =
| "peripheral_eject"
| "peripheral_bd_off"
| "pkg_install_start"
| "pkg_dpi_direct_install"
| "lwfs_mount"
| "pkg_direct_mount";

Expand Down
16 changes: 16 additions & 0 deletions client/src/state/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ export interface HostRuntime {
ps5Kernel: string | null;
ucredElevated: boolean | null;
maxTransferStreams: number | null;
/** Whether the FTX2 transfer listener (:9113) accepts TCP. Probed
* alongside the mgmt STATUS frame so the UI can flag the wedge
* state where mgmt (:9114) is up but the transfer port is dead —
* uploads will fail with "connection refused" until the payload is
* redeployed, but the status pill would otherwise show green.
* null = host not yet probed or transfer-port check not run. */
transferAlive: boolean | null;
}

export const EMPTY_HOST_RUNTIME: HostRuntime = {
Expand All @@ -95,6 +102,7 @@ export const EMPTY_HOST_RUNTIME: HostRuntime = {
ps5Kernel: null,
ucredElevated: null,
maxTransferStreams: null,
transferAlive: null,
};

export interface ConnectionState {
Expand Down Expand Up @@ -136,6 +144,11 @@ export interface ConnectionState {
* yet); the Upload path treats null as 1. The effective stream count is
* min(this, the user's upload-streams setting). */
maxTransferStreams: number | null;
/** Mirror of the active console's transfer-port (:9113) liveness.
* True = TCP connect to :9113 succeeded; false = refused/timeout;
* null = host not yet probed. See HostRuntime.transferAlive for
* why this is tracked separately from the mgmt-port STATUS. */
transferAlive: boolean | null;
/** True when a fresh payload-info probe is in flight and the
* currently-displayed payloadVersion / ps5Kernel may be stale.
* Set by Connection's handleSend on entry (the user just kicked
Expand Down Expand Up @@ -167,6 +180,7 @@ export interface ConnectionState {
| "ucredElevated"
| "maxTransferStreams"
| "payloadProbing"
| "transferAlive"
>
>
) => void;
Expand All @@ -187,6 +201,7 @@ function mirrorRuntime(host: string, rt: HostRuntime) {
ps5Kernel: rt.ps5Kernel,
ucredElevated: rt.ucredElevated,
maxTransferStreams: rt.maxTransferStreams,
transferAlive: rt.transferAlive,
};
}

Expand All @@ -201,6 +216,7 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
ps5Kernel: null,
ucredElevated: null,
maxTransferStreams: null,
transferAlive: null,
payloadProbing: false,
step1: "idle",
step1Msg: "Enter your PS5's address and check",
Expand Down
Loading