Initial commit

This commit is contained in:
Alula 2025-11-22 06:28:10 +01:00
commit 685bfd5016
No known key found for this signature in database
GPG Key ID: 3E00485503A1D8BA
38 changed files with 4821 additions and 0 deletions

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?

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# hbpatcher
A tool for patching Nintendo Switch homebrew (`.nro` files) to use the new ABI, resolving compatibility issues with Atmosphère 1.10.0+ and Firmware 21.0.0+.
## Disclaimer
**This tool may or may not work correctly.** It is not the responsibility of the author of this tool or the author of the homebrew. Users should obtain the latest version of homebrew recompiled against the latest libnx if possible over using this tool. This tool is only an assist for unmaintained homebrew to reduce the impact of kernel changes.
## Instructions
1. Drag and drop your homebrew `.nro` file into the patcher interface.
2. The tool will analyze the file to see if it uses the old ABI.
3. If patching is needed, click the "Patch File" button.
4. The patched file will be downloaded automatically.
5. Copy the patched file to your Switch SD card.
## For Developers
**Do not rely on this tool.** Please recompile your homebrew with `libnx v4.10.0` (or newer).
The updated library resolves the memory conflict and stays backward compatible with older Atmosphère versions and firmwares.

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

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="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>libnx ABI Patcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "hbpatcher",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\" index.html"
},
"dependencies": {
"@fontsource/open-sans": "^5.2.7",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "npm:rolldown-vite@7.2.2"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.2"
}
}
}

2574
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

45
src/App.tsx Normal file
View File

@ -0,0 +1,45 @@
import { useState } from "react";
import { Header } from "./components/Header";
import { Sidebar } from "./components/Sidebar";
import { PatcherPage } from "./pages/PatcherPage";
import { AboutPage } from "./pages/AboutPage";
import { SettingsPage } from "./pages/SettingsPage";
function App() {
const [activeTab, setActiveTab] = useState("patcher");
const [appendSuffix, setAppendSuffix] = useState(false);
const renderPage = () => {
switch (activeTab) {
case "patcher":
return <PatcherPage appendSuffix={appendSuffix} />;
case "about":
return <AboutPage />;
case "settings":
return (
<SettingsPage
appendSuffix={appendSuffix}
onAppendSuffixChange={setAppendSuffix}
/>
);
default:
return null;
}
};
return (
<div className="min-h-screen text-switch-text font-sans flex justify-center md:overflow-hidden">
<div className="w-full max-w-[1280px] md:h-screen flex flex-col bg-switch-bg relative">
<Header />
<div className="flex flex-1 md:overflow-hidden flex-col md:flex-row">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 overflow-auto p-8 pt-12">{renderPage()}</main>
</div>
</div>
</div>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,99 @@
import { FileDown } from "lucide-react";
import { useRef, useState } from "react";
interface FileDropZoneProps {
onFileSelected: (file: File) => void;
}
export function FileDropZone({ onFileSelected }: FileDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const nroFile = files.find((file) => file.name.endsWith(".nro"));
if (nroFile) {
onFileSelected(nroFile);
} else if (files.length > 0) {
onFileSelected(files[0]);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFileSelected(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
handleClick();
}
};
return (
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
className={`
border-2 border-dashed rounded-[2px] p-12 text-center cursor-pointer
transition-all duration-200 outline-none switch-focus-ring
${
isDragging
? "border-text-selected bg-switch-selected-bg shadow-[inset_0_0_20px_rgba(0,0,0,0.2)]"
: "border-switch-line-sep bg-switch-selected-bg/50 hover:border-text-selected hover:bg-switch-selected-bg"
}
`}
>
<input
ref={fileInputRef}
type="file"
accept=".nro"
onChange={handleFileInput}
className="hidden"
/>
<div className="space-y-4 pointer-events-none">
<div className="flex items-center justify-center mb-4 text-switch-text-selected">
<FileDown size={48} strokeWidth={1.5} />
</div>
<h3 className="text-switch-text text-xl font-normal">
{isDragging ? "Drop file now" : "Select NRO File"}
</h3>
<p className="text-switch-text-info">Drag & drop or click to browse</p>
</div>
</div>
);
}

View File

@ -0,0 +1,63 @@
import type { AnalysisResult } from "../types";
import { Card } from "./ui/Card";
import { formatMessage } from "../utils/localization";
interface FileStatusDisplayProps {
analysis: AnalysisResult | null;
}
export function FileStatusDisplay({ analysis }: FileStatusDisplayProps) {
if (!analysis) {
return null;
}
const getStatusColor = () => {
switch (analysis.state) {
case "invalid":
return "text-switch-error";
case "new-abi":
return "text-switch-highlight-1";
case "patched":
return "text-switch-text-selected";
case "needs-patching":
return "text-switch-text";
default:
return "text-switch-text-info";
}
};
const getStatusTitle = () => {
switch (analysis.state) {
case "invalid":
return "Invalid File";
case "new-abi":
return "No Patching Required";
case "patched":
return "Already Patched";
case "needs-patching":
return "Ready to Patch";
default:
return "Unknown Status";
}
};
return (
<Card className="mt-6">
<div className="p-6 flex flex-col gap-2">
<h3 className={`text-xl font-normal ${getStatusColor()}`}>
{getStatusTitle()}
</h3>
<div className="h-px bg-switch-line-sep w-full my-2"></div>
<p className="text-switch-text-info text-sm">
File:{" "}
<span className="text-switch-text font-mono ml-2">
{analysis.fileName}
</span>
</p>
<p className="text-switch-text">
{formatMessage(analysis.messageKey, analysis.messageParams)}
</p>
</div>
</Card>
);
}

14
src/components/Header.tsx Normal file
View File

@ -0,0 +1,14 @@
import { Drill } from "lucide-react";
export function Header() {
return (
<header className="h-16 flex items-center mx-4 px-6 border-b border-switch-text bg-switch-bg shrink-0 z-20 relative">
<div className="flex items-center gap-3">
<Drill size={32} />
<h1 className="text-xl text-switch-text font-normal tracking-wide">
libnx ABI Patcher
</h1>
</div>
</header>
);
}

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { ChevronDown, ChevronRight, Terminal } from "lucide-react";
import { Card } from "./ui/Card";
import { Button } from "./ui/Button";
interface LogViewerProps {
logs: string[];
defaultExpanded?: boolean;
}
export function LogViewer({ logs, defaultExpanded = false }: LogViewerProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
if (!logs || logs.length === 0) {
return null;
}
return (
<Card className="animate-in fade-in slide-in-from-bottom-4 duration-300 overflow-hidden">
<div
className="p-4 flex items-center justify-between cursor-pointer bg-switch-popup hover:bg-switch-hover-bg transition-colors border-b border-switch-line-sep"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2 text-switch-text">
<Terminal className="w-4 h-4" />
<span className="font-medium">Patch Log</span>
<span className="text-xs text-switch-text-info px-2 py-0.5 rounded-full bg-switch-selected-bg">
{logs.length} entries
</span>
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</Button>
</div>
{isExpanded && (
<div className="p-4 bg-black/20 max-h-60 overflow-y-auto font-mono text-xs text-switch-text-info space-y-1">
{logs.map((log, index) => (
<div key={index} className="break-all">
<span className="text-switch-text-selected opacity-50 mr-2">
{(index + 1).toString().padStart(2, "0")}
</span>
{log}
</div>
))}
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,45 @@
import { Button } from "./ui/Button";
interface SidebarProps {
activeTab: string;
onTabChange: (tab: string) => void;
}
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
const tabs = [
{ id: "patcher", label: "Patcher" },
{ id: "about", label: "About" },
{ id: "settings", label: "Settings" },
];
return (
<aside className="bg-switch-sidebar sidebar-shadow w-full md:w-80 flex flex-col border-r border-transparent py-12 pl-12 shrink-0">
<nav className="flex-1 pr-5">
<ul className="space-y-1">
{tabs.map((tab) => (
<li key={tab.id}>
<Button
variant="ghost"
className={`
w-full justify-start text-left text-xl pl-4 py-3 my-0
rounded-none transition-none switch-focus-ring relative
${
activeTab === tab.id
? "text-switch-text-selected bg-switch-sidebar-focus"
: "text-switch-text hover:bg-switch-sidebar-focus/50"
}
`}
onClick={() => onTabChange(tab.id)}
>
{activeTab === tab.id && (
<div className="absolute left-1.5 top-1/2 -translate-y-1/2 w-[3px] h-10 bg-switch-text-selected" />
)}
<span className="font-normal">{tab.label}</span>
</Button>
</li>
))}
</ul>
</nav>
</aside>
);
}

View File

@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
type Theme = "dark" | "light";
const ThemeOption = ({
value,
label,
previewColor,
theme,
setTheme,
isLast,
}: {
value: Theme;
label: string;
previewColor: string;
theme: Theme;
setTheme: (theme: Theme) => void;
isLast?: boolean;
}) => {
const isSelected = theme === value;
return (
<div
role="radio"
aria-checked={isSelected}
tabIndex={0}
onClick={() => setTheme(value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setTheme(value);
}
}}
className={`
group relative flex items-center justify-between py-4
cursor-pointer outline-none transition-all duration-200
border-t border-switch-line-sep ${isLast ? "border-b" : ""}
switch-focus-ring hover:bg-switch-selected-bg/10
`}
>
<div className="flex items-center gap-6">
{/* Preview Box */}
<div
className="w-16 h-10 border border-switch-line-sep shadow-sm"
style={{ backgroundColor: previewColor }}
/>
{/* Label */}
<span className="text-xl font-normal text-switch-text">{label}</span>
</div>
{/* Radio Indicator */}
<div
className={`
w-6 h-6 rounded-full flex items-center justify-center
${
isSelected
? "bg-switch-text-selected"
: "border-2 border-switch-text-info"
}
`}
>
{isSelected && (
<div className="w-2.5 h-2.5 bg-switch-bg rounded-full" />
)}
</div>
</div>
);
};
export function ThemeSelector() {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem("theme") as Theme | null;
return saved || "dark";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
return (
<div className="space-y-0" role="radiogroup" aria-label="Theme Selection">
<ThemeOption
value="light"
label="Basic White"
previewColor="#ebebeb"
theme={theme}
setTheme={setTheme}
/>
<ThemeOption
value="dark"
label="Basic Black"
previewColor="#2d2d2d"
theme={theme}
setTheme={setTheme}
isLast
/>
</div>
);
}

View File

@ -0,0 +1,84 @@
import * as React from "react";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "outline";
size?: "sm" | "md" | "lg" | "icon";
isLoading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className = "",
variant = "primary",
size = "md",
isLoading,
children,
disabled,
...props
},
ref,
) => {
// Removed font-bold, added font-normal
const baseStyles =
"inline-flex items-center justify-center font-normal transition-all duration-150 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none rounded-[2px]";
const variants = {
primary:
"bg-switch-text-selected text-switch-bg hover:brightness-110 active:scale-95 switch-focus-ring",
secondary:
"bg-switch-selected-bg text-switch-text border border-switch-line-sep hover:border-switch-highlight-1 active:scale-95 switch-focus-ring",
ghost: "bg-transparent text-switch-text hover:text-switch-text-selected",
outline:
"bg-transparent border border-switch-line-sep text-switch-text hover:border-switch-highlight-1",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-8 py-3 text-lg",
icon: "h-10 w-10",
};
return (
<button
ref={ref}
className={`
${baseStyles}
${variants[variant]}
${sizes[size]}
${className}
`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{children}
</span>
) : (
children
)}
</button>
);
},
);
Button.displayName = "Button";

View File

@ -0,0 +1,45 @@
import * as React from "react";
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`bg-switch-popup rounded-[2px] ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-6 border-b border-switch-line-sep ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-lg font-normal text-switch-text leading-none tracking-tight ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`px-6 py-4 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";

View File

@ -0,0 +1,22 @@
import React from "react";
interface SectionHeaderProps {
children: React.ReactNode;
className?: string;
}
export function SectionHeader({
children,
className = "",
}: SectionHeaderProps) {
return (
<h3
className={
"text-lg text-switch-text font-normal leading-none mb-4 px-2 border-l-4 border-switch-line-sep " +
className
}
>
{children}
</h3>
);
}

View File

@ -0,0 +1,43 @@
import * as React from "react";
export interface SwitchProps
extends React.InputHTMLAttributes<HTMLInputElement> {
onCheckedChange?: (checked: boolean) => void;
}
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className = "", onCheckedChange, onChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
onChange?.(e);
};
return (
<label
className={`relative inline-flex items-center cursor-pointer switch-focus-ring rounded-[2px] ${className}`}
>
<input
type="checkbox"
ref={ref}
className="sr-only peer"
onChange={handleChange}
{...props}
/>
<div
className="
w-11 h-6
bg-switch-line-sep
peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-switch-highlight-2
rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5
after:transition-all
peer-checked:bg-switch-text-selected
"
></div>
</label>
);
},
);
Switch.displayName = "Switch";

142
src/index.css Normal file
View File

@ -0,0 +1,142 @@
@import "tailwindcss";
@import "@fontsource/open-sans/400.css";
@import "@fontsource/open-sans/600.css";
@import "@fontsource/open-sans/700.css";
@theme {
--color-switch-bg: var(--switch-bg);
--color-switch-popup: var(--switch-popup);
--color-switch-error: var(--switch-error);
--color-switch-focus: var(--switch-focus);
--color-switch-text: var(--switch-text);
--color-switch-text-info: var(--switch-text-info);
--color-switch-text-selected: var(--switch-text-selected);
--color-switch-selected-bg: var(--switch-selected-bg);
--color-switch-sidebar: var(--switch-sidebar);
--color-switch-line: var(--switch-line);
--color-switch-line-sep: var(--switch-line-sep);
--color-switch-highlight-1: var(--switch-highlight-1);
--color-switch-highlight-2: var(--switch-highlight-2);
--color-switch-hover-bg: var(--switch-hover-bg);
}
@layer base {
:root {
font-family: "Open Sans", system-ui, sans-serif;
}
/* Dark Theme (default) */
:root,
[data-theme="dark"] {
--switch-bg: #2d2d2d;
--switch-popup: #3a3b3c;
--switch-error: #fa5a3a;
--switch-focus: rgba(0, 0, 0, 0.7);
--switch-text: #ffffff;
--switch-text-info: #a0a0a0;
--switch-text-selected: #00ffc8;
--switch-selected-bg: #202227;
--switch-sidebar: #323232;
--switch-sidebar-focus: #212227;
--switch-line: #ffffff;
--switch-line-sep: #707070;
--switch-highlight-1: #1989c6;
--switch-highlight-2: #89f0f2;
--switch-hover-bg: #00ffc810;
--switch-scrollbar: #00ffc8;
--switch-scrollbar-bg: transparent;
--switch-progressbar: #00ffc8;
--switch-progressbar-bg: #464646;
}
/* Light Theme */
[data-theme="light"] {
--switch-bg: #ebebeb;
--switch-popup: #f0f0f0;
--switch-error: #fa5a3a;
--switch-focus: rgba(211, 211, 211, 0.63);
--switch-text: #2b2b2b;
--switch-text-info: #707070;
--switch-text-selected: #3250f0;
--switch-selected-bg: #fcfcfc;
--switch-sidebar: #f0f0f0;
--switch-sidebar-focus: #fdfdfd;
--switch-line: #2b2b2b;
--switch-line-sep: #6d787a;
--switch-highlight-1: #1989c6;
--switch-highlight-2: #4ff4d7;
--switch-hover-bg: #3250f010;
--switch-scrollbar: #b0b0b0;
--switch-scrollbar-bg: transparent;
--switch-progressbar: #3250f0;
--switch-progressbar-bg: #808080;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--color-switch-selected-bg);
color: var(--switch-text);
transition:
background-color 0.3s,
color 0.3s;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--switch-scrollbar-bg);
}
::-webkit-scrollbar-thumb {
background: var(--switch-scrollbar);
border-radius: 4px;
}
@keyframes pulse-focus {
0% {
outline-color: var(--switch-highlight-1);
}
50% {
outline-color: var(--switch-highlight-2);
}
100% {
outline-color: var(--switch-highlight-1);
}
}
.sidebar-shadow {
box-shadow:
0 50px 50px -50px var(--color-switch-bg) inset,
0 -50px 50px -50px var(--color-switch-bg) inset;
}
.switch-focus-ring:focus,
.switch-focus-within:focus-within {
outline: 3px solid var(--switch-highlight-1);
outline-offset: 0px;
animation: pulse-focus 850ms infinite;
}
.tab-border-selected::after {
content: "";
position: absolute;
top: 5px;
bottom: 5px;
left: 5px;
width: 3px;
background-color: var(--switch-text-selected);
}
a {
color: var(--switch-text-selected);
text-decoration: underline;
}
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

174
src/pages/AboutPage.tsx Normal file
View File

@ -0,0 +1,174 @@
import { Card, CardContent } from "../components/ui/Card";
const GITHUB_URL = "https://github.com/alula/hbpatcher";
export function AboutPage() {
return (
<div className="w-full animate-in fade-in duration-300">
<div className="space-y-4">
<Card>
<CardContent className="space-y-3">
<h3 className="text-base font-normal">Disclaimer</h3>
<p className="text-switch-text-info text-sm font-bold">
This tool may or may not work correctly. It is not the
responsibility of the author of this tool or the author of the
homebrew. Users should obtain the latest version of homebrew
recompiled against the latest libnx if possible over using this
tool. This tool is only an assist for unmaintained homebrew to
reduce the impact of kernel changes.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3">
<h3 className="text-base font-normal">Instructions</h3>
<ol className="list-decimal list-inside space-y-1.5 text-switch-text-info text-sm ml-2">
<li>
Drag and drop your homebrew{" "}
<span className="font-mono text-switch-text">.nro</span> file
into the patcher tab.
</li>
<li>
The tool will analyze the file to see if it uses the old ABI.
</li>
<li>If patching is needed, click the "Patch File" button.</li>
<li>The patched file will be downloaded automatically.</li>
<li>Copy the patched file to your Switch SD card.</li>
</ol>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3">
<h3 className="text-base font-normal">For Developers</h3>
<div className="text-red-400 text-base font-bold">
Do not rely on this tool. Please recompile your homebrew with{" "}
<code>libnx v4.10.0</code> (or newer).
</div>
<div className="text-switch-text-info text-base leading-relaxed">
<p>
The updated library resolves the memory conflict and stays
backward compatible with older Atmosphère versions and
firmwares.
</p>
<p>
You can find the source code for this tool on{" "}
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
GitHub
</a>
.
</p>
</div>
</CardContent>
</Card>
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-lg font-bold text-switch-text">TL;DR</h2>
<p className="text-switch-text-info text-sm leading-relaxed">
Atmosphère 1.10.0+ integrates kernel optimizations from Firmware
21.0.0. This improves performance but breaks older homebrew,
causing crashes or hangs on exit. If your homebrew is
unmaintained, use this tool to patch the <code>.nro</code> files.
If the app is still active, ask the developer to update it.
</p>
</section>
<section className="space-y-3">
<h3 className="text-base font-bold text-switch-text">
How this tool works
</h3>
<p className="text-switch-text-info text-sm leading-relaxed">
This tool scans your <code>.nro</code> files to see if they rely
on the old, now-unsafe memory layout. If it detects a conflict, it
modifies the binary to{" "}
<a
href="https://github.com/switchbrew/libnx/commit/cad06c006e4e0caf9c63755ac6c2a10a52333e27"
target="_blank"
rel="noopener noreferrer"
>
move its Thread Local Storage (TLS)
</a>{" "}
from offset <code>0x108</code> to the safe offset{" "}
<code>0x180</code>. Effectively, it mimics the result of
recompiling the app with the latest libraries, restoring stability
without needing the source code.
</p>
</section>
<section className="space-y-3">
<h3 className="text-base font-bold text-switch-text">
Why isn't this built into the Homebrew Loader?
</h3>
<div className="text-switch-text-info text-sm leading-relaxed space-y-3">
<p>
It might seem logical for the Homebrew Loader (<code>hbl</code>)
to handle this automatically, but that's out of scope for a
simple program loader. HBLs job is to execute code as-is, not
to dynamically repair binaries.
</p>
<p>
Trying to patch memory offsets on the fly is invasive and
fragile; if HBL guesses wrong, it could corrupt the entire
homebrew environment. Furthermore, baking a patcher into the
loader sets a bad precedent. The maintainers want to encourage
proper fixes (recompilation) rather than relying on permanent
"dirty hacks" within the OS itself.{" "}
<a
href="https://discord.com/channels/269333940928512010/414949821003202562/1440485257550889221"
target="_blank"
rel="noopener noreferrer"
>
Source: Switchbrew
</a>
</p>
</div>
</section>
<section className="space-y-3">
<h3 className="text-base font-bold text-switch-text">
Why the breakage happened
</h3>
<div className="text-switch-text-info text-sm leading-relaxed space-y-3">
<p>
This isn't a bug; it's a conflict over real estate. In Firmware
21.0.0, Nintendo optimized the kernel to write CPU timing data
to a specific memory address (<code>usertls + 0x108</code>) on
every thread switch.
</p>
<p>
Previous versions of <code>libnx</code> (the homebrew library)
assumed this space was unused and stored data there. Official
software always respected the reservation, so it wasn't
affected. Now that the kerneland by extension,
Atmosphèreactively uses that space, old homebrew gets its
memory overwritten, leading to immediate crashes.
</p>
</div>
</section>
<section className="space-y-3">
<h3 className="text-base font-bold text-switch-text">
Why Atmosphère didn't add a workaround
</h3>
<div className="text-switch-text-info text-sm leading-relaxed space-y-3">
<p>
Atmosphère aims to reimplement Nintendo's kernel logic 1:1. When
Nintendo changes how the kernel works, Atmosphère must follow
suit to ensure official games run correctly.
</p>
<p>
While the developers have added quiet workarounds in the past
(like the FW 19.0.0 debug flags), this specific issue was too
risky to mask. A workaround here would require adding
conditional logic to the kernel schedulerthe most time-critical
part of the system. Adding checks for "broken homebrew" during
every thread switch would add complexity, potentially degrade
performance system-wide, and could even introduce bugs in
official games.
</p>
</div>
</section>
</div>
</div>
</div>
);
}

144
src/pages/PatcherPage.tsx Normal file
View File

@ -0,0 +1,144 @@
import { useState } from "react";
import { FileDropZone } from "../components/FileDropZone";
import { FileStatusDisplay } from "../components/FileStatusDisplay";
import { Button } from "../components/ui/Button";
import { Card } from "../components/ui/Card";
import { LogViewer } from "../components/LogViewer";
import { analyzeFile, patchFile } from "../patcher/NROPatcher";
import type { AnalysisResult, PatchResult } from "../types";
import { formatMessage } from "../utils/localization";
import { Check } from "lucide-react";
interface PatcherPageProps {
appendSuffix: boolean;
}
export function PatcherPage({ appendSuffix }: PatcherPageProps) {
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
const [patchResult, setPatchResult] = useState<PatchResult | null>(null);
const [isPatching, setIsPatching] = useState(false);
const [patchError, setPatchError] = useState<string | null>(null);
const [log, setLog] = useState<string[]>([]);
const handleFileSelected = async (file: File) => {
setCurrentFile(file);
setPatchResult(null);
setPatchError(null);
setLog([]);
try {
const result = await analyzeFile(file);
setAnalysis(result);
setLog(result.log);
} catch {
setAnalysis({
state: "invalid",
fileName: file.name,
messageKey: "error_analysis_failed",
log: [],
} as AnalysisResult);
setLog([]);
}
};
const handlePatchClick = async () => {
if (!currentFile) return;
setIsPatching(true);
setPatchError(null);
try {
const result = await patchFile(currentFile);
setPatchResult(result);
setLog(result.log);
if (result.success && result.patchedData) {
// Create Blob from Uint8Array for download
const blob = new Blob([result.patchedData as unknown as BlobPart], {
type: "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
let downloadName = currentFile.name;
if (appendSuffix) {
downloadName = downloadName.replace(/\.nro$/i, "_patched.nro");
if (downloadName === currentFile.name) downloadName += "_patched.nro";
}
a.download = downloadName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else if (!result.success) {
setPatchError(
formatMessage(result.errorKey || "error_unknown", result.errorParams),
);
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Unknown error occurred";
setPatchError(formatMessage("error_unknown", { message: errorMessage }));
setPatchResult({
success: false,
errorKey: "error_unknown",
errorParams: { message: errorMessage },
log: [],
});
} finally {
setIsPatching(false);
}
};
return (
<div className="w-full animate-in fade-in duration-300">
<div className="space-y-6">
<FileDropZone onFileSelected={handleFileSelected} />
{analysis && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300">
<FileStatusDisplay analysis={analysis} />
</div>
)}
{patchError && (
<Card className="animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="p-4 text-switch-error">Error: {patchError}</div>
</Card>
)}
{analysis && analysis.state === "needs-patching" && currentFile && (
<div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-300 delay-100">
<Button
size="lg"
onClick={handlePatchClick}
isLoading={isPatching}
className="min-w-[200px]"
>
Patch File
</Button>
</div>
)}
{patchResult && patchResult.success && (
<Card className="animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="p-6 flex items-center gap-3">
<Check className="w-6 h-6 text-switch-text-selected" />
<div>
<h3 className="text-switch-text-selected font-normal text-lg">
Success!
</h3>
<p className="text-switch-text">File patched and downloaded.</p>
</div>
</div>
</Card>
)}
<LogViewer logs={log} />
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { ThemeSelector } from "../components/ThemeSelector";
import { SectionHeader } from "../components/ui/SectionHeader";
import { Switch } from "../components/ui/Switch";
interface SettingsPageProps {
appendSuffix: boolean;
onAppendSuffixChange: (value: boolean) => void;
}
interface SettingsRowProps {
title: string;
description?: string;
children: React.ReactNode;
}
function SettingsRow({ title, description, children }: SettingsRowProps) {
return (
<div className="mb-6">
<div className="group relative flex items-center justify-between py-4 border-y border-switch-line-sep switch-focus-within hover:bg-switch-selected-bg/10 transition-colors">
<h3 className="text-xl text-switch-text font-normal">{title}</h3>
<div className="rounded-[2px] group-focus-within:outline-none">
{children}
</div>
</div>
{description && (
<p className="mt-2 text-switch-text-info text-sm">{description}</p>
)}
</div>
);
}
export function SettingsPage({
appendSuffix,
onAppendSuffixChange,
}: SettingsPageProps) {
return (
<div className="w-full animate-in fade-in duration-300 px-2">
<div className="space-y-8">
<div>
<SectionHeader>Themes</SectionHeader>
<ThemeSelector />
</div>
<SectionHeader>Miscellaneous</SectionHeader>
<SettingsRow
title="Output Filename"
description="Automatically append '_patched' to the filename of the patched NRO."
>
<Switch
checked={appendSuffix}
onCheckedChange={onAppendSuffixChange}
/>
</SettingsRow>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
export class BinaryReader {
private view: DataView;
private offset: number = 0;
constructor(buffer: ArrayBuffer | Uint8Array) {
if (buffer instanceof Uint8Array) {
this.view = new DataView(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength,
);
} else {
this.view = new DataView(buffer);
}
}
seek(offset: number) {
this.offset = offset;
}
skip(count: number) {
this.offset += count;
}
tell(): number {
return this.offset;
}
readU8(): number {
const val = this.view.getUint8(this.offset);
this.offset += 1;
return val;
}
readU16(): number {
const val = this.view.getUint16(this.offset, true); // Little Endian
this.offset += 2;
return val;
}
readU32(): number {
const val = this.view.getUint32(this.offset, true);
this.offset += 4;
return val;
}
readU64(): bigint {
const val = this.view.getBigUint64(this.offset, true);
this.offset += 8;
return val;
}
readBytes(length: number): Uint8Array {
const bytes = new Uint8Array(
this.view.buffer,
this.view.byteOffset + this.offset,
length,
);
this.offset += length;
// Return a copy to avoid issues if the underlying buffer changes or if we want to modify it
return new Uint8Array(bytes);
}
readString(length: number): string {
const bytes = this.readBytes(length);
const decoder = new TextDecoder();
// Remove null termination if present
let end = 0;
while (end < bytes.length && bytes[end] !== 0) end++;
return decoder.decode(bytes.subarray(0, end));
}
}

193
src/patcher/NRO.ts Normal file
View File

@ -0,0 +1,193 @@
import { BinaryReader } from "./BinaryReader";
export interface NroSection {
offset: number;
size: number;
}
export interface NroHeader {
magic: string;
version: number;
size: number;
flags: number;
text: NroSection;
rodata: NroSection;
data: NroSection;
bssSize: number;
moduleId: Uint8Array;
}
export interface Mod0Header {
magic: string;
dynamicOffset: number;
bssStartOffset: number;
bssEndOffset: number;
ehFrameHdrStartOffset: number;
ehFrameHdrEndOffset: number;
runtimeModuleObjectOffset: number;
// Homebrew extensions
lny0Offset?: number; // Offset to LNY0 magic relative to MOD0 start
lny1Offset?: number; // Offset to LNY1 magic relative to MOD0 start
lny2Offset?: number; // Offset to LNY2 magic relative to MOD0 start
lny2Version?: number; // Version of LNY2
hbpAOffset?: number; // Offset to hbpA magic relative to MOD0 start
}
export class NRO {
public header: NroHeader;
public mod0?: Mod0Header;
public mod0Offset: number = 0;
private buffer: Uint8Array;
constructor(buffer: Uint8Array) {
this.buffer = buffer;
this.header = this.parseHeader();
this.parseMod0();
}
private parseHeader(): NroHeader {
const reader = new BinaryReader(this.buffer);
// Check magic at 0x10
reader.seek(0x10);
const magic = reader.readString(4);
if (magic !== "NRO0") {
throw new Error("Invalid NRO magic");
}
const version = reader.readU32();
const size = reader.readU32();
const flags = reader.readU32();
const text = {
offset: reader.readU32(),
size: reader.readU32(),
};
const rodata = {
offset: reader.readU32(),
size: reader.readU32(),
};
const data = {
offset: reader.readU32(),
size: reader.readU32(),
};
const bssSize = reader.readU32();
/* const reserved =*/ reader.readU32();
const moduleId = reader.readBytes(0x20);
return {
magic,
version,
size,
flags,
text,
rodata,
data,
bssSize,
moduleId,
};
}
private parseMod0() {
// MOD0 is usually at the start of .text + 4 (after the branch instruction)
// But we should scan for it in the first few bytes of .text just in case
const textStart = this.header.text.offset;
const reader = new BinaryReader(this.buffer);
// Look for MOD0 magic in the first 0x20 bytes of text section
// Standard crt0 has it at offset 4 from start of text
let mod0Found = false;
let mod0Offset = 0;
for (let i = 0; i < 0x20; i += 4) {
reader.seek(textStart + i);
const magic = reader.readString(4);
if (magic === "MOD0") {
mod0Found = true;
mod0Offset = textStart + i;
break;
}
}
if (!mod0Found) return;
this.mod0Offset = mod0Offset;
reader.seek(mod0Offset + 4); // Skip magic
const dynamicOffset = reader.readU32();
const bssStartOffset = reader.readU32();
const bssEndOffset = reader.readU32();
const ehFrameHdrStartOffset = reader.readU32();
const ehFrameHdrEndOffset = reader.readU32();
const runtimeModuleObjectOffset = reader.readU32();
const mod0: Mod0Header = {
magic: "MOD0",
dynamicOffset,
bssStartOffset,
bssEndOffset,
ehFrameHdrStartOffset,
ehFrameHdrEndOffset,
runtimeModuleObjectOffset,
};
// Check for LNY0
const lny0Magic = reader.readString(4);
if (lny0Magic === "LNY0") {
mod0.lny0Offset = reader.tell() - 4 - mod0Offset;
reader.skip(8); // got_start, got_end
// Check for LNY1
const lny1Magic = reader.readString(4);
if (lny1Magic === "LNY1") {
mod0.lny1Offset = reader.tell() - 4 - mod0Offset;
reader.skip(8); // relro_start, data_start
// Check for LNY2
const lny2Magic = reader.readString(4);
if (lny2Magic === "LNY2") {
mod0.lny2Offset = reader.tell() - 4 - mod0Offset;
mod0.lny2Version = reader.readU32();
reader.skip(4); // Reserved
// Check for hbpA (Homebrew Patcher Applied)
const hbpAMagic = reader.readString(4);
if (hbpAMagic === "hbpA") {
mod0.hbpAOffset = reader.tell() - 4 - mod0Offset;
}
}
}
}
this.mod0 = mod0;
}
get text(): Uint8Array {
return this.buffer.subarray(
this.header.text.offset,
this.header.text.offset + this.header.text.size,
);
}
get rodata(): Uint8Array {
return this.buffer.subarray(
this.header.rodata.offset,
this.header.rodata.offset + this.header.rodata.size,
);
}
get data(): Uint8Array {
return this.buffer.subarray(
this.header.data.offset,
this.header.data.offset + this.header.data.size,
);
}
get fullBuffer(): Uint8Array {
return this.buffer;
}
}

120
src/patcher/NROPatcher.ts Normal file
View File

@ -0,0 +1,120 @@
import { NRO } from "./NRO";
import type { AnalysisResult, PatchResult } from "../types";
import { PatchContext, PATCHES } from "./patches";
/**
* Analyzes an NRO file to determine its state.
*/
export async function analyzeFile(file: File): Promise<AnalysisResult> {
let nro: NRO;
try {
const buffer = new Uint8Array(await file.arrayBuffer());
nro = new NRO(buffer);
} catch (error) {
console.error("Error parsing NRO:", error);
return {
state: "invalid",
fileName: file.name,
messageKey: "status_invalid_nro",
log: [],
};
}
const context = new PatchContext(nro);
// Check for any pattern that needs patching
let needsPatching = false;
for (const patch of PATCHES) {
context._onPatchStart(patch);
if (patch.check(context)) {
needsPatching = true;
}
context._onPatchEnd();
}
if (needsPatching) {
return {
state: "needs-patching",
fileName: file.name,
messageKey: "status_needs_patching",
log: context.errorLog,
};
}
// If we are here, no patterns matched, meaning it's compatible.
// Now we check if it was patched by THIS tool (hbpA marker) or if it's just compatible (recompiled).
if (nro.mod0 && nro.mod0.hbpAOffset) {
return {
state: "patched",
fileName: file.name,
messageKey: "status_already_patched",
log: context.errorLog,
};
}
if (!nro.mod0?.lny2Offset) {
return {
state: "new-abi",
fileName: file.name,
messageKey: "status_no_pattern",
log: context.errorLog,
};
}
return {
state: "new-abi",
fileName: file.name,
messageKey: "status_new_abi",
log: context.errorLog,
};
}
/**
* Patches an NRO file to use the new ABI.
*/
export async function patchFile(file: File): Promise<PatchResult> {
let log: string[] = [];
try {
const buffer = new Uint8Array(await file.arrayBuffer());
const nro = new NRO(buffer);
const context = new PatchContext(nro);
log = context.errorLog;
for (const patch of PATCHES) {
context._onPatchStart(patch);
if (patch.check(context)) {
const success = patch.apply(context);
if (success) {
context.appliedPatches.add(patch.id);
}
}
context._onPatchEnd();
}
if (context.appliedPatches.size === 0) {
return {
success: false,
errorKey: "error_pattern_not_found",
log,
};
}
// Return the modified buffer directly
return {
success: true,
patchedData: buffer,
log,
};
} catch (error) {
console.error("Error patching file:", error);
return {
success: false,
errorKey: "error_unknown",
errorParams: {
message: error instanceof Error ? error.message : String(error),
},
log,
};
}
}

286
src/patcher/patches.ts Normal file
View File

@ -0,0 +1,286 @@
import { NRO } from "./NRO";
import { nhex, PatternMatcher, type Pattern } from "./utils";
export class PatchContext {
appliedPatches = new Set<string>();
nro: NRO;
errorLog: string[] = [];
_currentPatch: Patch | null = null;
constructor(nro: NRO) {
this.nro = nro;
}
// track current patch
_onPatchStart(patch: Patch) {
this._currentPatch = patch;
}
_onPatchEnd() {
this._currentPatch = null;
}
log(message: string) {
if (this._currentPatch) {
this.errorLog.push(`${this._currentPatch.id}: ${message}`);
} else {
this.errorLog.push(`${message}`);
}
}
}
export abstract class Patch {
abstract readonly id: string;
abstract check(context: PatchContext): boolean;
// When this is called, the patch can safely assume that check() was called and returned true.
abstract apply(context: PatchContext): boolean;
}
export class LegacyAbiPatch extends Patch {
readonly id = "pattern_legacy_abi";
private readonly threadTlsGetOrig = PatternMatcher.parse(
"61 D0 3B D5 " + // mrs x1, TPIDRRO_EL0;
"20 CC 20 8B " + // add x0, x1, w0,sxtw#3;
"00 84 40 F9 " + // ldr x0, [x0,#0x108];
"C0 03 5F D6", // ret
);
private readonly threadTlsGetPatch = PatternMatcher.parse(
"61 D0 3B D5 " + // mrs x1, TPIDRRO_EL0;
"20 CC 20 8B " + // add x0, x1, w0,sxtw#3;
"00 C0 40 F9 " + // ldr x0, [x0,#0x180];
"C0 03 5F D6", // ret
);
private readonly threadTlsSetOrig = PatternMatcher.parse(
"62 D0 3B D5 " + // mrs x2, TPIDRRO_EL0;
"40 CC 20 8B " + // add x0, x2, w0,sxtw#3;
"01 84 00 F9 " + // str x1, [x0,#0x108];
"C0 03 5F D6", // ret
);
private readonly threadTlsSetPatch = PatternMatcher.parse(
"62 D0 3B D5 " + // mrs x2, TPIDRRO_EL0;
"40 CC 20 8B " + // add x0, x2, w0,sxtw#3;
"01 C0 00 F9 " + // str x1, [x0,#0x180];
"C0 03 5F D6", // ret
);
// all of this needs somewhat complex disassembly to patch reliably
// though those stupid patches should work in most cases
private readonly threadEntryOrigGCC15O2 = PatternMatcher.parse(
"01 0C 40 F9 " + // ldr x1, [x0, #24]
"A1 FA 00 F9 " + // str x1, [x21, #496]
"01 00 00 90 " + // adrp x1, 0
"A3 F6 00 F9 " + // str x3, [x21, #488]
"B5 22 04 91 " + // add x21, x21, #0x108
"21 00 40 F9 ", // ldr x1, [x1]
);
private readonly threadEntryPatchGCC15O2 = PatternMatcher.parse(
"01 0C 40 F9 " + // ldr x1, [x0, #24]
"A1 FA 00 F9 " + // str x1, [x21, #496]
"01 00 00 90 " + // adrp x1, 0
"A3 F6 00 F9 " + // str x3, [x21, #488]
"B5 02 06 91 " + // add x21, x21, #0x180
"21 00 40 F9 ", // ldr x1, [x1]
);
private readonly threadEntryOrigGCC14O2 = PatternMatcher.parse(
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
"64 8A 41 A9 " + // ldp x4, x2, [x19, #24]
"A1 82 07 91 " + // add x1, x21, #0x1e0
"A5 E6 01 B9 " + // str w5, [x21, #484]
"B5 22 04 91", // add x21, x21, #0x108
);
private readonly threadEntryPatchGCC14O2 = PatternMatcher.parse(
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
"64 8A 41 A9 " + // ldp x4, x2, [x19, #24]
"A1 82 07 91 " + // add x1, x21, #0x1e0
"A5 E6 01 B9 " + // str w5, [x21, #484]
"B5 02 06 91", // add x21, x21, #0x180
);
// this pattern seems consistent back to GCC 9?
private readonly threadEntryOrigGCC13 = PatternMatcher.parse(
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
"A2 E2 01 B9 " + // str w2, [x21, #480]
"A5 E6 01 B9 " + // str w5, [x21, #484]
"A3 92 1E A9 " + // stp x3, x4, [x21, #488]
"B5 22 04 91 ", // add x21, x21, #0x108
);
private readonly threadEntryPatchGCC13 = PatternMatcher.parse(
"75 D0 3B D5 " + // mrs x21, tpidrro_el0
"A2 E2 01 B9 " + // str w2, [x21, #480]
"A5 E6 01 B9 " + // str w5, [x21, #484]
"A3 92 1E A9 " + // stp x3, x4, [x21, #488]
"B5 02 06 91", // add x21, x21, #0x180
);
// this seems consistent at -O1 level and below
private readonly threadEntryOrigGCC15O1 = PatternMatcher.parse(
"60 02 40 F9 " + // ldr x0, [x19]
"B5 22 04 91 " + // add x21, x21, #0x108
"15 10 00 F9 " + // str x21, [x0, #32]
"60 02 40 F9 " + // ldr x0, [x19]
"81 22 00 91 " + // add x1, x20, #0x8
"01 18 00 F9 " + // str x1, [x0, #48]
"61 02 40 F9 " + // ldr x1, [x19]
"80 06 40 F9 " + // ldr x0, [x20, #8]
"20 14 00 F9 ", // str x0, [x1, #40]
);
private readonly threadEntryPatchGCC15O1 = PatternMatcher.parse(
"60 02 40 F9 " + // ldr x0, [x19]
"B5 02 06 91 " + // add x21, x21, #0x180
"15 10 00 F9 " + // str x21, [x0, #32]
"60 02 40 F9 " + // ldr x0, [x19]
"81 22 00 91 " + // add x1, x20, #0x8
"01 18 00 F9 " + // str x1, [x0, #48]
"61 02 40 F9 " + // ldr x1, [x19]
"80 06 40 F9 " + // ldr x0, [x20, #8]
"20 14 00 F9", // str x0, [x1, #40]
);
private readonly threadEntryOrigLibNx1 = PatternMatcher.parse(
"84 E2 01 B9 " + // str w4, [x20, #0x1e0]
"64 0E 40 F9 " + // ldr x4, [x19, #0x18]
"83 F6 00 F9 " + // str x3, [x20, #0x1e8]
"63 00 40 B9 " + // ldr w3, [x3]
"42 40 00 D1 " + // sub x2, x2, #0x10
"84 0A 1F A9 " + // stp x4, x2, [x20, #0x1f0]
"94 22 04 91 " + // add x20, x20, #0x108
"83 DE 00 B9", // str w3, [x20, #0xdc]
);
private readonly threadEntryPatchLibNx1 = PatternMatcher.parse(
"84 E2 01 B9 " + // str w4, [x20, #0x1e0]
"64 0E 40 F9 " + // ldr x4, [x19, #0x18]
"83 F6 00 F9 " + // str x3, [x20, #0x1e8]
"63 00 40 B9 " + // ldr w3, [x3]
"42 40 00 D1 " + // sub x2, x2, #0x10
"84 0A 1F A9 " + // stp x4, x2, [x20, #0x1f0]
"94 02 06 91 " + // add x20, x20, #0x180
"83 DE 00 B9", // str w3, [x20, #0xdc]
);
private readonly threadEntryOrigLibNx2 = PatternMatcher.parse(
"02 02 80 d2 " + // movz x2, #0x10
"f3 53 01 a9 " + // stp x19, x20, [sp, #0x10]
"f3 03 00 aa " + // mov x19, x0
"74 d0 3b d5 " + // mrs x20, tpidrro_el0
"94 22 04 91 " + // add x20, x20, #0x108
"03 00 40 f9 " + // ldr x3, [x0]
"f5 13 00 f9 " + // str x21, [sp, #0x20]
"81 da 00 b9 " + // str w1, [x20, #0xd8]
"01 0c 40 f9 " + // ldr x1, [x0, #0x18]
"83 72 00 f9 " + // str x3, [x20, #0xe0]
"81 76 00 f9 " + // str x1, [x20, #0xe8]
"?? ?? ?? ?? " +
"?? ?? ?? ?? " +
"21 00 40 f9 " + // ldr x1, [x1]
"3f 40 00 f1 " + // cmp x1, #0x10
"21 20 82 9a " + // csel x1, x1, x2, hs
"02 10 40 f9 " + // ldr x2, [x0, #0x20]
"?? ?? ?? ?? " +
"?? ?? ?? ?? " +
"41 00 01 cb " + // sub x1, x2, x1
"81 7a 00 f9 " + // str x1, [x20, #0xf0]
"61 00 40 b9 " + // ldr w1, [x3]
"81 de 00 b9 ", // str w1, [x20, #0xdc]
);
private readonly threadEntryPatchLibNx2 = PatternMatcher.parse(
"02 02 80 d2 " + //movz x2, #0x10
"f3 53 01 a9 " + // stp x19, x20, [sp, #0x10]
"f3 03 00 aa " + // mov x19, x0
"74 d0 3b d5 " + // mrs x20, tpidrro_el0
"94 02 06 91 " + // add x20, x20, #0x108+0x78
"03 00 40 f9 " + // ldr x3, [x0]
"f5 13 00 f9 " + // str x21, [sp, #0x20]
"81 62 00 b9 " + // str w1, [x20, #0xd8-0x78]
"01 0c 40 f9 " + // ldr x1, [x0, #0x18]
"83 36 00 f9 " + // str x3, [x20, #0xe0-0x78]
"81 3a 00 f9 " + // str x1, [x20, #0xe8-0x78]
"?? ?? ?? ?? " +
"?? ?? ?? ?? " +
"21 00 40 f9 " + // ldr x1, [x1]
"3f 40 00 f1 " + // cmp x1, #0x10
"21 20 82 9a " + // csel x1, x1, x2, hs
"02 10 40 f9 " + // ldr x2, [x0, #0x20]
"?? ?? ?? ?? " +
"?? ?? ?? ?? " +
"41 00 01 cb " + // sub x1, x2, x1
"81 3e 00 f9 " + // str x1, [x20, #0xf0-0x78]
"61 00 40 b9 " + // ldr w1, [x3]
"81 66 00 b9 ", // str w1, [x20, #0xdc-0x78]
);
private readonly patchesOrig = [
this.threadTlsGetOrig,
this.threadTlsSetOrig,
this.threadEntryOrigGCC15O2,
this.threadEntryOrigGCC14O2,
this.threadEntryOrigGCC13,
this.threadEntryOrigGCC15O1,
this.threadEntryOrigLibNx1,
this.threadEntryOrigLibNx2,
];
// prettier-ignore
private readonly patchesPatch: Array<{o: Pattern, p: Pattern, cmt: string}> = [
{o: this.threadTlsGetOrig, p: this.threadTlsGetPatch, cmt: "threadTlsGet()"},
{o: this.threadTlsSetOrig, p: this.threadTlsSetPatch, cmt: "threadTlsSet()"},
{o: this.threadEntryOrigGCC15O2, p: this.threadEntryPatchGCC15O2, cmt: "threadEntry() GCC 15 -O2"},
{o: this.threadEntryOrigGCC14O2, p: this.threadEntryPatchGCC14O2, cmt: "threadEntry() GCC 14 -O2"},
{o: this.threadEntryOrigGCC13, p: this.threadEntryPatchGCC13, cmt: "threadEntry() GCC 13 and below -O2"},
{o: this.threadEntryOrigGCC15O1, p: this.threadEntryPatchGCC15O1, cmt: "threadEntry() GCC 15 and below -O1"},
{o: this.threadEntryOrigLibNx1, p: this.threadEntryPatchLibNx1, cmt: "threadEntry() LibNX patch 1"},
{o: this.threadEntryOrigLibNx2, p: this.threadEntryPatchLibNx2, cmt: "threadEntry() LibNX patch 2"},
];
check(context: PatchContext): boolean {
return this.patchesOrig.some((pattern) => {
if (typeof pattern === "string") {
pattern = PatternMatcher.parse(pattern);
}
return PatternMatcher.find(context.nro.text, pattern) !== -1;
});
}
apply(context: PatchContext): boolean {
let anyApplied = false;
for (const { o, p, cmt } of this.patchesPatch) {
const offset = PatternMatcher.find(context.nro.text, o);
if (offset !== -1) {
context.log(`Found pattern ${cmt} at offset ${nhex(offset)}`);
PatternMatcher.applyPattern(context.nro.text, offset, p);
context.appliedPatches.add(this.id);
anyApplied = true;
} else {
// context.log(`Pattern ${cmt} not found`);
}
}
return anyApplied;
}
}
// TODO: What approach should we take here to add a LNY2 marker?
// I personally think it's wiser to just leave it as is so that hbmenu keeps the red warning visible - the whole method is a massive hack anyway.
// There's not enough space in the MOD0 header, so the best solution I'm thinking of is to move it to the end of file if possible.
// export class Crt0Lny2Patch extends Patch {
// readonly id = "pattern_crt0_lny2";
// check(context: PatchContext): boolean {
// const { nro } = context;
// return false;
// }
// apply(context: PatchContext): boolean {
// return false;
// }
// }
export const PATCHES: Patch[] = [
new LegacyAbiPatch(),
// new Crt0Lny2Patch()
];

136
src/patcher/utils.ts Normal file
View File

@ -0,0 +1,136 @@
export function unhex(hex: string): Uint8Array {
// Remove all spaces and parse every two hex characters as a byte
const clean = hex.replace(/\s+/g, "");
if (clean.length % 2 !== 0)
throw new Error("Hex string must have even length");
const arr = [];
for (let i = 0; i < clean.length; i += 2) {
arr.push(parseInt(clean.slice(i, i + 2), 16));
}
return new Uint8Array(arr);
}
export function nhex(num: number | bigint): string {
return `0x${num.toString(16).padStart(8, "0")}`;
}
export interface Pattern {
bytes: Uint8Array;
mask: Uint8Array;
}
export class PatternMatcher {
/**
* Parses a pattern string into a structure for matching.
* Supports hex bytes (e.g. "AA") and wildcards ("??").
* Spaces are ignored.
*/
static parse(pattern: string): Pattern {
const cleanPattern = pattern.replace(/\s+/g, "");
if (cleanPattern.length % 2 !== 0) {
throw new Error("Invalid pattern length");
}
const length = cleanPattern.length / 2;
const bytes = new Uint8Array(length);
const mask = new Uint8Array(length);
for (let i = 0; i < length; i++) {
const byteStr = cleanPattern.substr(i * 2, 2);
if (byteStr === "??") {
bytes[i] = 0;
mask[i] = 0;
} else {
bytes[i] = parseInt(byteStr, 16);
mask[i] = 0xff;
}
}
return { bytes, mask };
}
/**
* Finds the first occurrence of the pattern in the data.
* Returns the offset or -1 if not found.
*/
static find(data: Uint8Array, pattern: Pattern): number {
const { bytes, mask } = pattern;
// Optimization: if no wildcards, use simpler search
let hasWildcards = false;
for (let i = 0; i < mask.length; i++) {
if (mask[i] !== 0xff) {
hasWildcards = true;
break;
}
}
if (!hasWildcards) {
return this.findExact(data, bytes);
}
return this.findWithWildcards(data, bytes, mask);
}
private static findExact(data: Uint8Array, pattern: Uint8Array): number {
const len = pattern.length;
const end = data.length - len;
for (let i = 0; i <= end; i++) {
let found = true;
for (let j = 0; j < len; j++) {
if (data[i + j] !== pattern[j]) {
found = false;
break;
}
}
if (found) return i;
}
return -1;
}
private static findWithWildcards(
data: Uint8Array,
pattern: Uint8Array,
mask: Uint8Array,
): number {
const len = pattern.length;
const end = data.length - len;
for (let i = 0; i <= end; i++) {
let found = true;
for (let j = 0; j < len; j++) {
if (mask[j] === 0xff && data[i + j] !== pattern[j]) {
found = false;
break;
}
}
if (found) return i;
}
return -1;
}
/**
* Applies a patch pattern to a target buffer, skipping wildcards ('??').
* Wildcard bytes (mask value 0x00) are not written into the data array.
*
* @param data Target Uint8Array to patch (will be mutated).
* @param offset Offset in data at which to write the pattern.
* @param pattern Patch bytes (with wildcards matching any value).
* @param mask Uint8Array mask (0xFF for bytes to write, 0x00 for wildcards/'??').
*/
static applyPattern(
data: Uint8Array,
offset: number,
pattern: Pattern,
): void {
const { bytes, mask } = pattern;
const len = Math.min(bytes.length, mask.length, data.length - offset);
for (let i = 0; i < len; i++) {
if (mask[i] === 0xff) {
data[offset + i] = bytes[i];
}
// if mask[i] === 0x00, skip (wildcard, preserve existing byte)
}
}
}

22
src/types.ts Normal file
View File

@ -0,0 +1,22 @@
export type FileState =
| "none"
| "invalid"
| "new-abi"
| "patched"
| "needs-patching";
export interface AnalysisResult {
state: FileState;
fileName: string;
messageKey: string;
messageParams?: Record<string, string | number>;
log: string[];
}
export interface PatchResult {
success: boolean;
patchedData?: Uint8Array;
errorKey?: string;
errorParams?: Record<string, string | number>;
log: string[];
}

31
src/utils/localization.ts Normal file
View File

@ -0,0 +1,31 @@
export const MESSAGES: Record<string, string> = {
status_invalid_nro: "This file does not appear to be a valid NRO file.",
status_needs_patching: "This file uses the old ABI and can be patched.",
status_new_abi:
"This file already uses the new ABI and does not need patching.",
status_no_pattern:
"This file is a valid NRO but does not contain the expected ABI pattern. It may not need patching.",
pattern_legacy_abi: "Legacy ABI",
pattern_crt0_lny2: "Missing LNY2 (CRT0)",
error_analysis_failed: "Failed to analyze file. Please try again.",
error_already_patched: "File is already patched.",
error_pattern_not_found: "Could not find the ABI pattern to patch.",
error_unknown: "Unknown error occurred: {message}",
};
export function formatMessage(
key: string,
params?: Record<string, string | number>,
): string {
let message = MESSAGES[key] || key;
if (params) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
let displayValue = String(paramValue);
if (typeof paramValue === "string" && MESSAGES[paramValue]) {
displayValue = MESSAGES[paramValue];
}
message = message.replace(`{${paramKey}}`, displayValue);
});
}
return message;
}

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": 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://vite.dev/config/
export default defineConfig({
plugins: [react()],
})