feat: BrowserPackage version list (3/4 done)
chore: Make ErrorBoundary only work during prod
This commit is contained in:
parent
82485a509c
commit
c31f1086c7
13 changed files with 322 additions and 64 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
|
@ -4398,6 +4398,7 @@ dependencies = [
|
|||
"tracing-appender",
|
||||
"tracing-error",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid 1.10.0",
|
||||
"whoami",
|
||||
"winreg 0.52.0",
|
||||
|
|
@ -7261,9 +7262,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc103bde77870e08d5fc8765615b9615997827550b626fbc4ebbd7a1fbfe2a2"
|
||||
checksum = "7d5ad5fcfaf02cf79aa6727f6c5df38567d8dce172b00b62690c6bc46c08b7ce"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ tauri = { version = "=2.0.0-rc.3", features = [
|
|||
] }
|
||||
|
||||
# tauri-build is the codegen utility for our tauri buildscript.
|
||||
tauri-build = { version = "=2.0.0-rc.3" }
|
||||
tauri-build = { version = "=2.0.0-rc.7" }
|
||||
# tauri-plugin-shell adds a bridge for opening urls and paths in the frontend.
|
||||
tauri-plugin-shell = { version = "=2.0.0-rc.1" }
|
||||
# tauri-plugin-window-state saves the window position and size to be restored.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ macro_rules! collect_commands {
|
|||
// Provider Packages
|
||||
get_provider_package,
|
||||
get_provider_packages,
|
||||
get_all_provider_package_versions,
|
||||
get_provider_package_versions,
|
||||
get_provider_package_version,
|
||||
search_provider_packages,
|
||||
get_provider_authors,
|
||||
download_provider_package,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use onelauncher::cluster::content::package;
|
||||
use onelauncher::data::{Loader, ManagedPackage, ManagedUser, PackageType};
|
||||
use onelauncher::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
|
||||
use onelauncher::package::content::Providers;
|
||||
use onelauncher::store::{Author, ClusterPath, Package, PackagePath, ProviderSearchResults};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -22,6 +22,35 @@ pub async fn get_provider_packages(
|
|||
Ok(provider.get_multiple(&project_ids).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_all_provider_package_versions(
|
||||
provider: Providers,
|
||||
project_id: String,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
) -> Result<Vec<ManagedVersion>, String> {
|
||||
Ok(provider.get_all_versions(&project_id, game_versions, loaders).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_provider_package_versions(
|
||||
provider: Providers,
|
||||
versions: Vec<String>,
|
||||
) -> Result<Vec<ManagedVersion>, String> {
|
||||
Ok(provider.get_versions(versions).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_provider_package_version(
|
||||
provider: Providers,
|
||||
version: String,
|
||||
) -> Result<ManagedVersion, String> {
|
||||
Ok(provider.get_version(&version).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn search_provider_packages(
|
||||
|
|
@ -75,6 +104,9 @@ pub async fn download_provider_package(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_cluster_package(
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import type { ParentProps } from 'solid-js';
|
||||
import { ErrorBoundary as Boundary } from 'solid-js';
|
||||
import { useDebugState } from '@onelauncher/client';
|
||||
import Button from './base/Button';
|
||||
import Popup from './overlay/Popup';
|
||||
import { ErrorBoundary as Boundary, Match, Switch } from 'solid-js';
|
||||
|
||||
// TODO: Uncomment code
|
||||
function ErrorBoundary(props: ParentProps) {
|
||||
return (
|
||||
<Boundary
|
||||
fallback={err => (
|
||||
<div class="border border-danger rounded-md bg-danger/20 p-2">
|
||||
<h2>An unexpected error has occurred</h2>
|
||||
<p>{err.toString()}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</Boundary>
|
||||
<Switch>
|
||||
<Match when={import.meta.env.DEV !== true}>
|
||||
<Boundary
|
||||
fallback={err => (
|
||||
<div class="border border-danger rounded-md bg-danger/20 p-2">
|
||||
<h2>An unexpected error has occurred</h2>
|
||||
<p>{err.toString()}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</Boundary>
|
||||
</Match>
|
||||
<Match when>
|
||||
{props.children}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.button {
|
||||
@apply py-1.5 px-3 rounded-lg flex flex-row justify-center items-center gap-x-2.5;
|
||||
|
||||
&__primary {
|
||||
&__primary, &__iconPrimary {
|
||||
@apply text-onbrand hover:text-onbrand-hover active:text-onbrand-pressed disabled:text-onbrand-disabled;
|
||||
@apply bg-brand hover:bg-brand-hover active:bg-brand-pressed disabled:bg-brand-disabled;
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
@apply hover:bg-gray-05 [&[aria-checked="true"]]:bg-gray-10 active:bg-gray-10;
|
||||
}
|
||||
|
||||
&__icon, &__iconSecondary, &__iconDanger {
|
||||
&__icon, &__iconPrimary, &__iconSecondary, &__iconDanger {
|
||||
@apply p-1.5 aspect-square box-border w-8 h-8;
|
||||
|
||||
&.large {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import styles from './Button.module.scss';
|
|||
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & ParentProps & {
|
||||
iconLeft?: JSX.Element;
|
||||
iconRight?: JSX.Element;
|
||||
buttonStyle?: 'primary' | 'secondary' | 'danger' | 'icon' | 'iconSecondary' | 'iconDanger' | 'ghost';
|
||||
buttonStyle?: 'primary' | 'secondary' | 'danger' | 'icon' | 'iconPrimary' | 'iconSecondary' | 'iconDanger' | 'ghost';
|
||||
large?: boolean;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { For, type ParentProps, Show, createContext, createEffect, createSignal, useContext } from 'solid-js';
|
||||
import { For, type ParentProps, Show, createContext, createEffect, createSignal, on, useContext } from 'solid-js';
|
||||
import { A, type Params, Route, useSearchParams } from '@solidjs/router';
|
||||
import { CalendarIcon, ChevronDownIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
|
||||
import type { Cluster, ManagedPackage, ManagedUser, Providers } from '@onelauncher/client/bindings';
|
||||
import { getLicenseUrl, getPackageUrl } from '../../../utils';
|
||||
import { CalendarIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
|
||||
import type { Cluster, ManagedPackage, ManagedUser, ManagedVersion, Providers } from '@onelauncher/client/bindings';
|
||||
import { getLicenseUrl, getPackageUrl, upperFirst } from '../../../utils';
|
||||
import { bridge } from '~imports';
|
||||
import useCommand from '~ui/hooks/useCommand';
|
||||
import { abbreviateNumber, formatAsRelative } from '~utils';
|
||||
|
|
@ -37,7 +37,7 @@ function BrowserPackageRoutes() {
|
|||
return (
|
||||
<>
|
||||
<Route path="/" component={BrowserPackageBody} />
|
||||
<Route path="/gallery" component={BrowserPackageGallery} />
|
||||
{/* <Route path="/gallery" component={BrowserPackageGallery} /> */}
|
||||
<Route path="/versions" component={BrowserPackageVersions} />
|
||||
</>
|
||||
);
|
||||
|
|
@ -68,7 +68,7 @@ function BrowserPackage(props: ParentProps) {
|
|||
|
||||
const links = [
|
||||
['About', '/'],
|
||||
['Gallery', '/gallery'],
|
||||
// ['Gallery', '/gallery'],
|
||||
['Versions', '/versions'],
|
||||
];
|
||||
|
||||
|
|
@ -170,24 +170,29 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
|
|||
<h4 class="text-fg-primary font-bold">Authors</h4>
|
||||
<For each={props.authors}>
|
||||
{author => (
|
||||
<div
|
||||
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
||||
onClick={() => promptOpen(author.url)}
|
||||
>
|
||||
<img class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} alt={`${author.username}'s avatar`} />
|
||||
<div class="flex flex-1 flex-col justify-center gap-y-1">
|
||||
<span>{author.username}</span>
|
||||
<>
|
||||
<div
|
||||
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
||||
onClick={() => promptOpen(author.url)}
|
||||
>
|
||||
<img class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} alt={`${author.username}'s avatar`} />
|
||||
<div class="flex flex-1 flex-col justify-center gap-y-1">
|
||||
<span>{author.username}</span>
|
||||
|
||||
<Show when={author.is_organization_user}>
|
||||
<span class="text-xs text-fg-secondary">Organization</span>
|
||||
</Show>
|
||||
<Show when={author.is_organization_user}>
|
||||
<span class="text-xs text-fg-secondary">Organization</span>
|
||||
</Show>
|
||||
|
||||
<Show when={author.role !== null}>
|
||||
<span class="text-xs text-fg-secondary">{author.role}</span>
|
||||
</Show>
|
||||
<Show when={author.role !== null}>
|
||||
<span class="text-xs text-fg-secondary">{author.role}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<LinkExternal01Icon class="h-4 w-4" />
|
||||
</div>
|
||||
<LinkExternal01Icon class="h-4 w-4" />
|
||||
</div>
|
||||
<Show when={author.is_organization_user === true}>
|
||||
<div class="h-px w-full bg-gray-05" />
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
|
@ -335,27 +340,190 @@ function BrowserPackageBody() {
|
|||
);
|
||||
}
|
||||
|
||||
function BrowserPackageGallery() {
|
||||
// const context = useContext(BrowserPackageContext);
|
||||
// function BrowserPackageGallery() {
|
||||
// // const context = useContext(BrowserPackageContext);
|
||||
|
||||
return (
|
||||
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
|
||||
<div>Gallery</div>
|
||||
{/* <For each={[]}>
|
||||
{image => (
|
||||
<img src={image.url} alt={image.alt} />
|
||||
)}
|
||||
</For> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// return (
|
||||
// <div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
|
||||
// <div>Gallery</div>
|
||||
// {/* <For each={[]}>
|
||||
// {image => (
|
||||
// <img src={image.url} alt={image.alt} />
|
||||
// )}
|
||||
// </For> */}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
function BrowserPackageVersions() {
|
||||
// const context = useContext(BrowserPackageContext);
|
||||
const context = useContext(BrowserPackageContext);
|
||||
const [page, setPage] = createSignal(1);
|
||||
|
||||
const MAX_ITEMS_PER_PAGE = 20;
|
||||
|
||||
const getVersionsForPage = (page: number) => {
|
||||
if (context === null)
|
||||
return [];
|
||||
|
||||
const newestToOldest = context.versions.toReversed();
|
||||
const start = (page - 1) * MAX_ITEMS_PER_PAGE;
|
||||
const end = start + MAX_ITEMS_PER_PAGE;
|
||||
|
||||
return newestToOldest.slice(start, end);
|
||||
};
|
||||
|
||||
const getMaxPage = () => {
|
||||
if (context === null)
|
||||
return 1;
|
||||
|
||||
return Math.ceil(context.versions.length / MAX_ITEMS_PER_PAGE);
|
||||
};
|
||||
|
||||
const diffFrom = (from: number = page()) => getMaxPage() - from;
|
||||
|
||||
const [versions, { refetch }] = useCommand(context, async () => {
|
||||
if (context === null)
|
||||
return { error: 'No package context', status: 'error' };
|
||||
|
||||
const list = getVersionsForPage(page());
|
||||
|
||||
return await bridge.commands.getProviderPackageVersions(context.provider, list);
|
||||
});
|
||||
|
||||
function PaginationBtn(props: { page: number }) {
|
||||
return (
|
||||
<Button
|
||||
buttonStyle={props.page === page() ? 'iconPrimary' : 'iconSecondary'}
|
||||
children={props.page}
|
||||
onClick={() => {
|
||||
if (props.page === page())
|
||||
return;
|
||||
|
||||
setPage(props.page);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let container!: HTMLDivElement;
|
||||
|
||||
createEffect(on(() => page(), () => {
|
||||
refetch();
|
||||
container.scrollIntoView({ behavior: 'smooth' });
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
|
||||
<div>versions</div>
|
||||
<div ref={container} class="w-full flex flex-1 flex-col gap-y-1">
|
||||
<h1>
|
||||
Versions - Page
|
||||
{' '}
|
||||
{page()}
|
||||
</h1>
|
||||
|
||||
<table class="w-full border-separate border-spacing-x-none border-spacing-y-1">
|
||||
<thead>
|
||||
<tr class="bg-page-elevated [&>th]:py-2 [&>th]:text-left">
|
||||
<th class="w-16 rounded-l-lg" />
|
||||
|
||||
<th>Name</th>
|
||||
<th>Game Version</th>
|
||||
<th>Loader</th>
|
||||
<th>Created</th>
|
||||
<th>Downloads</th>
|
||||
<th class="rounded-r-lg" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<For each={versions()?.reverse()}>
|
||||
{version => <VersionRow {...version} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex flex-row gap-x-1">
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<ChevronLeftIcon />}
|
||||
disabled={page() <= 1}
|
||||
onClick={() => setPage(page => page - 1)}
|
||||
/>
|
||||
|
||||
<PaginationBtn page={1} />
|
||||
|
||||
{/* TODO: Pagination */}
|
||||
{/* <Switch>
|
||||
<Match when={}
|
||||
</Switch> */}
|
||||
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<ChevronRightIcon />}
|
||||
disabled={page() >= getMaxPage()}
|
||||
onClick={() => setPage(page => page + 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionRow(props: ManagedVersion) {
|
||||
function colorForType(type: string) {
|
||||
switch (type) {
|
||||
case 'release':
|
||||
return 'bg-code-trace';
|
||||
case 'snapshot':
|
||||
return 'bg-code-debug';
|
||||
case 'beta':
|
||||
return 'bg-code-warn';
|
||||
case 'alpha':
|
||||
return 'bg-code-error';
|
||||
default:
|
||||
return 'bg-gray-05';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr class="my-2 bg-page-elevated px-4 [&>td]:py-4">
|
||||
<td class="rounded-l-lg px-4">
|
||||
<Tooltip text={upperFirst(props.version_type)}>
|
||||
<div class={`${colorForType(props.version_type)} h-8 w-8 flex items-center justify-center rounded-md bg-opacity-30`}>
|
||||
<span class="font-bold">{props.version_type.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-lg">{props.version_id}</h3>
|
||||
<p class="text-wrap text-sm">{props.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{props.game_versions.join(', ')}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{props.loaders.map(upperFirst).join(', ')}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{formatAsRelative(new Date(props.published).getTime(), 'en', 'long')}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{props.downloads}
|
||||
</td>
|
||||
|
||||
<td class="rounded-r-lg">
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<Download01Icon />}
|
||||
/>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ async-compression = { workspace = true }
|
|||
flate2 = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
nom = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
# features[tauri]
|
||||
tauri = { workspace = true, optional = true }
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ pub async fn find_managed_version(
|
|||
) -> Result<ManagedVersion> {
|
||||
let provider = package.provider.clone();
|
||||
|
||||
let versions = provider.get_versions(&package.id).await?;
|
||||
let versions = provider.get_all_versions(
|
||||
&package.id,
|
||||
game_version.as_ref().map(|v| vec![v.to_owned()]).to_owned(),
|
||||
loader.map(|l| vec![l]).clone(),
|
||||
).await?;
|
||||
|
||||
Ok(versions
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -125,9 +125,23 @@ impl Providers {
|
|||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_versions(&self, project_id: &str) -> Result<Vec<ManagedVersion>> {
|
||||
pub async fn get_all_versions(&self,
|
||||
project_id: &str,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>
|
||||
) -> Result<Vec<ManagedVersion>> {
|
||||
Ok(match self {
|
||||
Providers::Modrinth => modrinth::get_versions(project_id),
|
||||
Providers::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders),
|
||||
}
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| p.into())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_versions(&self, versions: Vec<String>) -> Result<Vec<ManagedVersion>> {
|
||||
Ok(match self {
|
||||
Providers::Modrinth => modrinth::get_versions(versions),
|
||||
}
|
||||
.await?
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -429,10 +429,38 @@ async fn get_team(team_id: String) -> Result<Vec<ManagedUser>> {
|
|||
// TODO: modrinth api v3
|
||||
// pub async fn get_org_projects(organization: &str) -> Result<>
|
||||
|
||||
pub async fn get_versions(project_id: &str) -> Result<Vec<ModrinthVersion>> {
|
||||
pub async fn get_all_versions(
|
||||
project_id: &str,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
) -> Result<Vec<ModrinthVersion>> {
|
||||
let mut url = url::Url::parse(format_url!("/project/{}/version", project_id).as_str())?;
|
||||
if let Some(game_versions) = game_versions {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("game_versions", &game_versions.iter().map(|v| format!("\"{}\"", v.to_string())).collect::<Vec<String>>().join(","));
|
||||
}
|
||||
|
||||
if let Some(loaders) = loaders {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("loaders", format!("[{}]", &loaders.iter().map(|l| format!("\"{}\"", l.to_string())).collect::<Vec<String>>().join(",")).as_str());
|
||||
}
|
||||
|
||||
Ok(serde_json::from_slice(
|
||||
&fetch(
|
||||
format_url!("/project/{}/version", project_id).as_str(),
|
||||
format_url!(
|
||||
"/project/{}/version",
|
||||
project_id).as_str(),
|
||||
None,
|
||||
&State::get().await?.fetch_semaphore,
|
||||
)
|
||||
.await?,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub async fn get_versions(versions: Vec<String>) -> Result<Vec<ModrinthVersion>> {
|
||||
Ok(serde_json::from_slice(
|
||||
&fetch(
|
||||
format_url!("/versions?ids=[{}]", versions.iter().map(|v| format!("\"{}\"", v)).collect::<Vec<String>>().join(",")).as_str(),
|
||||
None,
|
||||
&State::get().await?.fetch_semaphore,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ pub enum ErrorKind {
|
|||
#[error("failed to manage credentials with IOTA stronghold: {0}")]
|
||||
StrongholdError(#[from] crate::store::StrongholdError),
|
||||
|
||||
/// Wrapper around [`url::ParseError`] to handle URL parsing errors
|
||||
#[error("failed to parse and handle URLs: {0}")]
|
||||
URLError(#[from] url::ParseError),
|
||||
|
||||
/// Wrapper around [`tauri::Error`] to handle Tauri errors when the feature flag is enabled
|
||||
#[cfg(feature = "tauri")]
|
||||
#[error("error handling tauri: {0}")]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue