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
7 changes: 7 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri = { version = "2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-single-instance = "2"
Expand Down
32 changes: 9 additions & 23 deletions src-tauri/src/commands/steamgriddb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ use crate::error::AppError;
use std::fs;
use std::path::{Path, PathBuf};

use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager, State};
use uuid::Uuid;
Expand Down Expand Up @@ -348,18 +346,6 @@ pub(crate) fn image_extension(url: &str) -> &'static str {
}
}

fn image_mime(ext: &str) -> &'static str {
match ext {
"jpg" => "image/jpeg",
"webp" => "image/webp",
_ => "image/png",
}
}

fn image_data_url(bytes: &[u8], ext: &str) -> String {
format!("data:{};base64,{}", image_mime(ext), STANDARD.encode(bytes))
}

/// The cache path for a game's asset of a given `kind` ("" for the cover,
/// kept suffix-less for backward compatibility with already-cached files;
/// "_icon" etc. for anything added since).
Expand Down Expand Up @@ -482,8 +468,10 @@ pub async fn set_game_cover(
Ok(updated)
}

/// Reads a game's cached cover, if any, as a `data:image/...;base64,...`
/// URI. Returns `Ok(None)` (rather than an error) both when the game has no
/// The path of a game's cached cover, if any. The frontend loads the file
/// through the asset protocol (scoped to the artwork directory in
/// tauri.conf.json) instead of receiving its bytes base64-encoded over IPC.
/// Returns `Ok(None)` (rather than an error) both when the game has no
/// cover set and when the cache file is unexpectedly missing, since either
/// case just means the card should fall back to showing no cover.
#[tauri::command(async)]
Expand All @@ -506,8 +494,7 @@ pub fn get_game_cover(app: AppHandle, state: State<ConfigState>, game_id: String
return Ok(None);
}

let bytes = fs::read(&path).map_err(|e| format!("Could not read cover file: {e}"))?;
Ok(Some(image_data_url(&bytes, ext)))
Ok(Some(path.to_string_lossy().into_owned()))
}

/// Clears a game's cover and deletes its cached file. Deliberately keeps
Expand Down Expand Up @@ -564,9 +551,9 @@ pub async fn set_game_icon(
Ok(updated)
}

/// Reads a game's cached icon, if any, as a `data:image/...;base64,...`
/// URI. Returns `Ok(None)` both when the game has no icon set and when the
/// cache file is unexpectedly missing.
/// The path of a game's cached icon, if any, for the asset protocol like
/// `get_game_cover`. Returns `Ok(None)` both when the game has no icon set
/// and when the cache file is unexpectedly missing.
#[tauri::command(async)]
pub fn get_game_icon(app: AppHandle, state: State<ConfigState>, game_id: String) -> Result<Option<String>, AppError> {
let icon_url = {
Expand All @@ -587,8 +574,7 @@ pub fn get_game_icon(app: AppHandle, state: State<ConfigState>, game_id: String)
return Ok(None);
}

let bytes = fs::read(&path).map_err(|e| format!("Could not read icon file: {e}"))?;
Ok(Some(image_data_url(&bytes, ext)))
Ok(Some(path.to_string_lossy().into_owned()))
}

/// Clears a game's SteamGridDB icon and deletes its cached file. Keeps
Expand Down
12 changes: 1 addition & 11 deletions src-tauri/src/commands/steamgriddb/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,11 @@ fn artwork_kinds_map_to_expected_endpoints() {
}

#[test]
fn detects_image_extensions_and_mime_types() {
fn detects_image_extensions() {
assert_eq!(image_extension("https://cdn/foo/bar.jpg?size=600"), "jpg");
assert_eq!(image_extension("https://cdn/foo/bar.JPEG"), "jpg");
assert_eq!(image_extension("https://cdn/foo/bar.webp"), "webp");
assert_eq!(image_extension("https://cdn/foo/bar.unknown"), "png");
assert_eq!(image_mime("jpg"), "image/jpeg");
assert_eq!(image_mime("webp"), "image/webp");
assert_eq!(image_mime("png"), "image/png");
}

#[test]
fn image_data_url_uses_matching_mime_and_base64() {
assert_eq!(image_data_url(b"\xff\xd8", "jpg"), "data:image/jpeg;base64,/9g=");
assert_eq!(image_data_url(b"RIFF", "webp"), "data:image/webp;base64,UklGRg==");
assert_eq!(image_data_url(b"PNG", "png"), "data:image/png;base64,UE5H");
}

#[test]
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/artwork/*"]
}
}
},
"bundle": {
Expand Down
31 changes: 23 additions & 8 deletions src/lib/components/GameCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,31 @@
let menuButtonEl: HTMLButtonElement | undefined;
let confirmingRemove = $state(false);

let coverDataUrl = $state<string | null>(null);
let coverSrc = $state<string | null>(null);

// Only these primitives decide which cover to show; depending on them
// instead of `game` keeps every games refresh (a new object per game)
// from fetching the cover again.
const gameId = $derived(game.id);
const coverUrl = $derived(game.cover_url);
const coverGridId = $derived(game.cover_grid_id);

$effect(() => {
if (!game.cover_url) {
coverDataUrl = null;
if (!coverUrl) {
coverSrc = null;
return;
}
getGameCover(game.id)
.then((url) => (coverDataUrl = url))
.catch(() => (coverDataUrl = null));
let stale = false;
getGameCover(gameId, coverGridId)
.then((src) => {
if (!stale) coverSrc = src;
})
.catch(() => {
if (!stale) coverSrc = null;
});
return () => {
stale = true;
};
});

function toggleMenu() {
Expand Down Expand Up @@ -79,8 +94,8 @@

<article class="card" class:running={runState?.running}>
<div class="cover-wrap">
{#if coverDataUrl}
<img class="cover" src={coverDataUrl} alt="" />
{#if coverSrc}
<img class="cover" src={coverSrc} alt="" loading="lazy" decoding="async" />
{:else}
<div class="cover placeholder">
{#if game.icon}
Expand Down
31 changes: 23 additions & 8 deletions src/lib/components/GameListRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,31 @@
let shortcutButtonEl: HTMLButtonElement | undefined;
let confirmingRemove = $state(false);

let coverDataUrl = $state<string | null>(null);
let coverSrc = $state<string | null>(null);

// Only these primitives decide which cover to show; depending on them
// instead of `game` keeps every games refresh (a new object per game)
// from fetching the cover again.
const gameId = $derived(game.id);
const coverUrl = $derived(game.cover_url);
const coverGridId = $derived(game.cover_grid_id);

$effect(() => {
if (!game.cover_url) {
coverDataUrl = null;
if (!coverUrl) {
coverSrc = null;
return;
}
getGameCover(game.id)
.then((url) => (coverDataUrl = url))
.catch(() => (coverDataUrl = null));
let stale = false;
getGameCover(gameId, coverGridId)
.then((src) => {
if (!stale) coverSrc = src;
})
.catch(() => {
if (!stale) coverSrc = null;
});
return () => {
stale = true;
};
});

function toggleShortcutMenu() {
Expand Down Expand Up @@ -79,8 +94,8 @@

<div class="row" class:running={runState?.running}>
<div class="thumb">
{#if coverDataUrl}
<img class="cover" src={coverDataUrl} alt="" />
{#if coverSrc}
<img class="cover" src={coverSrc} alt="" loading="lazy" decoding="async" />
{:else if game.icon}
<img class="icon" src={game.icon} alt="" />
{:else}
Expand Down
21 changes: 16 additions & 5 deletions src/lib/stores/steamgriddb.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { writable } from "svelte/store";
import type {
ArtworkKind,
Expand Down Expand Up @@ -46,8 +46,19 @@ export async function removeGameCover(gameId: string): Promise<void> {
await refreshGames();
}

export async function getGameCover(gameId: string): Promise<string | null> {
return await invoke<string | null>("get_game_cover", { gameId });
/**
* An asset-protocol URL for a cached artwork file, so the webview reads it
* from disk instead of receiving it base64-encoded over IPC. `version`
* changes with the chosen image and keeps the webview from showing a cached
* older file under the same path; the asset protocol ignores the query.
*/
function artworkUrl(path: string | null, version: number | null): string | null {
if (!path) return null;
return version == null ? convertFileSrc(path) : `${convertFileSrc(path)}?v=${version}`;
}

export async function getGameCover(gameId: string, version: number | null = null): Promise<string | null> {
return artworkUrl(await invoke<string | null>("get_game_cover", { gameId }), version);
}

export async function setGameIcon(
Expand All @@ -65,8 +76,8 @@ export async function removeGameIcon(gameId: string): Promise<void> {
await refreshGames();
}

export async function getGameIcon(gameId: string): Promise<string | null> {
return await invoke<string | null>("get_game_icon", { gameId });
export async function getGameIcon(gameId: string, version: number | null = null): Promise<string | null> {
return artworkUrl(await invoke<string | null>("get_game_icon", { gameId }), version);
}

export async function listSteamGridDbArtwork(
Expand Down
1 change: 0 additions & 1 deletion static/svelte.svg

This file was deleted.

6 changes: 0 additions & 6 deletions static/tauri.svg

This file was deleted.

1 change: 0 additions & 1 deletion static/vite.svg

This file was deleted.

Loading