chore: add proper error serialization

This commit is contained in:
LynithDev 2025-06-09 18:41:39 +02:00
parent 17734ca1b9
commit cbfb32e991
25 changed files with 425 additions and 138 deletions

View file

@ -16,7 +16,8 @@
"build": "tauri build",
"dev": "tauri dev",
"tauri": "tauri",
"tauri-ext": "pnpm --filter @onelauncher/scripts -- tauri"
"tauri-ext": "pnpm --filter @onelauncher/scripts -- tauri",
"cargo": "cargo"
},
"dependencies": {
"@oneclient/frontend": "workspace:*"

View file

@ -5,58 +5,45 @@ import { createTauRPCProxy as createProxy } from 'taurpc';
type TAURI_CHANNEL<T> = (response: T) => void;
export type ClusterError = { InvalidVersion: string } | 'MissingJavaVersion' | 'ClusterDownloading' | 'ClusterAlreadyRunning';
export type ClusterError = { type: 'InvalidVersion'; data: string } | { type: 'MissingJavaVersion'; data: string } | { type: 'ClusterDownloading'; data: string } | { type: 'ClusterAlreadyRunning'; data: string };
export type ClusterStage = 'notready' | 'downloading' | 'repairing' | 'ready';
export interface CreateCluster { name: string; mc_version: string; mc_loader: GameLoader; mc_loader_version: string | null; icon: Icon | null }
export type CryptoError = { InvalidHash: { algorithm: HashAlgorithm; expected: string; actual: string } } | 'InvalidAlgorithm';
export type CryptoError = { type: 'InvalidHash'; data: string } | { type: 'InvalidAlgorithm'; data: string };
export type DaoError = 'NotFound' | 'AlreadyExists' | { InvalidValue: [string, string] };
export type DaoError = { type: 'NotFound'; data: string } | { type: 'AlreadyExists'; data: string } | { type: 'InvalidValue'; data: string };
export type DirectoryError = 'BaseDir';
export interface DirectoryError { type: 'BaseDir'; data: string }
export type DiscordError = 'MissingClientId' | 'ConnectError';
export type DiscordError = { type: 'MissingClientId'; data: string } | { type: 'ConnectError'; data: string };
export type GameLoader = 'vanilla' | 'forge' | 'neoforge' | 'quilt' | 'fabric' | 'legacyfabric';
export type HashAlgorithm = 'Sha1' | 'Sha256' | 'Md5';
/**
* A wrapper around generic and unhelpful [`std::io::Error`] messages.
*/
export type IOError = { InvalidAbsolutePath: string } | { IOErrorWrapper: { path: string } } | 'IOError' | 'DeserializeError' | 'AsyncZipError';
export type IOError = { type: 'InvalidAbsolutePath'; data: string } | { type: 'IOErrorWrapper'; data: string } | { type: 'IOError'; data: string } | { type: 'DeserializeError'; data: string } | { type: 'AsyncZipError'; data: string };
export type Icon = string;
export type IncompatiblePackageType = 'McVersion' | 'Loader';
export type IncompatiblePackageType = { type: 'McVersion'; data: string } | { type: 'Loader'; data: string };
export type IngressError = 'NotFound';
export interface IngressError { type: 'NotFound'; data: string }
export interface IngressPayload { id: string; message: string; ingress_type: IngressType; percent: number | null; total: number }
export type IngressType = { Download: { file_name: string } } | 'JavaPrepare' | 'JavaCheck' | 'JavaLocate' | 'MinecraftDownload' | { PrepareCluster: { cluster_name: string } };
export type JavaError = { ParseVersion: [string] } | 'Execute' | 'MissingJava';
export type JavaError = { type: 'ParseVersion'; data: string } | { type: 'Execute'; data: string } | { type: 'MissingJava'; data: string };
export type LauncherError = { type: 'DirError'; data: DirectoryError } | { type: 'IOError'; data: IOError } | { type: 'IngressError'; data: IngressError } | { type: 'JavaError'; data: JavaError } | { type: 'CryptoError'; data: CryptoError } | { type: 'DiscordError'; data: DiscordError } | { type: 'MetadataError'; data: MetadataError } | { type: 'ClusterError'; data: ClusterError } | { type: 'MinecraftAuthError'; data: MinecraftAuthError } | { type: 'ProcessError'; data: ProcessError } | { type: 'PackageError'; data: PackageError } | { type: 'DaoError'; data: DaoError } | { type: 'SerdeError' } | { type: 'AnyhowError' } | { type: 'DbError' } | { type: 'ReqwestError' } | { type: 'InterpulseError' } | { type: 'RegexError' } | { type: 'SemaphoreError' } | { type: 'UrlError' } | { type: 'TauriError' };
export type LauncherError = { type: 'DirError'; data: DirectoryError } | { type: 'IOError'; data: IOError } | { type: 'IngressError'; data: IngressError } | { type: 'JavaError'; data: JavaError } | { type: 'CryptoError'; data: CryptoError } | { type: 'DiscordError'; data: DiscordError } | { type: 'MetadataError'; data: MetadataError } | { type: 'ClusterError'; data: ClusterError } | { type: 'MinecraftAuthError'; data: MinecraftAuthError } | { type: 'ProcessError'; data: ProcessError } | { type: 'PackageError'; data: PackageError } | { type: 'DaoError'; data: DaoError } | { type: 'SerdeError'; data: string } | { type: 'AnyhowError'; data: string } | { type: 'DbError'; data: string } | { type: 'ReqwestError'; data: string } | { type: 'InterpulseError'; data: string } | { type: 'RegexError'; data: string } | { type: 'SemaphoreError'; data: string } | { type: 'UrlError'; data: string } | { type: 'TauriError'; data: string };
export type MessageLevel = 'Info' | 'Warn' | 'Error';
export interface MessagePayload { level: MessageLevel; message: string }
export type MetadataError = 'FetchError' | { NotModdedManifest: GameLoader } | { NotVanillaManifest: GameLoader } | 'ParseError' | 'NoMatchingLoader' | 'NoMatchingVersion';
export type MetadataError = { type: 'FetchError'; data: string } | { type: 'NotModdedManifest'; data: string } | { type: 'NotVanillaManifest'; data: string } | { type: 'ParseError'; data: string } | { type: 'NoMatchingLoader'; data: string } | { type: 'NoMatchingVersion'; data: string };
/**
* Wrapper around all `Error`s that can occur during the Microsoft authentication process.
*/
export type MinecraftAuthError = 'PublicKeyReading' | 'PKCS8Error' | { SerializeError: { step: MinecraftAuthStep } } | { DeserializeError: { step: MinecraftAuthStep; raw: string } } | { RequestError: { step: MinecraftAuthStep } } | { SigningError: { step: MinecraftAuthStep } } | 'HashError' | 'SessionIdError';
/**
* An ordered list of all MSA authentication steps.
*/
export type MinecraftAuthStep = 'DeviceToken' | 'SisuAuthenicate' | 'OAuthToken' | 'RefreshOAuthToken' | 'SisuAuthorize' | 'XstsAuthorize' | 'MinecraftToken' | 'MinecraftEntitlements' | 'MinecraftProfile';
export type MinecraftAuthError = { type: 'PublicKeyReading'; data: string } | { type: 'PKCS8Error'; data: string } | { type: 'SerializeError'; data: string } | { type: 'DeserializeError'; data: string } | { type: 'RequestError'; data: string } | { type: 'SigningError'; data: string } | { type: 'HashError'; data: string } | { type: 'SessionIdError'; data: string };
/**
* A structure of all needed Minecraft credentials for logging in and account management.
@ -87,11 +74,11 @@ export interface MinecraftCredentials {
export interface Model { id: bigint; folder_name: string; stage: ClusterStage; created_at: string; group_id: bigint | null; name: string; mc_version: string; mc_loader: GameLoader; mc_loader_version: string | null; last_played: string | null; overall_played: bigint | null; icon_url: Icon | null; setting_profile_name: string | null; linked_pack_id: string | null; linked_pack_version: string | null }
export type PackageError = 'NoPrimaryFile' | 'IsModPack' | { Incompatible: IncompatiblePackageType };
export type PackageError = { type: 'NoPrimaryFile'; data: string } | { type: 'IsModPack'; data: string } | { type: 'Incompatible'; data: IncompatiblePackageType };
export interface Process { pid: number; started_at: string; cluster_id: bigint; post_hook: string | null; account_id: string }
export type ProcessError = { HookUnsuccessful: number } | 'NoPid';
export type ProcessError = { type: 'HookUnsuccessful'; data: string } | { type: 'NoPid'; data: string };
export type ProcessPayload = { type: 'Starting'; command: string } | { type: 'Started'; process: Process } | { type: 'Stopped'; pid: number; exit_code: number } | { type: 'Output'; pid: number; output: string };
@ -101,11 +88,11 @@ export interface SettingProfileModel { name: string; java_id: bigint | null; res
export interface SettingsOsExtra { enable_gamemode: boolean | null }
const ARGS_MAP = { oneclient: '{"open_dev_tools":[],"return_error":[]}', core: '{"getClusters":[],"createCluster":["options"],"getGlobalProfile":[],"getClusterById":["id"],"openMsaLogin":[],"getUsers":[],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getUser":["uuid"],"getProfileOrDefault":["name"],"removeUser":["uuid"],"removeCluster":["id"],"launchCluster":["id","uuid"]}', events: '{"message":["event"],"process":["event"],"ingress":["event"]}' };
const ARGS_MAP = { events: '{"message":["event"],"ingress":["event"],"process":["event"]}', core: '{"getUser":["uuid"],"getDefaultUser":["fallback"],"createCluster":["options"],"getClusters":[],"launchCluster":["id","uuid"],"setDefaultUser":["uuid"],"getGlobalProfile":[],"getProfileOrDefault":["name"],"getUsers":[],"openMsaLogin":[],"removeUser":["uuid"],"removeCluster":["id"],"getClusterById":["id"]}', oneclient: '{"return_error":[],"open_dev_tools":[]}' };
export interface Router {
events: { ingress: (event: IngressPayload) => Promise<void>; message: (event: MessagePayload) => Promise<void>; process: (event: ProcessPayload) => Promise<void> };
core: { getClusters: () => Promise<Array<Model>>; getClusterById: (id: bigint) => Promise<Model | null>; removeCluster: (id: bigint) => Promise<null>; createCluster: (options: CreateCluster) => Promise<Model>; launchCluster: (id: bigint, uuid: string | null) => Promise<null>; getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>; getGlobalProfile: () => Promise<SettingProfileModel>; getUsers: () => Promise<Array<MinecraftCredentials>>; getUser: (uuid: string) => Promise<MinecraftCredentials | null>; removeUser: (uuid: string) => Promise<null>; getDefaultUser: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; setDefaultUser: (uuid: string | null) => Promise<null>; openMsaLogin: () => Promise<MinecraftCredentials | null> };
oneclient: { return_error: () => Promise<null>; open_dev_tools: () => Promise<void> };
core: { getClusters: () => Promise<Array<Model>>; getClusterById: (id: bigint) => Promise<Model | null>; removeCluster: (id: bigint) => Promise<null>; createCluster: (options: CreateCluster) => Promise<Model>; launchCluster: (id: bigint, uuid: string | null) => Promise<null>; getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>; getGlobalProfile: () => Promise<SettingProfileModel>; getUsers: () => Promise<Array<MinecraftCredentials>>; getUser: (uuid: string) => Promise<MinecraftCredentials | null>; removeUser: (uuid: string) => Promise<null>; getDefaultUser: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; setDefaultUser: (uuid: string | null) => Promise<null>; openMsaLogin: () => Promise<MinecraftCredentials | null> };
events: { ingress: (event: IngressPayload) => Promise<void>; message: (event: MessagePayload) => Promise<void>; process: (event: ProcessPayload) => Promise<void> };
}
export type { InferCommandOutput };

View file

@ -0,0 +1,27 @@
import type { Context } from 'react';
import { createContext, useContext } from 'react';
const myContext = createContext(null) as Context<
{
text: string;
} | null
>;
export function MyContextProvider({ children, value }: {
children: React.ReactNode;
value: {
text: string;
} | null;
}) {
// eslint-disable-next-line react/no-context-provider -- ok
return <myContext.Provider value={value}>{children}</myContext.Provider>;
}
export default function useMyContext() {
const context = useContext(myContext);
if (!context)
throw new Error('useMyContext must be used within a MyContextProvider');
return context;
}

View file

@ -11,16 +11,30 @@
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as TestRouteImport } from './routes/test/route'
import { Route as IndexImport } from './routes/index'
import { Route as TestAppImport } from './routes/test/app'
// Create/Update Routes
const TestRouteRoute = TestRouteImport.update({
id: '/test',
path: '/test',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const TestAppRoute = TestAppImport.update({
id: '/app',
path: '/app',
getParentRoute: () => TestRouteRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -32,39 +46,73 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/test': {
id: '/test'
path: '/test'
fullPath: '/test'
preLoaderRoute: typeof TestRouteImport
parentRoute: typeof rootRoute
}
'/test/app': {
id: '/test/app'
path: '/app'
fullPath: '/test/app'
preLoaderRoute: typeof TestAppImport
parentRoute: typeof TestRouteImport
}
}
}
// Create and export the route tree
interface TestRouteRouteChildren {
TestAppRoute: typeof TestAppRoute
}
const TestRouteRouteChildren: TestRouteRouteChildren = {
TestAppRoute: TestAppRoute,
}
const TestRouteRouteWithChildren = TestRouteRoute._addFileChildren(
TestRouteRouteChildren,
)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/test': typeof TestRouteRouteWithChildren
'/test/app': typeof TestAppRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/test': typeof TestRouteRouteWithChildren
'/test/app': typeof TestAppRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/test': typeof TestRouteRouteWithChildren
'/test/app': typeof TestAppRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths: '/' | '/test' | '/test/app'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/test' | '/test/app'
id: '__root__' | '/' | '/test' | '/test/app'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
TestRouteRoute: typeof TestRouteRouteWithChildren
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
TestRouteRoute: TestRouteRouteWithChildren,
}
export const routeTree = rootRoute
@ -77,11 +125,22 @@ export const routeTree = rootRoute
"__root__": {
"filePath": "__root.tsx",
"children": [
"/"
"/",
"/test"
]
},
"/": {
"filePath": "index.tsx"
},
"/test": {
"filePath": "test/route.tsx",
"children": [
"/test/app"
]
},
"/test/app": {
"filePath": "test/app.tsx",
"parent": "/test"
}
}
}

View file

@ -1,3 +1,4 @@
import type { LauncherError } from '@/bindings.gen';
import { bindings } from '@/main';
import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
@ -16,15 +17,33 @@ function App() {
// mc_loader_version: '11.15.1.2318',
// });
await bindings.core.launchCluster(2 as unknown as bigint, null);
try {
// await bindings.core.launchCluster(9 as unknown as bigint, null);
await bindings.core.removeCluster(9 as unknown as bigint);
const unlisten = await bindings.events.process.on((e) => {
if (e.type === 'Output') { console.log(e.output); }
else if (e.type === 'Stopped') {
console.log(e.exit_code);
unlisten();
}
});
const unlisten = await bindings.events.process.on((e) => {
if (e.type === 'Output') { console.log(e.output); }
else if (e.type === 'Stopped') {
console.log(e.exit_code);
unlisten();
}
});
}
catch (err: unknown) {
let error = err as LauncherError;
// console.log(error);
// if (error.type === 'DaoError')
// if (error.data.type === 'NotFound')
// console.log(error.data.data);
if (error.type === 'TauriError')
console.error(error.data);
else if (error.type === 'IOError')
console.error(error.data.data);
else if (error.type === 'DaoError')
console.error(error.data);
console.log(error);
}
};
return (

View file

@ -0,0 +1,21 @@
import useMyContext from '@/components/MyContext';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/test/app')({
component: RouteComponent,
});
function RouteComponent() {
const myContext = useMyContext();
return (
<div>
<div>Hello "/test/app"!</div>
<div>
Value from MyContext:
{' '}
{myContext.text}
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import { MyContextProvider } from '@/components/MyContext';
import { createFileRoute, Outlet } from '@tanstack/react-router';
export const Route = createFileRoute('/test')({
component: RouteComponent,
});
function RouteComponent() {
return (
<MyContextProvider value={{ text: 'Hello from cool!' }}>
<div>Hello "/test"!</div>
<Outlet />
</MyContextProvider>
);
}

View file

@ -6,7 +6,6 @@ use onelauncher_entity::{clusters, java_versions};
use onelauncher_entity::icon::Icon;
use onelauncher_entity::loader::GameLoader;
use sea_orm::ActiveValue::Set;
use serde::Serialize;
use tokio::process::Command;
use crate::api::game::arguments;
@ -25,8 +24,8 @@ pub use sync::*;
use super::ingress::init_ingress;
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum ClusterError {
#[error("version '{0}' was not found")]
InvalidVersion(String),

View file

@ -119,7 +119,7 @@ pub async fn launch_minecraft(
arguments::java_arguments(
args.get(&interpulse::api::minecraft::ArgumentType::Jvm)
.map(Vec::as_slice),
&dirs.natives_dir().join(&version_name),
&dirs.natives_dir().join(&version.id),
&dirs.libraries_dir(),
&arguments::classpaths(
&dirs.libraries_dir(),

View file

@ -12,13 +12,13 @@ use crate::utils::io;
pub mod dao;
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum JavaError {
#[error("failed to parse version '{0}'")]
ParseVersion(String, #[source] #[serde(skip)] std::num::ParseIntError),
ParseVersion(String, #[source] #[skip] std::num::ParseIntError),
#[error("failed to execute java command")]
Execute(#[from] #[serde(skip)] std::io::Error),
Execute(#[from] #[skip] std::io::Error),
#[error("no java installations found")]
MissingJava,
}

View file

@ -3,7 +3,6 @@ use onelauncher_entity::icon::Icon;
use onelauncher_entity::package::PackageType;
use onelauncher_entity::{clusters, packages};
use reqwest::Method;
use serde::Serialize;
use crate::error::{LauncherError, LauncherResult};
use crate::store::Dirs;
@ -19,8 +18,8 @@ pub mod provider;
#[cfg(test)]
mod tests;
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("no file marked for download")]
NoPrimaryFile,
@ -30,8 +29,8 @@ pub enum PackageError {
Incompatible(#[from] IncompatiblePackageType),
}
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum IncompatiblePackageType {
#[error("package is not compatible with the current version")]
McVersion,

View file

@ -1,8 +1,5 @@
use serde::Serialize;
#[onelauncher_macro::specta]
#[derive(thiserror::Error, Debug, Serialize)]
#[serde(tag = "type", content = "data")]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum LauncherError {
#[error(transparent)]
DirError(#[from] crate::store::DirectoryError),
@ -30,41 +27,31 @@ pub enum LauncherError {
DaoError(#[from] DaoError),
#[error("json error: {0}")]
SerdeError(#[from] #[serde(skip)] serde_json::Error),
SerdeError(#[from] #[skip] serde_json::Error),
#[error(transparent)]
AnyhowError(#[from] #[serde(skip)] anyhow::Error),
AnyhowError(#[from] #[skip] anyhow::Error),
#[error("database error: {0}")]
DbError(#[from] #[serde(skip)] sea_orm::DbErr),
DbError(#[from] #[skip] sea_orm::DbErr),
#[error("http error: {0}")]
ReqwestError(#[from] #[serde(skip)] reqwest::Error),
ReqwestError(#[from] #[skip] reqwest::Error),
#[error("meta error: {0}")]
InterpulseError(#[from] #[serde(skip)] interpulse::Error),
InterpulseError(#[from] #[skip] interpulse::Error),
#[error(transparent)]
RegexError(#[from] #[serde(skip)] regex::Error),
RegexError(#[from] #[skip] regex::Error),
#[error("couldn't acquire semaphore: {0}")]
SemaphoreError(#[from] #[serde(skip)] tokio::sync::AcquireError),
SemaphoreError(#[from] #[skip] tokio::sync::AcquireError),
#[error(transparent)]
UrlError(#[from] #[serde(skip)] url::ParseError),
UrlError(#[from] #[skip] url::ParseError),
#[cfg(feature = "tauri")]
#[error(transparent)]
TauriError(#[from] #[serde(skip)] tauri::Error),
TauriError(#[from] #[skip] tauri::Error),
}
// impl serde::Serialize for LauncherError {
// fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
// where
// S: serde::Serializer,
// {
// // Serialize the error as a string
// serializer.serialize_str(&self.to_string())
// }
// }
pub type LauncherResult<T> = Result<T, LauncherError>;
#[onelauncher_macro::specta]
#[derive(thiserror::Error, Debug, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum DaoError {
#[error("entity was not found")]
NotFound,

View file

@ -1010,19 +1010,23 @@ pub enum MinecraftAuthStep {
}
/// Wrapper around all `Error`s that can occur during the Microsoft authentication process.
#[onelauncher_macro::specta]
#[derive(thiserror::Error, Debug, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum MinecraftAuthError {
#[error("failed to read public key during key generation")]
PublicKeyReading,
#[error("failed to serialize private key using PKCS8: {0}")]
PKCS8Error(#[from] #[serde(skip)] p256::pkcs8::Error),
PKCS8Error(
#[from]
#[skip]
p256::pkcs8::Error
),
#[error("failed to serialize JSON during MSA step {step:?}: {source}")]
SerializeError {
step: MinecraftAuthStep,
#[source]
#[serde(skip)]
#[skip]
source: serde_json::Error,
},
#[error(
@ -1033,24 +1037,24 @@ pub enum MinecraftAuthError {
raw: String,
#[source]
#[serde(skip)]
#[skip]
source: serde_json::Error,
#[serde(skip)]
#[skip]
status_code: reqwest::StatusCode,
},
#[error("failed to request using HTTP during MSA step {step:?}: {source}")]
RequestError {
step: MinecraftAuthStep,
#[source]
#[serde(skip)]
#[skip]
source: reqwest::Error,
},
#[error("failed to create signed buffer during MSA step {step:?}: {source}")]
SigningError {
step: MinecraftAuthStep,
#[source]
#[serde(skip)]
#[skip]
source: std::io::Error,
},
#[error("failed to read user hash")]

View file

@ -1,7 +1,6 @@
use std::path::PathBuf;
use onelauncher_entity::package::{PackageType, Provider};
use serde::Serialize;
use tokio::sync::OnceCell;
use crate::{utils, LauncherResult};
@ -16,8 +15,8 @@ pub struct Dirs {
base_dir: PathBuf,
}
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum DirectoryError {
#[error("Failed to get the base directory for the launcher.")]
BaseDir,

View file

@ -1,7 +1,6 @@
use std::sync::{atomic::AtomicBool, Arc};
use discord_rich_presence::{activity::{Activity, Assets, Button, Timestamps}, DiscordIpc, DiscordIpcClient};
use serde::Serialize;
use tokio::sync::RwLock;
use crate::error::LauncherResult;
@ -107,8 +106,8 @@ impl DiscordRPC {
}
}
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum DiscordError {
#[error("Discord client ID is missing")]
MissingClientId,

View file

@ -15,8 +15,8 @@ pub struct IngressProcessor {
ingress_feeds: RwLock<HashMap<Uuid, Ingress>>,
}
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum IngressError {
#[error("ingress not found")]
NotFound,

View file

@ -31,8 +31,8 @@ struct MetadataInner {
// legacyfabric: Option<ModdedManifest>,
}
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum MetadataError {
#[error("failed to fetch metadata")]
FetchError,
@ -41,7 +41,11 @@ pub enum MetadataError {
#[error("loader {0} does not use a vanilla manifest")]
NotVanillaManifest(GameLoader),
#[error("failed to parse metadata: {0}")]
ParseError(#[from] #[serde(skip)] serde_json::Error),
ParseError(
#[from]
#[skip]
serde_json::Error
),
#[error("no matching loader found")]
NoMatchingLoader,
#[error("no matching version found")]

View file

@ -24,8 +24,8 @@ use crate::utils::io::IOError;
use super::State;
use super::credentials::MinecraftCredentials;
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum ProcessError {
#[error("launch hook exited with non-zero code {0}")]
HookUnsuccessful(i32),

View file

@ -8,8 +8,8 @@ use tokio::io::AsyncReadExt;
use super::io::IOError;
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum CryptoError {
#[error("expected {algorithm} hash {expected}, but got {actual}")]
InvalidHash {

View file

@ -13,8 +13,8 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::compat::TokioAsyncWriteCompatExt;
/// A wrapper around generic and unhelpful [`std::io::Error`] messages.
#[onelauncher_macro::specta]
#[derive(Debug, thiserror::Error, Serialize)]
#[onelauncher_macro::error]
#[derive(Debug, thiserror::Error)]
pub enum IOError {
#[error("invalid absolute path '{0}'")]
InvalidAbsolutePath(PathBuf),
@ -22,16 +22,16 @@ pub enum IOError {
#[error("error acessing path: {source}, path: {path}")]
IOErrorWrapper {
#[source]
#[serde(skip)]
#[skip]
source: std::io::Error,
path: String,
},
#[error(transparent)]
IOError(#[from] #[serde(skip)] std::io::Error),
IOError(#[from] #[skip] std::io::Error),
#[error("json deserialization error: {0}")]
DeserializeError(#[from] #[serde(skip)] serde_json::Error),
DeserializeError(#[from] #[skip] serde_json::Error),
#[error(transparent)]
AsyncZipError(#[from] #[serde(skip)] async_zip::error::ZipError),
AsyncZipError(#[from] #[skip] async_zip::error::ZipError),
}
impl<P: AsRef<std::path::Path>> From<(P, std::io::Error)> for IOError {

179
packages/macro/src/error.rs Normal file
View file

@ -0,0 +1,179 @@
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{parse_macro_input, Data, DeriveInput, Fields};
pub fn error_attr(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as DeriveInput);
let Data::Enum(data_enum) = &input.data else {
return syn::Error::new_spanned(&input.ident, "Only enums are supported")
.to_compile_error()
.into();
};
let ident = &input.ident;
let mut serialize_arms = Vec::new();
for variant in &data_enum.variants {
let v_ident = &variant.ident;
match &variant.fields {
Fields::Unit => {
serialize_arms.push(quote! {
Self::#v_ident => {
let mut s = serializer.serialize_struct(stringify!(#ident), 2)?;
s.serialize_field("type", stringify!(#v_ident))?;
s.serialize_field("data", &self.to_string())?;
s.end()
}
});
}
Fields::Unnamed(fields_unnamed) => {
if fields_unnamed.unnamed.len() == 1 {
let field = &fields_unnamed.unnamed[0];
let mut has_from = false;
let mut has_skip = false;
for attr in &field.attrs {
if attr.path().is_ident("from") {
has_from = true;
} else if attr.path().is_ident("skip") {
has_skip = true;
}
}
if has_from && !has_skip {
serialize_arms.push(quote! {
Self::#v_ident(inner) => {
let mut s = serializer.serialize_struct(stringify!(#ident), 2)?;
s.serialize_field("type", stringify!(#v_ident))?;
s.serialize_field("data", inner)?;
s.end()
}
});
} else {
serialize_arms.push(quote! {
Self::#v_ident(_) => {
let mut s = serializer.serialize_struct(stringify!(#ident), 2)?;
s.serialize_field("type", stringify!(#v_ident))?;
s.serialize_field("data", &self.to_string())?;
s.end()
}
});
}
} else {
serialize_arms.push(quote! {
Self::#v_ident(..) => {
let mut s = serializer.serialize_struct(stringify!(#ident), 2)?;
s.serialize_field("type", stringify!(#v_ident))?;
s.serialize_field("data", &self.to_string())?;
s.end()
}
});
}
}
Fields::Named(_) => {
serialize_arms.push(quote! {
Self::#v_ident { .. } => {
let mut s = serializer.serialize_struct(stringify!(#ident), 2)?;
s.serialize_field("type", stringify!(#v_ident))?;
s.serialize_field("data", &self.to_string())?;
s.end()
}
});
}
}
}
let serialize_impl = quote! {
impl serde::Serialize for #ident {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
use serde::ser::SerializeStruct;
match self {
#(#serialize_arms),*
}
}
}
};
let output = quote! {
#[cfg_attr(feature = "specta", derive(onelauncher_macro::SerializedError))]
#input
#serialize_impl
};
output.into()
}
pub fn error_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let enum_name = &input.ident;
let specta_name = format_ident!("{}SpectaType", enum_name);
let mut variants = vec![];
let Data::Enum(data_enum) = &input.data else {
return syn::Error::new_spanned(enum_name, "Only enums are supported")
.to_compile_error()
.into();
};
for variant in &data_enum.variants {
let name = &variant.ident;
let ty = match &variant.fields {
Fields::Unit => quote! { String },
Fields::Unnamed(fields) => {
// Handle newtype-style or tuple-like enums
if fields.unnamed.len() == 1 {
let field = &fields.unnamed[0];
let field_ty = &field.ty;
let has_from = field
.attrs
.iter()
.any(|a| a.path().is_ident("from"));
let has_skip = field
.attrs
.iter()
.any(|a| a.path().is_ident("skip"));
if has_from && !has_skip {
quote! {
#field_ty
}
} else {
quote! { String }
}
} else {
quote! { String }
}
}
Fields::Named(_) => {
quote! { String } // Simplification: treat all struct-like variants as String
}
};
variants.push(quote! {
#name(#ty)
});
}
let enum_name_str = enum_name.to_string();
let output = quote! {
#[derive(Debug, specta::Type, serde::Serialize)]
#[specta(remote = #enum_name)]
#[specta(tag = "type", content = "data")]
#[specta(rename = #enum_name_str)]
pub enum #specta_name {
#(#variants,)*
}
};
output.into()
}

View file

@ -2,7 +2,7 @@
mod specta;
#[cfg(feature = "tauri")]
mod tauri;
mod error;
mod pin;
@ -14,13 +14,13 @@ mod pin;
/// #[cfg_attr(feature = "specta", derive(specta::Type))]
/// ```
///
/// ## `with_event` argument
/// Add `#[specta(with_event)]` to add the `tauri_specta::Event` derive.
/// ## `rename` attribute
/// Add `rename = ''` attribute to rename the type.
///
/// #### Expands to:
/// ```
/// #[cfg_attr(feature = "specta", derive(specta::Type))]
/// #[cfg_attr(feature = "tauri", derive(tauri_specta::Event))]
/// #[cfg_attr(feature = "specta", specta(rename = #rename))]
/// ```
#[proc_macro_attribute]
#[cfg(feature = "specta")]
@ -28,16 +28,16 @@ pub fn specta(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> p
specta::specta(attr, item)
}
/// This macro is used to derive the `tauri::command` as well as the `specta::specta` attribute for the annotated item.
/// #### Expands to:
/// ```
/// #[specta::specta]
/// #[tauri::command]
/// ```
/// This macro is used to implement the `serde::Serialize` trait for the annotated item.
/// It only works for enums and tries to serialize them so that taurpc can use them properly.
#[proc_macro_derive(SerializedError, attributes(from, skip))]
pub fn serialized_error_derive(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
error::error_derive(item)
}
#[proc_macro_attribute]
#[cfg(feature = "tauri")]
pub fn command(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
tauri::tauri_command(attr, item)
pub fn error(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
error::error_attr(attr, item)
}
/// This macro Box::Pin a function

View file

@ -1,14 +0,0 @@
use quote::quote;
use syn::{parse_macro_input, Item};
pub fn tauri_command(_attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(item as Item);
let expanded = quote! {
#[specta::specta]
#[tauri::command]
#input
};
expanded.into()
}

View file

@ -10,6 +10,7 @@ use tokio::task::spawn_blocking;
/// A wrapper around generic and unhelpful [`std::io::Error`] messages.
#[derive(Debug, thiserror::Error)]
#[serde(tag = "type", content = "data")]
pub enum IOError {
/// A wrapped [`std::io::Error`] along with the path involved in the error.
#[error("error acessing path: {source}, path: {path}")]
@ -36,6 +37,7 @@ impl<P: AsRef<std::path::Path>> From<(P, std::io::Error)> for IOError {
}
#[derive(Debug, thiserror::Error)]
#[serde(tag = "type", content = "data")]
#[error("received a non UTF-8 path: <lossy_path='{}'>", .0.to_string_lossy())]
pub struct NonUtf8PathError(pub Box<std::path::Path>);

0
test.rs Normal file
View file