initial commit

This commit is contained in:
David Reed
2023-05-20 04:24:12 -04:00
commit 54e2054572
19 changed files with 4154 additions and 0 deletions

15
.eslintrc Normal file
View File

@@ -0,0 +1,15 @@
{
"env": { "browser": true, "es2020": true },
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
"plugins": ["react-refresh"],
"rules": {
"@typescript-eslint/consistent-type-imports": "error",
"react-refresh/only-export-components": "warn"
}
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
README.md Normal file
View File

@@ -0,0 +1,18 @@
# Client Injector
A web-based Asar preload injector.
## Features
- No .exe
- Simple
- Fast
## Quickstart
```sh
git clone https://github.com/e9x/client-injector
cd client-injector
npm install
npm run dev
```

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/patcher.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Electron Injector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3279
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "electron-injector",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@monaco-editor/react": "^4.5.1",
"bootswatch": "^5.2.3",
"clsx": "^1.2.1",
"pretty-bytes": "^6.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^3.7.2",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"sass": "^1.62.1",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}

1
public/patcher.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CCD6DD" d="M31 32c0 2.209-1.791 4-4 4H5c-2.209 0-4-1.791-4-4V4c0-2.209 1.791-4 4-4h22c2.209 0 4 1.791 4 4v28z"/><path fill="#99AAB5" d="M27 24c0 .553-.447 1-1 1H6c-.552 0-1-.447-1-1 0-.553.448-1 1-1h20c.553 0 1 .447 1 1zm-16 4c0 .553-.448 1-1 1H6c-.552 0-1-.447-1-1 0-.553.448-1 1-1h4c.552 0 1 .447 1 1zM27 8c0 .552-.447 1-1 1H6c-.552 0-1-.448-1-1s.448-1 1-1h20c.553 0 1 .448 1 1zm0 4c0 .553-.447 1-1 1H6c-.552 0-1-.447-1-1 0-.553.448-1 1-1h20c.553 0 1 .447 1 1zm0 4c0 .553-.447 1-1 1H6c-.552 0-1-.447-1-1 0-.553.448-1 1-1h20c.553 0 1 .447 1 1zm0 4c0 .553-.447 1-1 1H6c-.552 0-1-.447-1-1 0-.553.448-1 1-1h20c.553 0 1 .447 1 1z"/><path fill="#66757F" d="M31 6.272c-.827-.535-1.837-.579-2.521-.023l-.792.646-1.484 1.211-.1.08-2.376 1.938-11.878 9.686c-.437.357-.793 1.219-1.173 2.074-.378.85-.969 2.852-1.443 4.391-.148.25-1.065 1.846-.551 2.453.52.615 2.326.01 2.568-.076 1.626-.174 3.731-.373 4.648-.58.924-.211 1.854-.395 2.291-.752.008-.006.01-.018.017-.023l11.858-9.666.792-.646.144-.118V6.272z"/><path fill="#D99E82" d="M18.145 22.526s-1.274-1.881-2.117-2.553c-.672-.843-2.549-2.116-2.549-2.116-.448-.446-1.191-.48-1.629-.043-.437.438-.793 1.366-1.173 2.291-.472 1.146-1.276 4.154-1.768 5.752-.083.272.517-.45.503-.21-.01.187.027.394.074.581l-.146.159.208.067c.025.082.05.154.068.21l.159-.146c.187.047.394.084.58.074.24-.014-.483.587-.21.503 1.598-.493 4.607-1.296 5.752-1.768.924-.381 1.854-.736 2.291-1.174.439-.435.406-1.178-.043-1.627z"/><path fill="#EA596E" d="M25.312 4.351c-.876.875-.876 2.293 0 3.168l3.167 3.168c.876.874 2.294.874 3.168 0l3.169-3.168c.874-.875.874-2.293 0-3.168l-3.169-3.168c-.874-.875-2.292-.875-3.168 0l-3.167 3.168z"/><path fill="#FFCC4D" d="M11.849 17.815l3.17 3.17 3.165 3.166 11.881-11.879-6.337-6.336-11.879 11.879z"/><path fill="#292F33" d="M11.298 26.742s-2.06 1.133-2.616.576c-.557-.558.581-2.611.581-2.611s1.951.036 2.035 2.035z"/><path fill="#CCD6DD" d="M23.728 5.935l3.96-3.96 6.336 6.337-3.96 3.96z"/><path fill="#99AAB5" d="M26.103 3.558l.792-.792 6.336 6.335-.792.792zM24.52 5.142l.791-.791 6.336 6.335-.792.792z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

61
src/App.tsx Normal file
View File

@@ -0,0 +1,61 @@
import SourcePicker from "./components/SourcePicker";
import dropzoneStyles from "./dropzone.module.scss";
import prettyBytes from "pretty-bytes";
import { useDropzone } from "react-dropzone";
function App() {
const {
getRootProps,
getInputProps,
acceptedFiles,
isFocused,
isDragAccept,
isDragReject,
} = useDropzone({
multiple: false,
});
const selectedAsar =
acceptedFiles.length === 1 ? acceptedFiles[0] : undefined;
return (
<div className="container mt-5">
<h1>Electron Injector</h1>
<div
{...getRootProps({
className: dropzoneStyles.dropzone,
style: {
borderColor: isDragAccept
? "#00e676"
: isFocused
? "#2196f3"
: isDragReject
? "#ff1744"
: "#eeeeee",
},
})}
>
<input {...getInputProps()} />
{selectedAsar ? (
<>
<p>
Drag and drop another .asar file here to replace the current one
</p>
<em>
{selectedAsar.name} - {prettyBytes(selectedAsar.size)}
</em>
</>
) : (
<>
<p>Drag and drop .asar file here or click to browse</p>
<em>
No .asar file selected. Please drag and drop or click to browse.
</em>
</>
)}
</div>
<SourcePicker selectedAsar={selectedAsar} />
</div>
);
}
export default App;

211
src/Asar.ts Normal file
View File

@@ -0,0 +1,211 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace AsarRaw {
export interface FileIntegrity {
algorithm: string;
blockSize: number;
blocks: string[];
hash: string;
}
export interface File {
integrity?: FileIntegrity;
offset: string;
size: number;
}
export interface Folder {
files: Record<string, Dirent>;
}
export type Dirent = File | Folder;
export function isFolder(dirent: Dirent): dirent is Folder {
return "files" in dirent;
}
}
export interface Folder {
files: Record<string, File>;
}
export interface File extends AsarRaw.File {
newValue?: Blob;
}
export type Dirent = File | Folder;
export function isFolder(dirent: Dirent): dirent is Folder {
return "files" in dirent;
}
export async function modifyFile(file: File, newValue: Blob) {
file.newValue = newValue;
file.size = newValue.size;
file.offset = "";
if (file.integrity) {
const hash = await digestSHA256(await newValue.arrayBuffer());
file.integrity = {
algorithm: "SHA256",
blockSize: newValue.size,
blocks: [hash],
hash,
};
}
}
export async function createFile(
folder: Folder,
name: string,
newValue: Blob,
integrity = false
) {
const file: File = {
newValue,
size: newValue.size,
offset: "",
};
if (integrity) {
const hash = await digestSHA256(await newValue.arrayBuffer());
file.integrity = {
algorithm: "SHA256",
blockSize: newValue.size,
blocks: [hash],
hash,
};
}
folder.files[name] = file;
return file;
}
async function digestSHA256(data: ArrayBuffer) {
const hashBuffer = await crypto.subtle.digest("SHA-256", data); // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join(""); // convert bytes to hex string
return hashHex;
}
async function compileRoot(asar: Asar) {
const newRoot: AsarRaw.Folder = { files: {} };
const parts: BlobPart[] = [];
let partsOffset = 0;
interface StackItem {
oldFolder: Folder;
newFolder: AsarRaw.Folder;
}
const stack: StackItem[] = [
{
oldFolder: asar,
newFolder: newRoot,
},
];
let e: StackItem | undefined;
while ((e = stack.pop())) {
for (const name in e.oldFolder.files) {
const dirent = e.oldFolder.files[name];
if (isFolder(dirent)) {
const newFolder: AsarRaw.Folder = {
files: {},
};
e.newFolder.files[name] = newFolder;
stack.push({
oldFolder: dirent,
newFolder,
});
} else {
const data = asar.getFile(dirent);
const newFile: AsarRaw.File = {
offset: partsOffset.toString(),
size: data.size,
integrity: dirent.integrity,
};
parts.push(data);
partsOffset += newFile.size;
e.newFolder.files[name] = newFile;
}
}
}
return {
root: newRoot,
data: new Blob(parts, { type: "application/octet-stream" }),
};
}
export async function compileAsar(asar: Asar) {
const header = new ArrayBuffer(16);
const headerView = new DataView(header);
const compiled = await compileRoot(asar);
const rootEncoded = new TextEncoder().encode(JSON.stringify(compiled.root));
headerView.setUint32(0, 0x4, true);
headerView.setUint32(4, rootEncoded.byteLength + 8, true);
headerView.setUint32(8, rootEncoded.byteLength + 4, true);
headerView.setUint32(12, rootEncoded.byteLength, true);
return new Blob([headerView, rootEncoded, compiled.data], {
type: "application/octet-stream",
});
}
export class Asar implements Folder {
private headerSize: number;
private blob: Blob;
files: Folder["files"];
constructor(blob: Blob, headerSize: number, root: Folder) {
this.blob = blob;
this.headerSize = headerSize;
this.files = root.files;
}
getFile(file: File) {
if (file.newValue) return file.newValue;
const offset = 8 + this.headerSize + parseInt(file.offset);
return this.blob.slice(offset, offset + file.size);
}
}
export const asarURL = "http://test";
export function resolvePath(folder: Folder, path: string) {
const { pathname } = new URL(path, asarURL);
let depth: Dirent = folder;
for (const file of pathname.split("/").slice(1)) {
if (!isFolder(depth))
throw new TypeError("Attempt to read file inside file");
depth = depth.files[file];
if (!depth) throw new TypeError("File not found");
}
return depth;
}
export async function openAsar(blob: Blob) {
const header = blob.slice(0, 8);
const rootSize = new DataView(await header.arrayBuffer()).getUint32(4, true);
const root = JSON.parse(await blob.slice(16, 8 + rootSize).text()) as Folder;
return new Asar(blob, rootSize, root);
}

View File

@@ -0,0 +1,11 @@
.tab:not(:disabled) {
cursor: pointer;
}
.sourceNavItem {
flex: auto;
text-align: center;
@media only screen and (max-width: 770px) {
width: 100%;
}
}

View File

@@ -0,0 +1,316 @@
import dropzoneStyles from "../dropzone.module.scss";
import { SourceType, injectScript } from "../inject";
import styles from "./SourcePicker.module.scss";
import { Editor } from "@monaco-editor/react";
import clsx from "clsx";
import type { editor } from "monaco-editor";
import prettyBytes from "pretty-bytes";
import type { MouseEventHandler } from "react";
import { useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
async function download(blob: Blob, name: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
document.documentElement.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function InjectButton({
busy,
pickedAsar,
setScriptURL,
onClick,
}: {
busy: boolean;
pickedAsar: boolean;
setScriptURL?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
}) {
const [mouseEnter, setMouseEnter] = useState(false);
let tooltipMessage: string | undefined;
if (busy) tooltipMessage = "Please wait";
else if (!pickedAsar) tooltipMessage = "Please add an .asar file";
else if (setScriptURL === false) tooltipMessage = "Please add a script URL";
return (
<>
<div
className="mt-2"
style={{ display: "inline-block", position: "relative" }}
onMouseOver={() => setMouseEnter(true)}
onMouseLeave={() => setMouseEnter(false)}
>
<button
type="button"
className="btn btn-primary"
disabled={typeof tooltipMessage === "string"}
onClick={onClick}
>
Inject Script
</button>
{typeof tooltipMessage === "string" && (
<div
className={clsx(
"tooltip",
"bs-tooltip-auto",
"fade",
mouseEnter && "show"
)}
style={{
position: "absolute",
top: 0,
left: "100%",
width: "max-content",
pointerEvents: "none",
userSelect: "none",
}}
role="tooltip"
data-popper-placement="right"
>
<div className="tooltip-inner">{tooltipMessage}</div>
<div
className="tooltip-arrow"
style={{
position: "absolute",
right: "100%",
top: 0,
bottom: 0,
margin: "auto",
}}
/>
</div>
)}
</div>
</>
);
}
export default function SourcePicker({
selectedAsar,
}: {
selectedAsar: File | undefined;
}) {
const [output, setOutput] = useState<
{ blob: Blob; name: string } | undefined
>();
const [busy, setBusy] = useState(false);
const [openDevTools, setOpenDevTools] = useState(false);
const [disableWebSecurity, setDisableWebSecurity] = useState(false);
const { getRootProps, getInputProps, isFocused, isDragAccept, isDragReject } =
useDropzone({
accept: { "application/javascript": [".js"] },
multiple: true,
onDropAccepted: async (scripts) => {
// combine all the selected scripts into one
editorRef.current?.setValue(
(await Promise.all(scripts.map((script) => script.text()))).join("\n")
);
setActiveTab(1); // go to code editor tab
},
});
const [url, setURL] = useState("");
const [code, setCode] = useState("");
const [activeTab, setActiveTab] = useState(0);
const editorRef = useRef<editor.IStandaloneCodeEditor>();
const handleInject = async (type: SourceType, value: string) => {
if (!selectedAsar) return;
setBusy(true);
injectScript(selectedAsar, type, value, openDevTools, disableWebSecurity)
.then((blob) => {
setOutput({
name: `injected.${selectedAsar.name}`,
blob,
});
setActiveTab(3); // go to output tab
})
.finally(() => setBusy(false));
};
const tabs = [
{
title: <>Script URLs</>,
content: (
<>
<div className="form-group">
<input
type="email"
className="form-control"
id="exampleInputEmail1"
aria-describedby="scriptURLHelp"
placeholder="https://example.com/script1.js,https://example.com/script2.js"
onChange={(e) => setURL(e.currentTarget.value)}
/>
<small id="scriptURLHelp" className="form-text text-muted">
Please provide a comma-separated list of script URLs without
spaces. Each URL should point to a JavaScript file. The files will
be fetched and executed each time a new page is loaded in the
Electron app.
</small>
</div>
<InjectButton
busy={busy}
pickedAsar={selectedAsar !== undefined}
setScriptURL={url !== ""}
onClick={() => handleInject(SourceType.url, url)}
/>
</>
),
},
{
title: <>Copy & Paste JavaScript Code</>,
content: (
<>
<Editor
onMount={(e) => (editorRef.current = e)}
height="200px"
defaultLanguage="javascript"
defaultValue={code}
onChange={(value) => setCode(value || "")}
options={{ readOnly: busy }}
/>
<InjectButton
busy={busy}
pickedAsar={selectedAsar !== undefined}
onClick={() => handleInject(SourceType.url, url)}
/>
</>
),
},
{
title: <>Upload JavaScript Files</>,
content: (
<div
{...getRootProps({
className: dropzoneStyles.dropzone,
style: {
borderColor: isDragAccept
? "#00e676"
: isDragReject
? "#ff1744"
: isFocused
? "#2196f3"
: "#eeeeee",
},
})}
>
<input {...getInputProps()} />
<p>
Drag and drop your JavaScript files here or click to browse multiple
files
</p>
</div>
),
},
{
title: <>Output</>,
content: output && (
<>
<p>
<em>
{output.name} - {prettyBytes(output.blob.size)}
</em>
</p>
<button
type="button"
className="btn btn-primary"
onClick={() => {
download(output.blob, output.name);
}}
>
Download
</button>
</>
),
},
];
return (
<>
<div
className="card bg-secondary mb-3 mt-2"
style={{ overflow: "hidden" }}
>
<ul
className={clsx("nav", "nav-tabs", styles.sourceNavTabs)}
role="tablist"
>
{tabs.map((tab, i) => (
<li
className={clsx("nav-item", styles.sourceNavItem)}
role="presentation"
key={i}
>
<a
className={clsx(
styles.tab,
"nav-link",
i === activeTab && "active"
)}
data-bs-toggle="tab"
aria-selected="false"
role="tab"
onClick={() => setActiveTab(i)}
>
{tab.title}
</a>
</li>
))}
</ul>
<div className="card-body">
<div className="tab-content">
{tabs.map((tab, i) => (
<div
className={clsx(
"tab-pane",
"fade",
i === activeTab && ["active", "show"]
)}
role="tabpanel"
key={i}
>
{tab.content}
</div>
))}
</div>
</div>
</div>
<fieldset className="form-group">
<legend className="mt-4">Options</legend>
<div className="form-check">
<label className="form-check-label">
<input
className="form-check-input"
type="checkbox"
defaultChecked={openDevTools}
onChange={(e) => setOpenDevTools(e.currentTarget.checked)}
/>
Open DevTools
</label>
</div>
<div className="form-check">
<label className="form-check-label">
<input
className="form-check-input"
type="checkbox"
defaultChecked={disableWebSecurity}
onChange={(e) => setDisableWebSecurity(e.currentTarget.checked)}
/>
Disable WebSecurity
</label>
</div>
</fieldset>
</>
);
}

17
src/dropzone.module.scss Normal file
View File

@@ -0,0 +1,17 @@
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-width: 2px;
border-radius: 2px;
border-style: dashed;
background-color: #fafafa;
color: #bdbdbd;
outline: none;
transition: border 0.24s ease-in-out;
user-select: none;
p {
margin: 0;
}
}

0
src/index.css Normal file
View File

99
src/inject.ts Normal file
View File

@@ -0,0 +1,99 @@
import {
compileAsar,
isFolder,
createFile,
openAsar,
resolvePath,
modifyFile,
} from "./Asar";
interface ElectronPackage {
main: string;
}
export enum SourceType {
url,
code,
}
function generateHook(
preload: string,
openDevTools: boolean,
disableWebSecurity: boolean
) {
return (
"(() => {" +
'"use strict";' +
'const { join } = require("path");' +
'const { BrowserWindow } = require("electron");' +
"const electronModule = require.cache.electron;" +
"const electronExports = electronModule.exports;" +
"const descs = Object.getOwnPropertyDescriptors(electronExports);" +
`const customPreload = join(__dirname, ${JSON.stringify(preload)});` +
"function hooked(options) {" +
"const oldPreload = options && options.webPreferences ? options.webPreferences.preload : undefined;" +
`const bw = new BrowserWindow({ ...(options || {}), webPreferences: { ...(options.webPreferences || {}), preload: customPreload, sandbox: false${
disableWebSecurity ? ", webSecurity: false" : ""
} } });` +
(openDevTools ? 'bw.webContents.openDevTools({ mode: "undocked" });' : "") +
'bw.webContents.on("ipc-message-sync", (event, channel) => { if (channel === "original-preload") event.returnValue = oldPreload; });' +
"return bw;" +
"};" +
"descs.BrowserWindow.get = () => hooked;" +
"const newExports = Object.defineProperties({}, descs);" +
'Object.defineProperty(electronModule, "exports", { get: () => newExports, configurable: true, enumerable: true });' +
"})();"
);
}
export async function injectScript(
blob: Blob,
sourceType: SourceType,
value: string,
openDevTools: boolean,
disableWebSecurity: boolean
) {
const asar = await openAsar(blob);
const preloadFile = "custom-preload.js";
// we use eval() to expose require() and other nodejs functions to the userscripts
// this is dangerous but very good for making cheats harder to detect
const preloadEval =
sourceType === SourceType.code
? `try { eval(${JSON.stringify(
value + "\n//# sourceURL=injected"
)}) } catch (err) { console.error(err) }`
: `for (const src of ${JSON.stringify(
value.split(",").map((val) => val.trim())
)}) try {` +
"const http = new XMLHttpRequest();" +
'http.open("GET", src, false);' +
"http.send();" +
'try{eval(http.responseText + "\\n//# sourceURL=" + src) } catch (err) { console.error(err) }' +
"} catch (err) { console.error(err) }";
const preload =
preloadEval +
'const originalPreload = require("electron").ipcRenderer.sendSync("original-preload");' +
"if (originalPreload) require(originalPreload);";
const main = generateHook(preloadFile, openDevTools, disableWebSecurity);
const pkgFile = resolvePath(asar, "package.json");
if (isFolder(pkgFile)) throw new Error("package.json was a folder");
const pkg = JSON.parse(await asar.getFile(pkgFile).text()) as ElectronPackage;
const mainFile = resolvePath(asar, pkg.main);
if (isFolder(mainFile)) throw new Error("entry point was a folder");
const mainJS = await asar.getFile(mainFile).text();
await createFile(asar, preloadFile, new Blob([preload]));
await modifyFile(mainFile, new Blob([main + mainJS]));
return await compileAsar(asar);
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import App from "./App";
import React from "react";
import ReactDOM from "react-dom/client";
import "bootswatch/dist/zephyr/bootstrap.min.css";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});