Skip to content

Commit cb5af80

Browse files
committed
Add ports library route and stabilize native port indexing
1 parent d78e85e commit cb5af80

11 files changed

Lines changed: 123 additions & 14 deletions

File tree

backend/cmd/server/native_ports_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ func TestNativePortUploadUsesPortsTrackAndManifestMetadata(t *testing.T) {
5252
t.Fatalf("expected origin system metadata: %s", prettyJSON(found))
5353
}
5454

55+
portsOnly := h.request(http.MethodGet, "/saves?systemSlug=ports&limit=10", nil)
56+
assertStatus(t, portsOnly, http.StatusOK)
57+
portsBody := decodeJSONMap(t, portsOnly.Body)
58+
if total := int(mustNumber(t, portsBody["total"], "ports.total")); total != 1 {
59+
t.Fatalf("expected one filtered native port save, got %d: %s", total, prettyJSON(portsBody))
60+
}
61+
n64Only := h.request(http.MethodGet, "/saves?systemSlug=n64&limit=10", nil)
62+
assertStatus(t, n64Only, http.StatusOK)
63+
n64Body := decodeJSONMap(t, n64Only.Body)
64+
if total := int(mustNumber(t, n64Body["total"], "n64.total")); total != 0 {
65+
t.Fatalf("expected native port save to stay isolated from n64 list, got %d: %s", total, prettyJSON(n64Body))
66+
}
67+
5568
latest := h.request(http.MethodGet, "/save/latest?romSha1=port:ship-of-harkinian&slotName="+url.QueryEscape("file1"), nil)
5669
assertStatus(t, latest, http.StatusOK)
5770
latestBody := decodeJSONMap(t, latest.Body)
@@ -83,3 +96,55 @@ func TestNativePortUploadRejectsUnmanifestedPath(t *testing.T) {
8396
t.Fatalf("expected manifest rejection reason: %s", prettyJSON(body))
8497
}
8598
}
99+
100+
func TestNativePortUploadAllowsManifestDefaultFilename(t *testing.T) {
101+
h := newContractHarness(t)
102+
103+
payload := make([]byte, 512)
104+
payload[0] = 0x4d
105+
payload[1] = 0x4b
106+
107+
body := uploadSave(t, h, "/saves", map[string]string{
108+
"system": "ports",
109+
"rom_sha1": "port:spaghettikart",
110+
"slotName": "default",
111+
"runtimeProfile": "port/spaghettikart",
112+
"portId": "spaghettikart",
113+
"portName": "Mario Kart 64 (SpaghettiKart)",
114+
"originSystemSlug": "n64",
115+
"portSaveKind": "progress",
116+
"relativePath": "default.sav",
117+
"rootRelativePath": "MarioKart64/default.sav",
118+
"slotId": "default",
119+
"displayTitle": "Mario Kart 64 (SpaghettiKart) - Default",
120+
}, "default.sav", payload)
121+
122+
save := mustObject(t, body["save"], "save")
123+
saveID := mustString(t, save["id"], "save.id")
124+
if saveID == "" {
125+
t.Fatalf("expected manifest default save upload to succeed: %s", prettyJSON(body))
126+
}
127+
128+
list := h.request(http.MethodGet, "/saves?systemSlug=ports&limit=10", nil)
129+
assertStatus(t, list, http.StatusOK)
130+
listBody := decodeJSONMap(t, list.Body)
131+
items := mustArray(t, listBody["saves"], "saves")
132+
found := false
133+
for _, raw := range items {
134+
item := mustObject(t, raw, "save")
135+
if mustString(t, item["id"], "save.id") == saveID {
136+
found = true
137+
break
138+
}
139+
}
140+
if !found {
141+
t.Fatalf("manifest default save was accepted but missing from ports list: %s", prettyJSON(listBody))
142+
}
143+
144+
latest := h.request(http.MethodGet, "/save/latest?romSha1="+url.QueryEscape("port:spaghettikart")+"&slotName=default", nil)
145+
assertStatus(t, latest, http.StatusOK)
146+
latestBody := decodeJSONMap(t, latest.Body)
147+
if mustString(t, latestBody["id"], "latest.id") != saveID {
148+
t.Fatalf("latest did not resolve manifest default save: %s", prettyJSON(latestBody))
149+
}
150+
}

backend/cmd/server/save_handlers.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ func (a *app) handleListSaves(w http.ResponseWriter, r *http.Request) {
703703
romSHA1 := strings.TrimSpace(r.URL.Query().Get("romSha1"))
704704
romMD5 := strings.TrimSpace(r.URL.Query().Get("romMd5"))
705705
systemID := parseIntOrDefault(r.URL.Query().Get("systemId"), 0)
706+
systemSlug := canonicalOptionalSegment(firstNonEmpty(r.URL.Query().Get("systemSlug"), r.URL.Query().Get("system")))
706707

707708
records := a.snapshotSaveRecords()
708709
filteredRecords := make([]saveRecord, 0, len(records))
@@ -718,6 +719,9 @@ func (a *app) handleListSaves(w http.ResponseWriter, r *http.Request) {
718719
continue
719720
}
720721
}
722+
if systemSlug != "" && canonicalOptionalSegment(saveRecordSystemSlug(record)) != systemSlug {
723+
continue
724+
}
721725
filteredRecords = append(filteredRecords, record)
722726
}
723727

@@ -782,8 +786,10 @@ func (a *app) handleListSaves(w http.ResponseWriter, r *http.Request) {
782786
summary.LatestVersion = summary.Version
783787
filtered = append(filtered, summary)
784788
}
785-
if romMD5 == "" {
789+
if romMD5 == "" && (systemSlug == "" || systemSlug == "psx" || systemSlug == "ps2") {
786790
filtered = append(filtered, a.playStationLogicalListSummaries(systemID)...)
791+
}
792+
if romMD5 == "" && (systemSlug == "" || systemSlug == "n64") {
787793
filtered = append(filtered, a.n64ControllerPakListSummaries(systemID, romSHA1)...)
788794
}
789795
sort.Slice(filtered, func(i, j int) bool {

backend/cmd/server/system_detection.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,10 @@ func detectSaveSystem(input saveSystemDetectionInput) saveSystemDetectionResult
249249
displayTitle := strings.TrimSpace(input.DisplayTitle)
250250
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".")
251251
payload := input.Payload
252+
declaredSlug := canonicalOptionalSegment(input.DeclaredSystemSlug)
253+
trustedNativePort := declaredSlug == nativePortSystemSlug && (input.TrustedHelperSystem || input.TrustedStoredSystem)
252254

253-
if noisy, reason := isLikelyNoiseFilename(filename); noisy {
255+
if noisy, reason := isLikelyNoiseFilename(filename); noisy && !trustedNativePort {
254256
return saveSystemDetectionResult{
255257
Slug: "unknown-system",
256258
System: nil,

frontend/src/app/layouts/AppLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useEffect, useMemo, useState } from "react";
22
import { Link, NavLink, Outlet, useNavigate } from "react-router-dom";
3-
import { AlertTriangle, Database, FileWarning, KeyRound, ListChecks, LogOut, MonitorSmartphone, ScrollText, Settings, Wand2 } from "lucide-react";
3+
import { AlertTriangle, Database, FileWarning, Gamepad2, KeyRound, ListChecks, LogOut, MonitorSmartphone, ScrollText, Settings, Wand2 } from "lucide-react";
44
import { clearFrontendAuthSession, isFrontendAuthRequired } from "../../services/authSession";
55
import { enableAutoAppPasswordEnrollment, getAutoAppPasswordEnrollmentStatus, getRuntimeConfig } from "../../services/retrosaveApi";
66
import type { RuntimeConfig } from "../../services/types";
77

88
const appNav: Array<{ label: string; to: string; icon: typeof Database }> = [
99
{ label: "My Saves", to: "/app/my-games", icon: Database },
10+
{ label: "Ports", to: "/app/ports", icon: Gamepad2 },
1011
{ label: "Cheats", to: "/app/cheats", icon: Wand2 },
1112
{ label: "Validation", to: "/app/validation", icon: ListChecks },
1213
{ label: "Logs", to: "/app/logs", icon: ScrollText },
@@ -97,12 +98,16 @@ function RuntimeWarning({ runtime }: { runtime: RuntimeConfig | null }): JSX.Ele
9798
if (!warning) {
9899
return null;
99100
}
101+
const displayWarning = warning.replace(
102+
"Keep this instance on a trusted LAN or protect it behind your own reverse proxy.",
103+
"Trusted LAN or reverse proxy required."
104+
);
100105
return (
101106
<section className="runtime-warning" role="status" aria-live="polite">
102107
<AlertTriangle aria-hidden="true" />
103108
<div>
104109
<strong>LAN-only mode</strong>
105-
<span>{warning}</span>
110+
<span title={warning}>{displayWarning}</span>
106111
</div>
107112
</section>
108113
);

frontend/src/app/layouts/__tests__/AppLayout.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe("AppLayout", () => {
6565
);
6666

6767
expect(screen.getByRole("link", { name: "My Saves" })).toBeInTheDocument();
68+
expect(screen.getByRole("link", { name: "Ports" })).toBeInTheDocument();
6869
expect(screen.getByRole("link", { name: "Cheats" })).toBeInTheDocument();
6970
expect(screen.queryByRole("link", { name: "My Games" })).not.toBeInTheDocument();
7071
expect(screen.queryByRole("link", { name: "Getting Started" })).not.toBeInTheDocument();

frontend/src/app/routes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export const appRouter = createBrowserRouter([
8888
children: [
8989
{ index: true, element: <Navigate to="/app/my-games" replace /> },
9090
{ path: "my-games", element: <MyGamesPage /> },
91+
{ path: "ports", element: <MyGamesPage title="Ports" systemSlugFilter="ports" emptyLabel="No native port saves found." /> },
9192
{ path: "cheats", element: <CheatsPage /> },
9293
{ path: "validation", element: <ValidationPage /> },
9394
{ path: "logs", element: <LogsPage /> },

frontend/src/pages/app/MyGamesPage.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@ const DEFAULT_SORT: { key: SaveSortKey; direction: SaveSortDirection } = {
3737

3838
const SAVE_PAGE_SIZE = 100;
3939

40-
export function MyGamesPage(): JSX.Element {
40+
type MyGamesPageProps = {
41+
title?: string;
42+
systemSlugFilter?: string;
43+
emptyLabel?: string;
44+
};
45+
46+
export function MyGamesPage({ title = "My Saves", systemSlugFilter, emptyLabel = "No saves found." }: MyGamesPageProps = {}): JSX.Element {
4147
const [deleteError, setDeleteError] = useState<string | null>(null);
4248
const [deletingKeys, setDeletingKeys] = useState<string[]>([]);
4349
const [pendingDeleteRow, setPendingDeleteRow] = useState<SaveRow | null>(null);
@@ -71,9 +77,9 @@ export function MyGamesPage(): JSX.Element {
7177
const [cheatPendingUpdates, setCheatPendingUpdates] = useState<Record<string, unknown>>({});
7278
const [saveLimit, setSaveLimit] = useState(SAVE_PAGE_SIZE);
7379

74-
const loader = useCallback(async () => listSaves({ limit: saveLimit, offset: 0 }), [saveLimit]);
80+
const loader = useCallback(async () => listSaves({ limit: saveLimit, offset: 0, systemSlug: systemSlugFilter }), [saveLimit, systemSlugFilter]);
7581

76-
const { loading, error, data, reload } = useAsyncData(loader, [saveLimit]);
82+
const { loading, error, data, reload } = useAsyncData(loader, [saveLimit, systemSlugFilter]);
7783

7884
const loadedSaves = data?.saves ?? [];
7985
const totalAvailableSaves = data?.total ?? loadedSaves.length;
@@ -474,7 +480,7 @@ export function MyGamesPage(): JSX.Element {
474480
}
475481

476482
if (loading && loadedSaves.length === 0) {
477-
return <LoadingState label="Loading My Saves..." />;
483+
return <LoadingState label={`Loading ${title}...`} />;
478484
}
479485

480486
if (error) {
@@ -485,7 +491,7 @@ export function MyGamesPage(): JSX.Element {
485491
<section className="treegrid-panel fade-in-up">
486492
<header className="treegrid-panel__header">
487493
<div>
488-
<h1>My Saves</h1>
494+
<h1>{title}</h1>
489495
<p>{summaryText}</p>
490496
</div>
491497
<button className="treegrid-header-action" type="button" onClick={openUploadModal}>
@@ -495,7 +501,7 @@ export function MyGamesPage(): JSX.Element {
495501

496502
{deleteError ? <p className="error-state">{deleteError}</p> : null}
497503
{loadingMore ? <p className="treegrid-panel__status">Refreshing save library...</p> : null}
498-
{rows.length === 0 ? <p className="treegrid-panel__empty">No saves found.</p> : null}
504+
{rows.length === 0 ? <p className="treegrid-panel__empty">{emptyLabel}</p> : null}
499505

500506
{rows.length > 0 ? (
501507
<MyGamesLibraryTable

frontend/src/pages/app/__tests__/MyGamesPage.treegrid.test.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import type { ComponentProps } from "react";
23
import { MemoryRouter } from "react-router-dom";
34
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45
import { MyGamesPage } from "../MyGamesPage";
@@ -59,10 +60,10 @@ function makeSave(overrides: Partial<SaveSummary> & { id: string; title: string;
5960
};
6061
}
6162

62-
function renderPage(): ReturnType<typeof render> {
63+
function renderPage(props?: ComponentProps<typeof MyGamesPage>): ReturnType<typeof render> {
6364
return render(
6465
<MemoryRouter>
65-
<MyGamesPage />
66+
<MyGamesPage {...props} />
6667
</MemoryRouter>
6768
);
6869
}
@@ -251,6 +252,13 @@ describe("MyGamesPage TreeGrid", () => {
251252
expect(await screen.findByText("Chrono Trigger")).toBeInTheDocument();
252253
});
253254

255+
it("can render the dedicated Ports library route with backend-side filtering", async () => {
256+
renderPage({ title: "Ports", systemSlugFilter: "ports", emptyLabel: "No native port saves found." });
257+
258+
expect(await screen.findByRole("heading", { name: "Ports" })).toBeInTheDocument();
259+
expect(retrosaveApi.listSaves).toHaveBeenCalledWith({ limit: 100, offset: 0, systemSlug: "ports" });
260+
});
261+
254262
it("sorts rows inside a console group and opens the download profile popup with the preserved PlayStation key", async () => {
255263
const view = renderPage();
256264

frontend/src/services/retrosaveApi.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,17 @@ export function getRuntimeConfig(): Promise<{ success: boolean; runtime: Runtime
8181
return apiFetchJSON<{ success: boolean; runtime: RuntimeConfig }>("/api/runtime-config");
8282
}
8383

84-
export async function listSaves(params: { limit?: number; offset?: number } = {}): Promise<SaveListResponse> {
84+
export async function listSaves(params: { limit?: number; offset?: number; systemSlug?: string } = {}): Promise<SaveListResponse> {
8585
const limit = params.limit && params.limit > 0 ? params.limit : 100;
8686
const offset = params.offset && params.offset > 0 ? params.offset : 0;
87-
return apiFetchJSON<SaveListResponse>(`/saves?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}`);
87+
const query = new URLSearchParams({
88+
limit: String(limit),
89+
offset: String(offset)
90+
});
91+
if (params.systemSlug?.trim()) {
92+
query.set("systemSlug", params.systemSlug.trim());
93+
}
94+
return apiFetchJSON<SaveListResponse>(`/saves?${query.toString()}`);
8895
}
8996

9097
export function uploadSaveFile(params: {

frontend/src/styles/layers/foundation.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ html,
2424
body,
2525
#root {
2626
min-height: 100%;
27+
background: #13151b;
2728
}
2829

2930
body {
31+
min-height: 100vh;
3032
margin: 0;
3133
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
3234
-webkit-font-smoothing: antialiased;
@@ -56,6 +58,7 @@ code {
5658
display: grid;
5759
grid-template-columns: 248px minmax(0, 1fr);
5860
min-height: 100vh;
61+
min-height: 100dvh;
5962
}
6063

6164
.app-sidebar {

0 commit comments

Comments
 (0)