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
2 changes: 1 addition & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"name": "dist/index.js (raw)",
"path": "dist/index.js",
"brotli": false,
"limit": "820 KB"
"limit": "830 KB"
}
]
13 changes: 8 additions & 5 deletions docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ The Connection Settings page manages your RomM server connection.
[Sign out](#sign-out)).
- **Allow Insecure SSL** — shown only for `https://` URLs; skips certificate verification for self-signed certs (LAN
only).
- **Test Connection** — available once you are signed in; verifies the plugin can reach and authenticate with your RomM
server using the stored token.

The plugin checks the connection for you — there is no manual "Test Connection" button. The **Connection** row on the
plugin's main QAM panel shows the live status whenever you open it, and names the problem when it can't connect (for
example _Sign-in rejected_, _Server unreachable_, or _No server URL_).

### Sign out

Expand Down Expand Up @@ -125,10 +127,11 @@ To set this up:

1. Create a free account at [steamgriddb.com](https://www.steamgriddb.com/)
2. Go to your [API preferences](https://www.steamgriddb.com/profile/preferences/api) and copy your API key
3. In Connection Settings, paste it into the **API Key** field under "SteamGridDB"
4. Tap **Verify Key** to confirm it works
3. In Connection Settings, tap **Edit** next to **API Key** under "SteamGridDB" and paste your key
4. Tap **Save** — the key is checked against SteamGridDB first, so an invalid key is rejected inline and only a working
key is stored

<!-- Screenshot: SteamGridDB API Key section with Edit and Verify buttons -->
<!-- Screenshot: SteamGridDB API Key section with the Edit button -->

Without an API key, games will still have cover art from RomM but the hero banner, logo overlay, and wide grid image
will be missing.
Expand Down
10 changes: 6 additions & 4 deletions docs/user-guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ After installation, you need to connect the plugin to your RomM server:
2. Tap **Connection Settings**
3. Enter your RomM server URL (e.g. `http://192.168.1.100:8080`) — this saves automatically
4. Tap **Sign in**, enter your RomM username and password once, and confirm
5. The **RomM Account** row shows **Signed in** on success. Once signed in, tap **Test Connection** to re-verify at any
time
5. The **RomM Account** row shows **Signed in** on success. The plugin's main QAM panel has a **Connection** row that
shows the live connection status whenever you open it — there is no separate test button

On a controller, any text field in the plugin can be confirmed with the on-screen keyboard's **Enter** key (or the
**R2** shortcut) instead of navigating to the button — this applies to sign-in, save-slot names, and the artwork search.
On a controller you can confirm a text field with the on-screen keyboard's **Enter** key (or the **R2** shortcut)
instead of navigating to the button — for example save-slot names and the artwork search. In the sign-in dialog, Enter
moves from the username to the password field and confirms only once every required field is filled; an incomplete form
can't be submitted, and **Cancel** leaves your existing sign-in untouched.

The plugin mints a RomM Client API Token from the credentials you enter and discards the password — it is never stored.
The same applies if the plugin auto-migrates an older install that still had a saved password: the password is discarded
Expand Down
9 changes: 5 additions & 4 deletions docs/user-guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ config file.

**Symptom**: Toast notifications say "RomM unreachable" or sync operations show in the Failed Syncs list.

**Fix**: Check your network connection and verify the RomM server is running. Go to **Connection Settings** and tap
**Test Connection**. Failed syncs are queued and retried automatically when the server is reachable again.
**Fix**: Check your network connection and verify the RomM server is running. The **Connection** row on the plugin's
main QAM panel shows the live status (and names the problem when it can't connect). Failed syncs are queued and retried
automatically when the server is reachable again.

### Offline detection and recovery

Expand Down Expand Up @@ -175,8 +176,8 @@ For technical details, see [Steam Remote Play and Cross-Device Shortcuts](../arc

### Download shows no progress

**Fix**: Check your connection to the RomM server (Connection Settings > Test Connection). If the server is reachable,
try cancelling and restarting the download from the game detail page.
**Fix**: Check your connection to the RomM server (the **Connection** row on the plugin's main QAM panel). If the server
is reachable, try cancelling and restarting the download from the game detail page.

### Download failed

Expand Down
25 changes: 21 additions & 4 deletions py_modules/services/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
_INVALID_URL_MESSAGE = "Enter a valid http(s):// server URL"

_FORBIDDEN_TOKEN_MESSAGE = (
"Your RomM account cannot create API tokens — ask your admin to grant "
"token permissions or use an account with a higher role."
"RomM rejected the sign-in. Check your username and password — if they are correct, your account may not be "
"allowed to create API tokens (ask your admin or use an account with a higher role)."
)

# Pasted-token validation (``establish_user_token``): a 401 means the token
Expand All @@ -62,6 +62,9 @@
_USER_TOKEN_SCOPE_MESSAGE = (
"The API token is missing required permissions (scopes). Grant the scopes listed in the plugin docs and try again."
)
_USER_TOKEN_REJECTED_MESSAGE = (
"RomM rejected this token. Check you pasted it correctly and that it has the required scopes (see the plugin docs)."
)

# Pairing-code sign-in (``establish_paired_token``). The 60s single-use pairing
# code is exchanged for a token over a public endpoint; each rejection carries a
Expand Down Expand Up @@ -276,8 +279,10 @@ async def establish_token(
minted = await self._loop.run_in_executor(None, self._mint, username, password)
except RommForbiddenError:
# 403 on token mint: same AUTH_FAILED slug as a 401, but a distinct
# message — the account lacks token-creation permission (or a
# Cloudflare bot-fight 403 at the edge), not wrong credentials.
# message. RomM answers 403 indistinguishably for wrong credentials
# AND for an account that lacks token-creation permission (and a
# Cloudflare bot-fight 403 lands here too), so the message names both
# causes rather than asserting only the permission one.
self._restore_auth_state(snapshot)
return {"success": False, "reason": ErrorCode.AUTH_FAILED.value, "message": _FORBIDDEN_TOKEN_MESSAGE}
except Exception as e:
Expand Down Expand Up @@ -366,6 +371,18 @@ async def establish_user_token(
except Exception as e:
self._restore_auth_state(snapshot)
self._romm_api.set_version(None)
# RomM answers the token-carrying probe with a 500 (a malformed token)
# or a 403 (a token-shaped but invalid/revoked one) instead of a clean
# 401, so a bad pasted token otherwise surfaces as a generic server
# error. The auth state is restored now (old token, or none), so a
# reachability probe that succeeds proves the server is up and the
# pasted token — not the server — is at fault.
if (await self.probe_reachability()).get("online"):
return {
"success": False,
"reason": ErrorCode.AUTH_FAILED.value,
"message": _USER_TOKEN_REJECTED_MESSAGE,
}
return error_response(e)

version_error = self._version_gate_error(version)
Expand Down
52 changes: 51 additions & 1 deletion src/components/MainPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, fireEvent, act } from "@testing-library/react";
import { createElement, type ReactElement } from "react";
import { MainPage } from "./MainPage";
import { MainPage, ConnectionIndicator } from "./MainPage";
import * as backend from "../api/backend";
import { useVersionError } from "./VersionErrorCard";
import {
Expand Down Expand Up @@ -656,6 +656,56 @@ describe("MainPage", () => {
await flushAsync();
expect(container.textContent).toContain("Not connected");
});

// A failed probe carries the backend's {reason, message}; the row shows a
// specific label instead of a bare "Not connected". version_error is not
// exercised here — a version failure short-circuits the whole panel to the
// VersionErrorCard (covered separately); the label mapping is covered by the
// direct-render cases below.
it.each([
["auth_failed", "401 Unauthorized", "Sign-in rejected"],
["server_unreachable", "timed out", "Server unreachable"],
["config_error", "No server URL configured", "No server URL"],
["config_error", "Not signed in — sign in to RomM first", "Not signed in"],
["config_error", "some other config issue", "Not connected"],
] as const)("a failed probe with reason=%s renders %s", async (reason, message, label) => {
vi.mocked(backend.testConnection).mockResolvedValue({ success: false, reason, message });
const { container } = render(<MainPage onNavigate={vi.fn()} />);
await flushAsync();
expect(container.textContent).toContain(label);
});
});

// ===========================================================================
// D1. ConnectionIndicator — failure label mapping (direct render)
// ===========================================================================
describe("ConnectionIndicator failure labels", () => {
it.each([
["auth_failed", "", "Sign-in rejected"],
["server_unreachable", "", "Server unreachable"],
["version_error", "server too old", "Unsupported RomM version"],
["config_error", "No server URL configured", "No server URL"],
["config_error", "Not signed in — sign in to RomM first", "Not signed in"],
["config_error", "unclassified config problem", "Not connected"],
["unknown", "", "Not connected"],
] as const)("reason=%s / message=%s → %s", (reason, message, label) => {
const { container } = render(<ConnectionIndicator connected={false} failure={{ reason, message }} />);
expect(container.textContent).toContain(label);
});

it("falls back to 'Not connected' when no failure detail is present", () => {
const { container } = render(<ConnectionIndicator connected={false} failure={null} />);
expect(container.textContent).toContain("Not connected");
});

it("still renders the unchanged Connected / Checking… / Backend error states", () => {
const connected = render(<ConnectionIndicator connected={true} />);
expect(connected.container.textContent).toContain("Connected");
const checking = render(<ConnectionIndicator connected={null} />);
expect(checking.container.textContent).toContain("Checking...");
const failed = render(<ConnectionIndicator connected="backend_failed" />);
expect(failed.container.textContent).toContain("Backend error");
});
});

// ===========================================================================
Expand Down
48 changes: 45 additions & 3 deletions src/components/MainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type {
SessionBudgetStatus,
DownloadItem,
MigrationStatus,
RommErrorCode,
} from "../types";
import { detach } from "../utils/detach";
import { withTimeout } from "../utils/withTimeout";
Expand All @@ -98,6 +99,39 @@ const CONNECTION_CALLABLE_TIMEOUT = 5000;
/** Backend never answered after the retry budget — distinct from `false` ("not connected"). */
type BackendFailed = "backend_failed";

/** A resolved-but-failed `test_connection()` probe. `reason`/`message` classify
* why so the connection row shows a specific label instead of a bare "Not
* connected"; both are absent when the probe never resolved (an unreachable
* server that hung past every deadline), which reads as the generic label. */
interface ConnectionFailure {
reason: RommErrorCode | undefined;
message: string;
}

/** The connection-row label for a failed probe, mapped from the backend's
* `{reason, message}`. The two `config_error` sub-cases are split by message
* text (the slug is shared). Anything unclassified falls back to the generic
* "Not connected". `version_error` is included for completeness even though a
* version failure short-circuits the whole panel to the VersionErrorCard. */
function connectionFailureLabel(failure: ConnectionFailure | null | undefined): string {
const reason = failure?.reason;
const message = failure?.message ?? "";
switch (reason) {
case "auth_failed":
return "Sign-in rejected";
case "server_unreachable":
return "Server unreachable";
case "version_error":
return "Unsupported RomM version";
case "config_error":
if (/server url/i.test(message)) return "No server URL";
if (/not signed in/i.test(message)) return "Not signed in";
return "Not connected";
default:
return "Not connected";
}
}

/** Counted segments — ``[[count, word], …]`` → ``"353 new / 800 updated"``: every
* segment spells its word out, zero counts dropped, joined with `` / ``. Empty
* when every count is zero. The old ``+``/``~``/``−`` sigils were a legend the
Expand All @@ -109,7 +143,10 @@ function countedSegments(pairs: [number, string][]): string {
.join(" / ");
}

const ConnectionIndicator: FC<{ connected: boolean | null | BackendFailed }> = ({ connected }) => {
export const ConnectionIndicator: FC<{
connected: boolean | null | BackendFailed;
failure?: ConnectionFailure | null;
}> = ({ connected, failure }) => {
if (connected === "backend_failed") {
return (
<>
Expand Down Expand Up @@ -137,7 +174,7 @@ const ConnectionIndicator: FC<{ connected: boolean | null | BackendFailed }> = (
return (
<>
<FaTimesCircle style={{ color: "#d4343c", fontSize: "14px" }} />
<span style={{ fontSize: "12px" }}>Not connected</span>
<span style={{ fontSize: "12px" }}>{connectionFailureLabel(failure)}</span>
</>
);
};
Expand Down Expand Up @@ -423,6 +460,10 @@ export const MainPage: FC<MainPageProps> = ({ onNavigate }) => {
const [stats, setStats] = useState<SyncStats | null>(null);
const [budgetStatus, setBudgetStatus] = useState<SessionBudgetStatus | null>(null);
const [connected, setConnected] = useState<boolean | null | BackendFailed>(null);
// Classifies a resolved-but-failed probe so the connection row can show a
// specific label (auth rejected / server unreachable / no URL / not signed
// in). Null for every non-failed state and for a probe that never resolved.
const [connectionFailure, setConnectionFailure] = useState<ConnectionFailure | null>(null);
const versionError = useVersionError();
const [syncing, setSyncing] = useState(false);
// Disarmed "Cancelling…" state during the backend's RUNNING→CANCELLING→IDLE
Expand Down Expand Up @@ -499,6 +540,7 @@ export const MainPage: FC<MainPageProps> = ({ onNavigate }) => {
const r = await withTimeout(testConnection(), CONNECTION_CALLABLE_TIMEOUT);
if (isCancelled()) return;
setConnected(r.success);
setConnectionFailure(r.success ? null : { reason: r.reason, message: r.message });
setVersionError(r.reason === "version_error" ? r.message : null);
return;
} catch {
Expand Down Expand Up @@ -1289,7 +1331,7 @@ export const MainPage: FC<MainPageProps> = ({ onNavigate }) => {
}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<ConnectionIndicator connected={connected} />
<ConnectionIndicator connected={connected} failure={connectionFailure} />
</div>
</Field>
</PanelSectionRow>
Expand Down
Loading
Loading