Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6deeb781 | ||
|
|
8c9baf3214 | ||
|
|
d83eeedde4 | ||
|
|
786f6ad28f | ||
|
|
1e9957d97b |
@@ -73,33 +73,20 @@ jobs:
|
|||||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||||
# there, right before the first `flatpak` network call.
|
# there, right before the first `flatpak` network call.
|
||||||
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
|
- name: Fix container DNS (drop nss-resolve)
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||||
# Resolve over TCP instead of UDP. The documented root cause of the flathub
|
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
|
||||||
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
|
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
|
||||||
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
|
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
|
||||||
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
|
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
|
||||||
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
|
# local dnsmasq cache on the docker bridge and daemon.json points every job container
|
||||||
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
|
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
|
||||||
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
|
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
|
||||||
# each needing a manual re-run.
|
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
|
||||||
#
|
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
|
||||||
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
|
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
|
||||||
# silently lost under load. Same resolver, same search path — only the transport
|
# genuine upstream blips.
|
||||||
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
|
|
||||||
# NO extra nameservers, which would risk answering an internal name from a public
|
|
||||||
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
|
|
||||||
# retry.sh stays as the backstop for genuine upstream blips.
|
|
||||||
#
|
|
||||||
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
|
|
||||||
# DNS tuning that cannot be applied must not be what fails the release build — that
|
|
||||||
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
|
|
||||||
# today's behaviour, which retry.sh already covers.
|
|
||||||
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
|
|
||||||
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|
|
||||||
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
|
|
||||||
fi
|
|
||||||
cat /etc/resolv.conf || true
|
cat /etc/resolv.conf || true
|
||||||
|
|
||||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ import {
|
|||||||
showModal,
|
showModal,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from "@decky/ui";
|
} from "@decky/ui";
|
||||||
import { definePlugin, routerHook } from "@decky/api";
|
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
|
import {
|
||||||
|
FaDownload,
|
||||||
|
FaLock,
|
||||||
|
FaLockOpen,
|
||||||
|
FaPlay,
|
||||||
|
FaPlus,
|
||||||
|
FaSyncAlt,
|
||||||
|
FaTv,
|
||||||
|
} from "react-icons/fa";
|
||||||
import { PluginErrorBoundary } from "./boundary";
|
import { PluginErrorBoundary } from "./boundary";
|
||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
@@ -31,7 +39,19 @@ import {
|
|||||||
import { streamPin } from "./library";
|
import { streamPin } from "./library";
|
||||||
import { PunktfunkRoute, ROUTE } from "./page";
|
import { PunktfunkRoute, ROUTE } from "./page";
|
||||||
import { PairModal } from "./pair";
|
import { PairModal } from "./pair";
|
||||||
import { ensureGamepadUiShortcut } from "./steam";
|
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
||||||
|
|
||||||
|
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||||
|
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||||
|
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||||
|
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||||
|
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||||
|
const appId = await recreateShortcuts();
|
||||||
|
toaster.toast({
|
||||||
|
title: "Punktfunk",
|
||||||
|
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||||
@@ -190,6 +210,16 @@ const QamPanel: FC = () => {
|
|||||||
{checking ? "Checking…" : "Check for updates"}
|
{checking ? "Checking…" : "Check for updates"}
|
||||||
</ButtonItem>
|
</ButtonItem>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem
|
||||||
|
layout="below"
|
||||||
|
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||||
|
onClick={() => void recreatePunktfunkShortcut()}
|
||||||
|
>
|
||||||
|
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||||
|
Recreate library shortcut
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,29 @@ declare const collectionStore:
|
|||||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
// SteamUI's appStore indexes every registered app/shortcut by appId; a remembered appId whose
|
||||||
|
// overview is gone was deleted out from under us (the user removed the library entry). We must
|
||||||
|
// verify this because the remembered appId lives in Steam's CEF localStorage — which survives a
|
||||||
|
// plugin UNINSTALL/REINSTALL — so a manually-deleted shortcut otherwise leaves a dangling appId
|
||||||
|
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||||
|
// entry never comes back.
|
||||||
|
declare const appStore:
|
||||||
|
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||||
|
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||||
|
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||||
|
function shortcutStillExists(appId: number): boolean {
|
||||||
|
try {
|
||||||
|
const get = appStore?.GetAppOverviewByAppID;
|
||||||
|
if (!get) return true; // no way to verify — preserve the reuse path
|
||||||
|
return get(appId) != null;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||||
@@ -199,8 +222,10 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
|
|||||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||||
|
|
||||||
|
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||||
|
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||||
const remembered = recall(STORAGE_KEY_STREAM);
|
const remembered = recall(STORAGE_KEY_STREAM);
|
||||||
if (remembered != null) {
|
if (remembered != null && shortcutStillExists(remembered)) {
|
||||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||||
@@ -236,8 +261,11 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
|||||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||||
|
|
||||||
|
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||||
|
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||||
|
// library entry actually comes back instead of repointing a dead id.
|
||||||
let appId = recall(STORAGE_KEY_UI);
|
let appId = recall(STORAGE_KEY_UI);
|
||||||
if (appId != null) {
|
if (appId != null && shortcutStillExists(appId)) {
|
||||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
@@ -256,6 +284,30 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||||
|
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||||
|
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||||
|
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||||
|
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||||
|
* Returns the (new or existing) visible appId, or null on failure.
|
||||||
|
*/
|
||||||
|
export async function recreateShortcuts(): Promise<number | null> {
|
||||||
|
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||||
|
const id = recall(key);
|
||||||
|
if (id != null && !shortcutStillExists(id)) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||||
|
return ensureGamepadUiShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||||
export async function launchGamepadUi(): Promise<void> {
|
export async function launchGamepadUi(): Promise<void> {
|
||||||
const appId = await ensureGamepadUiShortcut();
|
const appId = await ensureGamepadUiShortcut();
|
||||||
|
|||||||
@@ -1278,14 +1278,19 @@ mod tests {
|
|||||||
target_id: 262,
|
target_id: 262,
|
||||||
resolved_monitor_id: 7,
|
resolved_monitor_id: 7,
|
||||||
wudf_pid: 4242,
|
wudf_pid: 4242,
|
||||||
|
cursor_excluded: 1,
|
||||||
};
|
};
|
||||||
let rbytes = bytemuck::bytes_of(&reply);
|
let rbytes = bytemuck::bytes_of(&reply);
|
||||||
assert_eq!(rbytes.len(), 20);
|
assert_eq!(rbytes.len(), 24);
|
||||||
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
||||||
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
||||||
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
||||||
// The v2 duplication-target pid trails at offset 16.
|
// The v2 duplication-target pid trails at offset 16.
|
||||||
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
|
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
|
||||||
|
// The cursor-excluded tail rides after the legacy boundary; an un-upgraded driver writes
|
||||||
|
// only the prefix, so a zero-filled tail reads as "unknown/clean" (see the field docs).
|
||||||
|
assert_eq!(rbytes[20..24], 1u32.to_le_bytes());
|
||||||
|
assert_eq!(control::ADD_REPLY_LEGACY_SIZE, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1330,8 +1335,8 @@ mod tests {
|
|||||||
req
|
req
|
||||||
);
|
);
|
||||||
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
||||||
// The compat window: v4/v5 are additive over v3, so the host floor stays at 3.
|
// The compat window: v4–v6 are additive over v3, so the host floor stays at 3.
|
||||||
assert_eq!(PROTOCOL_VERSION, 5);
|
assert_eq!(PROTOCOL_VERSION, 6);
|
||||||
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use super::*;
|
|||||||
|
|
||||||
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
|
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
|
||||||
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
|
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
|
||||||
/// `steamapps/libraryfolders.vdf`; artwork from the public Steam CDN by appid.
|
/// `steamapps/libraryfolders.vdf`; the user's own non-Steam shortcuts ("Add a Non-Steam Game to My
|
||||||
|
/// Library") from each account's binary `userdata/<id>/config/shortcuts.vdf`; artwork from the
|
||||||
|
/// public Steam CDN by appid, or the user's per-account `grid/` overrides (all a shortcut ever has).
|
||||||
pub struct SteamProvider;
|
pub struct SteamProvider;
|
||||||
|
|
||||||
impl LibraryProvider for SteamProvider {
|
impl LibraryProvider for SteamProvider {
|
||||||
@@ -21,7 +23,7 @@ impl LibraryProvider for SteamProvider {
|
|||||||
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
|
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
by_appid
|
let mut games: Vec<GameEntry> = by_appid
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
||||||
.map(|(appid, title)| GameEntry {
|
.map(|(appid, title)| GameEntry {
|
||||||
@@ -35,7 +37,11 @@ impl LibraryProvider for SteamProvider {
|
|||||||
value: appid.to_string(),
|
value: appid.to_string(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
|
||||||
|
// user's own custom entries are gathered separately from `shortcuts.vdf`.
|
||||||
|
games.extend(steam_shortcuts());
|
||||||
|
games
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,17 +60,26 @@ fn steam_art(appid: u32) -> Artwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
|
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
|
||||||
/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL
|
/// literally what the user's Steam client already shows for this title), then the user's per-account
|
||||||
/// as a fallback. `None` when neither has it (the client then falls through to its next art
|
/// `grid/` overrides (the *only* art a non-Steam shortcut ever has), then the legacy flat CDN URL.
|
||||||
/// candidate). Blocking (disk + network) — call off the async runtime.
|
/// `None` when none has it (the client then falls through to its next art candidate). Blocking
|
||||||
|
/// (disk + network) — call off the async runtime.
|
||||||
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||||
steam_local_art_bytes(appid, kind).or_else(|| {
|
if let Some(local) =
|
||||||
|
steam_local_art_bytes(appid, kind).or_else(|| steam_grid_art_bytes(appid, kind))
|
||||||
|
{
|
||||||
|
return Some(local);
|
||||||
|
}
|
||||||
|
// A non-Steam shortcut's appid has the high bit set (see [`shortcut_appid`]) and is never a real
|
||||||
|
// store appid, so the CDN would only 404 — skip the wasted request and fall through cleanly.
|
||||||
|
if appid & 0x8000_0000 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
|
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
|
||||||
kind.cdn_filename()
|
kind.cdn_filename()
|
||||||
);
|
);
|
||||||
fetch_image(&url)
|
fetch_image(&url)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
|
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
|
||||||
@@ -113,6 +128,59 @@ fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Artwork a user set in Steam itself for a **non-Steam shortcut** (or a `grid/` override for a real
|
||||||
|
/// title), stored per-account under `userdata/<id>/config/grid/`, keyed by the same 32-bit appid the
|
||||||
|
/// shortcut carries. Tried before the CDN — a shortcut has no CDN art at all, so this is where its
|
||||||
|
/// poster lives.
|
||||||
|
fn steam_grid_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||||
|
for root in steam_roots() {
|
||||||
|
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for user in users.flatten() {
|
||||||
|
let grid = user.path().join("config").join("grid");
|
||||||
|
if let Some(path) = find_grid_art_file(&grid, appid, kind) {
|
||||||
|
if let Ok(bytes) = std::fs::read(&path) {
|
||||||
|
let ctype = if path.extension().is_some_and(|e| e == "png") {
|
||||||
|
"image/png"
|
||||||
|
} else {
|
||||||
|
"image/jpeg"
|
||||||
|
};
|
||||||
|
return Some((bytes, ctype.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `grid/` filenames Steam stores this art kind under for appid `<A>`, tried in order (PNG then
|
||||||
|
/// JPG — Steam accepts either). Pure path logic, so it's unit-testable against a directory fixture.
|
||||||
|
fn find_grid_art_file(grid: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
|
||||||
|
for name in grid_filenames(kind, appid) {
|
||||||
|
let path = grid.join(&name);
|
||||||
|
if let Ok(meta) = std::fs::metadata(&path) {
|
||||||
|
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `userdata/<id>/config/grid/` basenames Steam names each art kind under for appid `<A>`:
|
||||||
|
/// portrait `<A>p`, hero `<A>_hero`, logo `<A>_logo`, and the wide capsule `<A>` — each as `.png`
|
||||||
|
/// then `.jpg`.
|
||||||
|
fn grid_filenames(kind: ArtKind, appid: u32) -> Vec<String> {
|
||||||
|
let both = |base: String| vec![format!("{base}.png"), format!("{base}.jpg")];
|
||||||
|
match kind {
|
||||||
|
ArtKind::Portrait => both(format!("{appid}p")),
|
||||||
|
ArtKind::Hero => both(format!("{appid}_hero")),
|
||||||
|
ArtKind::Logo => both(format!("{appid}_logo")),
|
||||||
|
ArtKind::Header => both(format!("{appid}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
|
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
fn steam_roots() -> Vec<PathBuf> {
|
fn steam_roots() -> Vec<PathBuf> {
|
||||||
@@ -243,6 +311,226 @@ fn is_steam_tool(appid: u32, name: &str) -> bool {
|
|||||||
|| n.contains("steamvr")
|
|| n.contains("steamvr")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One non-Steam shortcut ("Add a Non-Steam Game to My Library"), as read from `shortcuts.vdf`.
|
||||||
|
struct Shortcut {
|
||||||
|
/// The 32-bit shortcut appid Steam assigns it (high bit set — see [`shortcut_appid`]). Keys the
|
||||||
|
/// entry id and its `grid/` artwork; the 64-bit launch id derives from it ([`shortcut_gameid`]).
|
||||||
|
appid: u32,
|
||||||
|
/// Display name (`AppName`).
|
||||||
|
name: String,
|
||||||
|
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
|
||||||
|
hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every non-Steam shortcut across all Steam accounts on this host, as launchable [`GameEntry`]s.
|
||||||
|
/// These carry no `appmanifest`, so [`scan_manifests`] never sees them — this is the only path that
|
||||||
|
/// surfaces a user's custom Steam entries. Best-effort: an unreadable/absent `shortcuts.vdf`
|
||||||
|
/// contributes nothing; hidden shortcuts and duplicate appids are dropped.
|
||||||
|
fn steam_shortcuts() -> Vec<GameEntry> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for path in shortcuts_files() {
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for sc in parse_shortcuts(&bytes) {
|
||||||
|
if seen.insert(sc.appid) {
|
||||||
|
if let Some(entry) = shortcut_entry(sc) {
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map one parsed [`Shortcut`] to a library entry, or `None` if Steam has it hidden. Launch reuses
|
||||||
|
/// the `steam_appid` recipe (`steam steam://rungameid/<id>`) — the value is the 64-bit shortcut
|
||||||
|
/// game id, not the 32-bit appid, because the plain appid won't launch a non-Steam shortcut.
|
||||||
|
fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||||
|
if sc.hidden {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GameEntry {
|
||||||
|
provider: None,
|
||||||
|
id: format!("steam:{}", sc.appid),
|
||||||
|
store: "steam".into(),
|
||||||
|
title: sc.name,
|
||||||
|
art: steam_art(sc.appid),
|
||||||
|
launch: Some(LaunchSpec {
|
||||||
|
kind: "steam_appid".into(),
|
||||||
|
value: shortcut_gameid(sc.appid).to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
|
||||||
|
/// that has signed in on this host.
|
||||||
|
fn shortcuts_files() -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for root in steam_roots() {
|
||||||
|
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for user in users.flatten() {
|
||||||
|
let path = user.path().join("config").join("shortcuts.vdf");
|
||||||
|
if path.is_file() {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||||
|
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||||
|
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||||
|
fn shortcut_gameid(appid: u32) -> u64 {
|
||||||
|
((appid as u64) << 32) | 0x0200_0000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||||
|
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||||
|
/// Steam writes it, and we prefer the stored value.
|
||||||
|
fn shortcut_appid(exe: &str, name: &str) -> u32 {
|
||||||
|
crc32(format!("{exe}{name}").as_bytes()) | 0x8000_0000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard reflected (IEEE) CRC-32 — a few short strings' worth per scan, so a table-free bitwise
|
||||||
|
/// loop is plenty. Matches what Steam uses to hash a shortcut's `exe + name`.
|
||||||
|
fn crc32(data: &[u8]) -> u32 {
|
||||||
|
let mut crc: u32 = 0xFFFF_FFFF;
|
||||||
|
for &byte in data {
|
||||||
|
crc ^= byte as u32;
|
||||||
|
for _ in 0..8 {
|
||||||
|
let mask = (crc & 1).wrapping_neg();
|
||||||
|
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!crc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a **binary** `shortcuts.vdf` into its shortcuts. The format is Steam's binary KeyValues: a
|
||||||
|
/// 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32), a NUL-terminated key, then a
|
||||||
|
/// type-specific payload; `0x08` closes the current map. The whole file is one `shortcuts` map whose
|
||||||
|
/// children (keyed `"0"`, `"1"`, …) are the individual shortcuts. Lenient and panic-free: a
|
||||||
|
/// truncated file or an unrecognized tag stops the walk and returns whatever parsed so far.
|
||||||
|
fn parse_shortcuts(buf: &[u8]) -> Vec<Shortcut> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut pos = 0usize;
|
||||||
|
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
|
||||||
|
if buf.first() != Some(&0x00) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
if read_cstr(buf, &mut pos).is_none() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Each child is a map describing one shortcut, until the map-closing `0x08`.
|
||||||
|
while let Some(&tag) = buf.get(pos) {
|
||||||
|
pos += 1;
|
||||||
|
if tag != 0x00 {
|
||||||
|
break; // `0x08` (end of shortcuts) or anything unexpected
|
||||||
|
}
|
||||||
|
if read_cstr(buf, &mut pos).is_none() {
|
||||||
|
break; // the index key ("0", "1", …)
|
||||||
|
}
|
||||||
|
match parse_one_shortcut(buf, &mut pos) {
|
||||||
|
Some(sc) => out.push(sc),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`,
|
||||||
|
/// pulling the ones we surface. `None` on a truncated/garbled entry.
|
||||||
|
fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
|
||||||
|
let mut appid: Option<u32> = None;
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut exe = String::new();
|
||||||
|
let mut hidden = false;
|
||||||
|
loop {
|
||||||
|
let tag = *buf.get(*pos)?;
|
||||||
|
*pos += 1;
|
||||||
|
if tag == 0x08 {
|
||||||
|
break; // end of this shortcut
|
||||||
|
}
|
||||||
|
let key = read_cstr(buf, pos)?.to_ascii_lowercase();
|
||||||
|
match tag {
|
||||||
|
0x00 => skip_map(buf, pos)?, // nested map (e.g. `tags`) — not needed
|
||||||
|
0x01 => {
|
||||||
|
let val = read_cstr(buf, pos)?;
|
||||||
|
match key.as_str() {
|
||||||
|
"appname" => name = val,
|
||||||
|
"exe" => exe = val,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x02 => {
|
||||||
|
let val = read_i32(buf, pos)?;
|
||||||
|
match key.as_str() {
|
||||||
|
"appid" => appid = Some(val as u32),
|
||||||
|
"ishidden" => hidden = val != 0,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x07 => *pos += 8, // uint64 — skip
|
||||||
|
_ => return None, // unknown tag: payload size unknown, can't continue safely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return None; // nothing worth showing
|
||||||
|
}
|
||||||
|
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
|
||||||
|
let appid = appid
|
||||||
|
.filter(|a| *a != 0)
|
||||||
|
.unwrap_or_else(|| shortcut_appid(&exe, &name));
|
||||||
|
Some(Shortcut {
|
||||||
|
appid,
|
||||||
|
name,
|
||||||
|
hidden,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip a nested map's contents (positioned just after its key) up to and including its `0x08`.
|
||||||
|
fn skip_map(buf: &[u8], pos: &mut usize) -> Option<()> {
|
||||||
|
loop {
|
||||||
|
let tag = *buf.get(*pos)?;
|
||||||
|
*pos += 1;
|
||||||
|
if tag == 0x08 {
|
||||||
|
return Some(());
|
||||||
|
}
|
||||||
|
read_cstr(buf, pos)?; // key
|
||||||
|
match tag {
|
||||||
|
0x00 => skip_map(buf, pos)?,
|
||||||
|
0x01 => {
|
||||||
|
read_cstr(buf, pos)?;
|
||||||
|
}
|
||||||
|
0x02 => *pos += 4,
|
||||||
|
0x07 => *pos += 8,
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a NUL-terminated UTF-8 string (lossy) starting at `pos`, advancing past the terminator.
|
||||||
|
/// `None` if the buffer ends before a NUL.
|
||||||
|
fn read_cstr(buf: &[u8], pos: &mut usize) -> Option<String> {
|
||||||
|
let start = *pos;
|
||||||
|
let end = buf.get(start..)?.iter().position(|&b| b == 0)? + start;
|
||||||
|
let s = String::from_utf8_lossy(&buf[start..end]).into_owned();
|
||||||
|
*pos = end + 1;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a little-endian int32 at `pos`, advancing 4 bytes. `None` if fewer than 4 bytes remain.
|
||||||
|
fn read_i32(buf: &[u8], pos: &mut usize) -> Option<i32> {
|
||||||
|
let bytes: [u8; 4] = buf.get(*pos..*pos + 4)?.try_into().ok()?;
|
||||||
|
*pos += 4;
|
||||||
|
Some(i32::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -338,4 +626,146 @@ mod tests {
|
|||||||
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
|
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
|
||||||
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
|
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Non-Steam shortcuts (custom Steam entries) ---
|
||||||
|
|
||||||
|
/// Build one binary-VDF field for a test `shortcuts.vdf`.
|
||||||
|
fn field_str(key: &str, val: &str) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x01u8];
|
||||||
|
v.extend_from_slice(key.as_bytes());
|
||||||
|
v.push(0);
|
||||||
|
v.extend_from_slice(val.as_bytes());
|
||||||
|
v.push(0);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
fn field_i32(key: &str, val: i32) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x02u8];
|
||||||
|
v.extend_from_slice(key.as_bytes());
|
||||||
|
v.push(0);
|
||||||
|
v.extend_from_slice(&val.to_le_bytes());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
fn map_open(key: &str) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x00u8];
|
||||||
|
v.extend_from_slice(key.as_bytes());
|
||||||
|
v.push(0);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_shortcuts_reads_entries_honors_hidden_and_key_case() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend(map_open("shortcuts"));
|
||||||
|
// Entry 0: a normal shortcut, with a nested `tags` map to exercise skip_map, and mixed-case
|
||||||
|
// keys (Steam has shipped both `AppName` and `appname`). appid stored as a negative i32.
|
||||||
|
buf.extend(map_open("0"));
|
||||||
|
buf.extend(field_i32("appid", -1838178284)); // == 2456789012 as u32
|
||||||
|
buf.extend(field_str("AppName", "My Emulator"));
|
||||||
|
buf.extend(field_str("Exe", "\"/usr/bin/foo\""));
|
||||||
|
buf.extend(field_i32("IsHidden", 0));
|
||||||
|
buf.extend(map_open("tags"));
|
||||||
|
buf.extend(field_str("0", "emulator"));
|
||||||
|
buf.push(0x08); // end tags
|
||||||
|
buf.push(0x08); // end entry 0
|
||||||
|
// Entry 1: hidden, lowercase key variant.
|
||||||
|
buf.extend(map_open("1"));
|
||||||
|
buf.extend(field_str("appname", "Hidden Game"));
|
||||||
|
buf.extend(field_i32("ishidden", 1));
|
||||||
|
buf.push(0x08); // end entry 1
|
||||||
|
buf.push(0x08); // end shortcuts
|
||||||
|
|
||||||
|
let scs = parse_shortcuts(&buf);
|
||||||
|
assert_eq!(scs.len(), 2);
|
||||||
|
assert_eq!(scs[0].appid, 2_456_789_012);
|
||||||
|
assert_eq!(scs[0].name, "My Emulator");
|
||||||
|
assert!(!scs[0].hidden);
|
||||||
|
assert_eq!(scs[1].name, "Hidden Game");
|
||||||
|
assert!(scs[1].hidden);
|
||||||
|
|
||||||
|
// A hidden shortcut is dropped from the surfaced library; a visible one launches via its
|
||||||
|
// 64-bit game id (not the bare appid).
|
||||||
|
assert!(shortcut_entry(scs.into_iter().nth(1).unwrap()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_shortcuts_is_lenient() {
|
||||||
|
assert!(parse_shortcuts(b"").is_empty()); // not even a top-level map
|
||||||
|
assert!(parse_shortcuts(b"{not binary vdf}").is_empty());
|
||||||
|
// A truncated entry (buffer ends mid-int) yields what parsed cleanly before it — here, none.
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend(map_open("shortcuts"));
|
||||||
|
buf.extend(map_open("0"));
|
||||||
|
buf.extend_from_slice(b"\x02appid\x00\x01\x02"); // 2 of 4 int bytes, then EOF
|
||||||
|
assert!(parse_shortcuts(&buf).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_entry_launches_via_rungameid() {
|
||||||
|
let sc = Shortcut {
|
||||||
|
appid: 2_456_789_012,
|
||||||
|
name: "My Emulator".into(),
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
let entry = shortcut_entry(sc).unwrap();
|
||||||
|
assert_eq!(entry.id, "steam:2456789012");
|
||||||
|
assert_eq!(entry.store, "steam");
|
||||||
|
let launch = entry.launch.unwrap();
|
||||||
|
assert_eq!(launch.kind, "steam_appid");
|
||||||
|
// Value is the 64-bit game id — digits only, so it passes the shared appid guard.
|
||||||
|
assert_eq!(launch.value, shortcut_gameid(2_456_789_012).to_string());
|
||||||
|
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_gameid_composes_appid_and_marker() {
|
||||||
|
let id = shortcut_gameid(0x8000_0000);
|
||||||
|
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
|
||||||
|
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
|
||||||
|
assert_eq!(crc32(b"123456789"), 0xCBF4_3926); // IEEE CRC-32 check value
|
||||||
|
// A derived shortcut appid always has the high bit set (so it never collides with a real
|
||||||
|
// store appid, and its CDN art fetch is skipped).
|
||||||
|
assert_ne!(
|
||||||
|
shortcut_appid("\"/usr/bin/foo\"", "My Emulator") & 0x8000_0000,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_filenames_follow_steams_naming() {
|
||||||
|
assert_eq!(
|
||||||
|
grid_filenames(ArtKind::Portrait, 42),
|
||||||
|
vec!["42p.png", "42p.jpg"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
grid_filenames(ArtKind::Hero, 42),
|
||||||
|
vec!["42_hero.png", "42_hero.jpg"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
grid_filenames(ArtKind::Logo, 42),
|
||||||
|
vec!["42_logo.png", "42_logo.jpg"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
grid_filenames(ArtKind::Header, 42),
|
||||||
|
vec!["42.png", "42.jpg"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_grid_art_file_matches_the_userdata_grid_layout() {
|
||||||
|
let grid = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(grid.path().join("2456789012p.jpg"), b"poster").unwrap();
|
||||||
|
let found = find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Portrait).unwrap();
|
||||||
|
assert_eq!(found, grid.path().join("2456789012p.jpg"));
|
||||||
|
// Missing kinds and a zero-byte file both miss cleanly.
|
||||||
|
assert_eq!(
|
||||||
|
find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Hero),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
std::fs::write(grid.path().join("42p.png"), b"").unwrap();
|
||||||
|
assert_eq!(find_grid_art_file(grid.path(), 42, ArtKind::Portrait), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+424
-412
File diff suppressed because it is too large
Load Diff
+25
-21
@@ -2,7 +2,7 @@
|
|||||||
"name": "punktfunk-web",
|
"name": "punktfunk-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
|
"description": "punktfunk management console \u2014 TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "bun run codegen",
|
"prepare": "bun run codegen",
|
||||||
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
|
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
|
||||||
@@ -19,48 +19,52 @@
|
|||||||
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
|
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-router": "^1.170.17",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
"@tanstack/react-start": "^1.168.27",
|
"@tanstack/react-start": "^1.168.32",
|
||||||
"@unom/style": "^0.4.4",
|
"@unom/style": "^0.4.4",
|
||||||
"@unom/ui": "^0.8.16",
|
"@unom/ui": "^0.8.16",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"radix-ui": "^1.6.2",
|
"radix-ui": "^1.6.4",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.10.0",
|
||||||
"tailwind-merge": "^2.6.1",
|
"tailwind-merge": "^2.6.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.2",
|
"@biomejs/biome": "^2.5.5",
|
||||||
"@inlang/paraglide-js": "^2.20.2",
|
"@inlang/paraglide-js": "^2.22.0",
|
||||||
"@inlang/plugin-message-format": "^4.4.0",
|
"@inlang/plugin-message-format": "^4.4.0",
|
||||||
"@storybook/react-vite": "^10.4.6",
|
"@storybook/react-vite": "^10.5.3",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||||
"@types/node": "^22.20.0",
|
"@types/node": "^22.20.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"orval": "^8.20.0",
|
"orval": "^8.22.0",
|
||||||
"playwright": "^1.61.1",
|
"playwright": "^1.61.1",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.5.3",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.6",
|
"vite": "^7.3.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"tar": "^7.5.21",
|
||||||
|
"dompurify": "^3.4.12",
|
||||||
|
"linkify-it": "^5.0.2",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"fast-uri": "^3.1.4",
|
||||||
|
"immutable": "^4.3.9",
|
||||||
"undici": "^7.28.0",
|
"undici": "^7.28.0",
|
||||||
"dompurify": "^3.4.11",
|
"postcss": "^8.5.10",
|
||||||
"postcss": "^8.5.16",
|
"js-yaml": "^4.3.0"
|
||||||
"esbuild": "^0.28.1",
|
|
||||||
"js-yaml": "^4.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user