Merge branch 'dev'

This commit is contained in:
OTKUSteyler 2025-12-27 01:36:29 +01:00
commit 945acc1738
27 changed files with 2672 additions and 1529 deletions

View file

@ -1,7 +1,3 @@
{
"languages": {
"TypeScript": {
"format_on_save": "off"
}
}
"format_on_save": "off",
}

2778
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -15,43 +15,47 @@
"devDependencies": {
"@saithodev/semantic-release-backmerge": "^4.0.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"semantic-release": "^24.2.7",
"semantic-release": "^25.0.2",
"@swc/core": "^1.13.1",
"@swc/helpers": "^0.5.17",
"@types/node": "^20.19.9",
"@types/node": "^25.0.3",
"@types/yargs-parser": "^21.0.3",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/typescript-estree": "^7.18.0",
"chalk": "^5.4.1",
"esbuild": "^0.20.2",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@typescript-eslint/typescript-estree": "^8.50.1",
"chalk": "^5.6.2",
"esbuild": "^0.27.2",
"esbuild-plugin-globals": "^0.2.0",
"eslint": "^8.57.1",
"eslint": "^9.39.2",
"eslint-plugin-import-alias": "^1.2.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^3.2.0",
"typescript": "^5.8.3",
"yargs-parser": "^21.1.1"
"eslint-plugin-unused-imports": "^4.3.0",
"typescript": "^5.9.3",
"yargs-parser": "^22.0.0"
},
"dependencies": {
"@gullerya/object-observer": "^6.1.4",
"@tanstack/react-query": "^5.83.0",
"@types/chroma-js": "~2.4.5",
"@types/lodash": "~4.17.20",
"@types/react": "19.1.8",
"es-toolkit": "^1.39.7",
"@tanstack/react-query": "^5.90.12",
"@types/chroma-js": "~3.1.2",
"@types/lodash": "~4.17.21",
"@types/react": "19.2.7",
"es-toolkit": "^1.43.0",
"fuzzysort": "^3.1.0",
"intl-messageformat": "^10.7.16",
"moment": "2.22.2",
"react-native": "0.78.0",
"react-native-reanimated": "^3.18.0",
"spitroast": "^1.4.4",
"type-fest": "^4.41.0"
"intl-messageformat": "^11.0.5",
"moment": "2.30.1",
"react-devtools": "^7.0.1",
"react-native": "0.83.1",
"react-native-reanimated": "^4.2.1",
"react-native-webview": "^13.16.0",
"spitroast": "^2.1.6",
"type-fest": "^5.3.1"
},
"trustedDependencies": [
"@swc/core"
"@swc/core",
"electron",
"esbuild"
]
}

View file

@ -6,6 +6,6 @@ module.exports = {
"util": findByPropsLazy("inspect", "isNullOrUndefined"),
"moment": findByPropsLazy("isMoment"),
"chroma-js": findByPropsLazy("brewer"),
"lodash": findByPropsLazy("forEachRight")
// "@shopify/react-native-skia": findByPropsLazy("useFont")
"lodash": findByPropsLazy("forEachRight"),
"react-native-webview": findByPropsLazy("WebView")
};

View file

@ -240,7 +240,7 @@ export default defineCorePlugin({
// Initialize sound settings
settings.startupSound = settings.startupSound || {
enabled: true,
enabled: false,
probability: 2,
};

View file

@ -44,7 +44,7 @@ const useStyles = createStyles({
},
headerLabel: {
...TextStyleSheet["heading-md/semibold"],
color: semanticColors.HEADER_PRIMARY,
color: semanticColors.MOBILE_TEXT_HEADING_PRIMARY,
},
headerSubtitle: {
...TextStyleSheet["text-md/semibold"],
@ -60,7 +60,7 @@ const useStyles = createStyles({
gap: 5,
},
iconStyle: {
tintColor: semanticColors.LOGO_PRIMARY,
tintColor: semanticColors.INTERACTIVE_ICON_DEFAULT,
opacity: 0.2,
height: 64,
width: 64,

View file

@ -236,7 +236,7 @@ export default function AddonPage<T extends object>({
>
<View style={{ gap: 8, alignItems: "center" }}>
<Image source={findAssetId("empty_quick_switcher")!} />
<Text variant="text-lg/semibold" color="text-normal">
<Text variant="text-lg/semibold" color="text-default">
Oops! Nothing to see here… yet!
</Text>
</View>
@ -303,7 +303,7 @@ export default function AddonPage<T extends object>({
ListEmptyComponent={() => (
<View style={{ gap: 12, padding: 12, alignItems: "center" }}>
<Image source={findAssetId("devices_not_found")!} />
<Text variant="text-lg/semibold" color="text-normal">
<Text variant="text-lg/semibold" color="text-default">
Hmmm... could not find that!
</Text>
</View>

View file

@ -3,7 +3,11 @@ import { CheckState, useFileExists } from "@core/ui/hooks/useFS";
import AssetBrowser from "@core/ui/settings/pages/Developer/AssetBrowser";
import { useProxy } from "@core/vendetta/storage";
import { findAssetId } from "@lib/api/assets";
import { connectToDebugger } from "@lib/api/debug";
import {
connectToDebugger,
disconnectFromDebugger,
isConnectedToDebugger,
} from "@lib/api/debug";
import {
getReactDevToolsProp,
getReactDevToolsVersion,
@ -31,6 +35,7 @@ import { createStyles, TextStyleSheet } from "@ui/styles";
import { NativeModules } from "react-native";
import { ScrollView, StyleSheet } from "react-native";
import { showToast } from "@ui/toasts";
import { useState, useEffect } from "react";
const { hideActionSheet } = lazyDestructure(() =>
findByProps("openLazy", "hideActionSheet"),
@ -45,8 +50,8 @@ const { AlertModal, AlertActionButton } = lazyDestructure(() =>
findByProps("AlertModal", "AlertActions"),
);
// todo: This link isnt minified, but it probably should be
const RDT_EMBED_LINK = "https://codeberg.org/raincord/Devtools/raw/branch/main/reactDevtools.js";
const RDT_EMBED_LINK =
"https://codeberg.org/raincord/raindevtools/raw/branch/dev/dist/index.bundle";
const useStyles = createStyles({
leadingText: {
@ -64,6 +69,9 @@ const useStyles = createStyles({
export default function Developer() {
const [rdtFileExists, fs] = useFileExists("preloads/reactDevtools.js");
const [isDebuggerConnected, setIsDebuggerConnected] = useState(
isConnectedToDebugger(),
);
const styles = useStyles();
const navigation = NavigationNative.useNavigation();
@ -71,6 +79,23 @@ export default function Developer() {
useProxy(settings);
useProxy(loaderConfig);
useEffect(() => {
const interval = setInterval(() => {
setIsDebuggerConnected(isConnectedToDebugger());
}, 1000);
return () => clearInterval(interval);
}, []);
const handleDebuggerConnect = () => {
if (isDebuggerConnected) {
disconnectFromDebugger();
setIsDebuggerConnected(false);
} else {
connectToDebugger(settings.debuggerUrl);
setTimeout(() => setIsDebuggerConnected(isConnectedToDebugger()), 100);
}
};
return (
<ErrorBoundary>
<ScrollView
@ -250,6 +275,11 @@ export default function Developer() {
fs.downloadFile(
RDT_EMBED_LINK,
"preloads/reactDevtools.js",
).then(() =>
showToast(
"Successfully installed! A reload is required",
findAssetId("DownloadIcon"),
),
);
} else if (rdtFileExists === CheckState.TRUE) {
fs.removeFile("preloads/reactDevtools.js");
@ -257,7 +287,7 @@ export default function Developer() {
}}
icon={findAssetId(
rdtFileExists === CheckState.TRUE
? "ic_message_delete"
? "TrashIcon"
: "DownloadIcon",
)}
style={{ marginLeft: 8 }}

View file

@ -4,13 +4,89 @@ import { showConfirmationAlert } from "@core/vendetta/alerts";
import { useProxy } from "@core/vendetta/storage";
import { FontDefinition, fonts, selectFont } from "@lib/addons/fonts";
import { findAssetId } from "@lib/api/assets";
import { lazyDestructure } from "@lib/utils/lazy";
import { BundleUpdaterManager } from "@lib/api/native/modules";
import { NavigationNative } from "@metro/common";
import { Button, Card, IconButton, Text } from "@metro/common/components";
import { View } from "react-native";
import { createStyles, TextStyleSheet } from "@lib/ui/styles";
import { NavigationNative, tokens } from "@metro/common";
import {
Button,
Card,
IconButton,
Stack,
Text,
} from "@metro/common/components";
import { findByProps } from "@metro";
import { useMemo, useState } from "react";
import { PixelRatio, View } from "react-native";
import { WebView } from "react-native-webview";
import previewHtml from "./preview.html";
import FontEditor from "./FontEditor";
const { useToken } = lazyDestructure(() => findByProps("useToken"));
const useStyles = createStyles({
full: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
},
});
function FontPreview({ font }: { font: FontDefinition }) {
const [loaded, setLoaded] = useState(false);
const styles = useStyles();
const TEXT_DEFAULT = useToken(tokens.colors.TEXT_DEFAULT);
const { fontFamily: fontFamilyList, fontSize } =
TextStyleSheet["text-md/medium"];
const fontFamily = fontFamilyList!.split(/,/g)[0];
const props = useMemo(
() => ({
family: font.main[fontFamily],
size: fontSize! * PixelRatio.getFontScale(),
color: TEXT_DEFAULT,
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
}),
[font.main, fontFamily, fontSize, TEXT_DEFAULT],
);
return (
<View style={{ width: "100%", height: 64 }}>
<WebView
onMessage={() => setLoaded(true)}
source={{
html: previewHtml.replace("$$props", JSON.stringify(props)),
}}
javaScriptEnabled
scrollEnabled={false}
overScrollMode="never"
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
pointerEvents="none"
style={[
styles.full,
{ backgroundColor: "transparent", opacity: Number(loaded) },
]}
/>
{!loaded && (
<View
style={[
styles.full,
{ justifyContent: "center", alignItems: "center" },
]}
>
<Text color="text-muted" variant="heading-lg/semibold">
Loading...
</Text>
</View>
)}
</View>
);
}
export default function FontCard({ item: font }: CardWrapper<FontDefinition>) {
useProxy(fonts);
@ -18,50 +94,51 @@ export default function FontCard({ item: font }: CardWrapper<FontDefinition>) {
const selected = fonts.__selected === font.name;
return (
<Card style={{ padding: 18, borderRadius: 16, backgroundColor: "#23272a" }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1 }}>
<Text variant="heading-lg/bold">{font.name}</Text>
</View>
<Card>
<Stack spacing={16}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Button
size="sm"
variant={selected ? "secondary" : "primary"}
text={selected ? "Unapply" : "Apply"}
style={{ minWidth: 80, marginRight: 8 }}
onPress={async () => {
await selectFont(selected ? null : font.name);
showConfirmationAlert({
title: Strings.HOLD_UP,
content: "Reload Discord to apply changes?",
confirmText: Strings.RELOAD,
cancelText: Strings.CANCEL,
confirmColor: "red",
onConfirm: BundleUpdaterManager.reload,
});
}}
/>
<IconButton
onPress={() => {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: "Edit Font",
render: () => <FontEditor name={font.name} />,
});
}}
size="sm"
variant="secondary"
disabled={selected}
icon={findAssetId("WrenchIcon")}
/>
<View>
<Text variant="heading-lg/semibold">{font.name}</Text>
{/* TODO: Text wrapping doesn't work well */}
{/* <Text color="text-muted" variant="text-sm/semibold">
{font.description}
</Text> */}
</View>
<View style={{ marginLeft: "auto" }}>
<Stack spacing={12} direction="horizontal">
<IconButton
onPress={() => {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: "Edit Font",
render: () => <FontEditor name={font.name} />,
});
}}
size="sm"
variant="secondary"
disabled={selected}
icon={findAssetId("WrenchIcon")}
/>
<Button
size="sm"
variant={selected ? "secondary" : "primary"}
text={selected ? "Unapply" : "Apply"}
onPress={async () => {
await selectFont(selected ? null : font.name);
showConfirmationAlert({
title: Strings.HOLD_UP,
content: "Reload Discord to apply changes?",
confirmText: Strings.RELOAD,
cancelText: Strings.CANCEL,
confirmColor: "red",
onConfirm: BundleUpdaterManager.reload,
});
}}
/>
</Stack>
</View>
</View>
</View>
<FontPreview font={font} />
</Stack>
</Card>
);
}

View file

@ -332,7 +332,7 @@ export default function FontEditor(props: {
return <TableRow
label={name}
subLabel={error ? <Text variant='text-xs/medium' color='text-danger'>{error.message}</Text> : url}
subLabel={error ? <Text variant='text-xs/medium' color='text-feedback-critical'>{error.message}</Text> : url}
trailing={<Stack spacing={8} direction="horizontal">
<IconButton
size="sm"
@ -363,7 +363,7 @@ export default function FontEditor(props: {
})}
<TableRow label={<NewEntryRow fontName={props.name} fontEntry={fontEntries} />} />
</TableRowGroup>
{errors && <Text variant='text-sm/medium' color='text-danger'>Some font entries cannot be imported. Please modify the entries and try again.</Text>}
{errors && <Text variant='text-sm/medium' color='text-feedback-critical'>Some font entries cannot be imported. Please modify the entries and try again.</Text>}
<View style={{ flexDirection: "row", justifyContent: "flex-end", bottom: 0, left: 0 }}>
<Button
size="lg"

View file

@ -0,0 +1,20 @@
<head>
<meta name="viewport" content="initial-scale=1.0" />
</head>
<body style="overflow: hidden; padding: 0; margin: 0"></body>
<script>
const props = $$props;
new FontFace("Preview", `url(${props.family})`).load().then((font) => {
document.fonts.add(font);
const p = document.createElement("p");
p.textContent = props.text;
p.style.fontFamily = font.family;
p.style.fontSize = `${props.size}px`;
p.style.color = props.color;
document.body.appendChild(p);
ReactNativeWebView.postMessage(true);
});
</script>

View file

@ -79,7 +79,7 @@ export default function General() {
<Text
variant="text-md/semibold"
style={{
color: "#FFFFFF",
color: "text-default",
textAlign: "left",
}}
>

View file

@ -115,7 +115,7 @@ const Actions = () => {
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("WrenchIcon")}
icon={findAssetId("SettingsIcon")}
disabled={!plugin.getPluginSettingsComponent()}
onPress={() =>
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {

View file

@ -195,7 +195,7 @@ export default function PluginInfoActionSheet({
label="Configure"
variant="secondary"
disabled={!plugin.getPluginSettingsComponent()}
icon={findAssetId("WrenchIcon")}
icon={findAssetId("SettingsIcon")}
onPress={() => {
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {
title: plugin.name,
@ -236,7 +236,7 @@ export default function PluginInfoActionSheet({
variant="text-md/semibold"
style={{
marginBottom: 4,
color: semanticColors.HEADER_PRIMARY,
color: "text-strong",
}}
>
Description

View file

@ -43,7 +43,7 @@ export default function PluginInfoActionSheet({
size="md"
text="Configure"
variant="secondary"
icon={findAssetId("WrenchIcon")}
icon={findAssetId("SettingsIcon")}
onPress={() => {
hideSheet("PluginInfoActionSheet");
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {

View file

@ -194,7 +194,7 @@ export default function ThemeInfoActionSheet({
variant="text-md/semibold"
style={{
marginBottom: 4,
color: semanticColors.HEADER_PRIMARY,
color: semanticColors.MOBILE_TEXT_HEADING_PRIMARY,
}}
>
Description

View file

@ -140,10 +140,21 @@ export const initVendettaObject = (): any => {
FluxDispatcher: common.FluxDispatcher,
React: common.React,
ReactNative: common.ReactNative,
moment: require("moment"),
chroma: require("chroma-js"),
lodash: require("lodash"),
util: require("util"),
get moment() {
// Lazy-require heavy libs to avoid pulling them into the startup bundle.
// Consumers access `window.vendetta.common.moment` and this getter will
// require the module on first access.
return require("moment");
},
get chroma() {
return require("chroma-js");
},
get lodash() {
return require("lodash");
},
get util() {
return require("util");
},
},
},
constants: {

23
src/global.d.ts vendored
View file

@ -1,18 +1,17 @@
declare global {
type React = typeof import("react");
var SkiaApi: typeof import("@shopify/react-native-skia").Skia;
type React = typeof import("react");
// ReactNative/Hermes globals
var globalEvalWithSourceUrl: (script: string, sourceURL: string) => any;
var nativePerformanceNow: typeof performance.now;
var nativeModuleProxy: Record<string, any>;
var __turboModuleProxy: (name: string) => any;
// ReactNative/Hermes globals
var globalEvalWithSourceUrl: (script: string, sourceURL: string) => any;
var nativePerformanceNow: typeof performance.now;
var nativeModuleProxy: Record<string, any>;
var __turboModuleProxy: (name: string) => any;
interface Window {
[key: string]: any;
vendetta: any;
bunny: typeof import("@lib");
}
interface Window {
[key: string]: any;
vendetta: any;
bunny: typeof import("@lib");
}
}
export { };

View file

@ -19,30 +19,35 @@ import { getDebugInfo, initDebugger } from "@lib/api/debug";
// imported when needed (to avoid bundling it permanently) and removed after use.
import * as lib from "./lib";
import { timings } from "@lib/utils/timings";
/**
* Start sequence split into critical (UI) and deferred (network/plugin) work.
* The goal is to get the UI ready quickly and run heavy tasks after interactions.
*/
export default async () => {
// Critical initializers that are required for the app's UI and basic features.
const criticalInits = [
initThemes(),
injectFluxInterceptor(),
patchSettings(),
patchLogHook(),
patchCommands(),
patchJsx(),
patchErrorBoundary(),
initVendettaObject(),
initFetchI18nStrings(),
initSettings(),
initFixes(),
initDebugger(),
// Wrap critical initializers as named functions so we can instrument each.
const criticalInitFns: Array<[string, () => Promise<any>]> = [
["initThemes", () => initThemes()],
["injectFluxInterceptor", () => injectFluxInterceptor()],
["patchSettings", () => patchSettings()],
["patchLogHook", () => patchLogHook()],
["patchCommands", () => patchCommands()],
["patchJsx", () => patchJsx()],
["patchErrorBoundary", () => patchErrorBoundary()],
["initVendettaObject", () => initVendettaObject()],
["initFetchI18nStrings", () => initFetchI18nStrings()],
["initSettings", () => initSettings()],
["initFixes", () => initFixes()],
["initDebugger", () => initDebugger()],
];
// Run critical inits and collect unpatchers/cleanup handlers.
await Promise.all(criticalInits)
// Run critical inits with timing instrumentation and collect unpatchers/cleanup handlers.
await Promise.all(
criticalInitFns.map(([name, fn]) =>
timings.measureAsync(`critical:${name}`, async () => fn()),
),
)
.then((u) => u.forEach((f) => f && lib.unload.push(f)))
.catch((e) => {
// Log but don't abort — critical inits failing should be visible in logs.

View file

@ -14,18 +14,49 @@ export interface Asset {
// Cache common usage
const _nameToAssetCache = {} as Record<string, Asset>;
// Simple cached asset list with TTL to avoid iterating the flagsIndex too often.
// This is a small in-memory cache to improve repeated asset lookups during UI rendering.
let _cachedAssetList: Asset[] | null = null;
let _cachedAssetListTs = 0;
const ASSET_CACHE_TTL = 2500; // milliseconds
/**
* Invalidate the internal asset caches so the next iterateAssets() call will
* re-run through the metro flags index and refresh the cache.
*/
export function invalidateAssetCache() {
_cachedAssetList = null;
_cachedAssetListTs = 0;
for (const k in _nameToAssetCache) delete _nameToAssetCache[k];
}
export function* iterateAssets() {
// Return cached list when fresh
const now = Date.now();
if (_cachedAssetList && now - _cachedAssetListTs < ASSET_CACHE_TTL) {
for (const asset of _cachedAssetList) yield asset;
return;
}
const { flagsIndex } = getMetroCache();
const yielded = new Set<number>();
const list: Asset[] = [];
for (const id in flagsIndex) {
if (flagsIndex[id] & ModuleFlags.ASSET) {
const assetId = requireModule(Number(id));
if (typeof assetId !== "number" || yielded.has(assetId)) continue;
yield getAssetById(assetId);
const asset = getAssetById(assetId);
if (!asset) continue;
list.push(asset);
yielded.add(assetId);
yield asset;
}
}
// Update cache
_cachedAssetList = list;
_cachedAssetListTs = now;
}
// Apply additional properties for convenience

View file

@ -1,14 +1,13 @@
import { getThemeFromLoader, selectTheme, themes } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
import { getLoaderName, getLoaderVersion, isThemeSupported, getReactDevToolsProp } from "@lib/api/native/loader";
import { getLoaderName, getLoaderVersion, isThemeSupported, getReactDevToolsProp, isReactDevToolsPreloaded } from "@lib/api/native/loader";
import { BundleUpdaterManager, NativeClientInfoModule, NativeDeviceModule } from "@lib/api/native/modules";
import { after } from "@lib/api/patcher";
import { settings } from "@lib/api/settings";
import { logger } from "@lib/utils/logger";
import { showToast } from "@ui/toasts";
import { version } from "bunny-build-info";
import { Platform, type PlatformConstants } from "react-native";
import { StyleSheet } from "react-native";
import { Platform, type PlatformConstants, StyleSheet } from "react-native";
export interface RNConstants extends PlatformConstants {
// Android
@ -44,38 +43,291 @@ export async function toggleSafeMode() {
setTimeout(BundleUpdaterManager.reload, 400);
}
let socket: WebSocket;
// --- Debugger State & Logic ---
let socket: WebSocket | undefined;
let originalConsoleLog: any;
let originalConsoleError: any;
let originalConsoleWarn: any;
let originalLoggerLog: any;
let originalLoggerError: any;
let originalLoggerWarn: any;
const VERSION = 1;
enum MessageType {
Hello = 'hello',
Hi = 'hi',
Log = 'log',
Run = 'run',
}
enum LogLevel {
Debug = 'debug',
Default = 'default',
Warn = 'warn',
Error = 'error',
}
interface LogMessage {
type: MessageType.Log;
data: {
level: LogLevel;
message: any[];
};
}
interface HelloMessage {
type: MessageType.Hello;
data: {
version: number;
};
}
interface RunMessage {
type: MessageType.Run;
data: {
code: string;
};
}
function serializeMessage(msg: any): string {
return JSON.stringify(msg);
}
function sendLog(level: LogLevel, ...args: any[]) {
if (socket?.readyState === WebSocket.OPEN) {
const message: LogMessage = {
type: MessageType.Log,
data: {
level,
message: args
}
};
socket.send(serializeMessage(message));
}
}
function patchConsoleAndLogger() {
originalConsoleLog = console.log;
console.log = function(...args: any[]) {
originalConsoleLog.apply(console, args);
sendLog(LogLevel.Default, ...args);
};
originalConsoleError = console.error;
console.error = function(...args: any[]) {
originalConsoleError.apply(console, args);
sendLog(LogLevel.Error, ...args);
};
originalConsoleWarn = console.warn;
console.warn = function(...args: any[]) {
originalConsoleWarn.apply(console, args);
sendLog(LogLevel.Warn, ...args);
};
if (logger) {
originalLoggerLog = logger.log;
logger.log = function(...args: any[]) {
originalLoggerLog.apply(logger, args);
sendLog(LogLevel.Default, ...args);
};
originalLoggerError = logger.error;
logger.error = function(...args: any[]) {
originalLoggerError.apply(logger, args);
sendLog(LogLevel.Error, ...args);
};
originalLoggerWarn = logger.warn;
logger.warn = function(...args: any[]) {
originalLoggerWarn.apply(logger, args);
sendLog(LogLevel.Warn, ...args);
};
}
}
function unpatchConsoleAndLogger() {
if (originalConsoleLog) {
console.log = originalConsoleLog;
originalConsoleLog = undefined;
}
if (originalConsoleError) {
console.error = originalConsoleError;
originalConsoleError = undefined;
}
if (originalConsoleWarn) {
console.warn = originalConsoleWarn;
originalConsoleWarn = undefined;
}
if (logger) {
if (originalLoggerLog) {
logger.log = originalLoggerLog;
originalLoggerLog = undefined;
}
if (originalLoggerError) {
logger.error = originalLoggerError;
originalLoggerError = undefined;
}
if (originalLoggerWarn) {
logger.warn = originalLoggerWarn;
originalLoggerWarn = undefined;
}
}
}
export function connectToDebugger(url: string) {
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) {
unpatchConsoleAndLogger();
socket.close();
}
if (!url) {
showToast("Invalid debugger URL!", findAssetId("Small"));
return;
}
socket = new WebSocket(`ws://${url}`);
try {
socket = new WebSocket(`ws://${url}`);
socket.addEventListener("open", () => showToast("Connected to debugger.", findAssetId("Check")));
socket.addEventListener("message", (message: any) => {
try {
(0, eval)(message.data);
} catch (e) {
console.error(e);
}
});
socket.addEventListener("open", () => {
showToast("Connected to debugger.", findAssetId("Check"));
const hello: HelloMessage = {
type: MessageType.Hello,
data: {
version: VERSION
}
};
socket?.send(serializeMessage(hello));
socket.addEventListener("error", (err: any) => {
console.log(`Debugger error: ${err.message}`);
showToast("An error occurred with the debugger connection!", findAssetId("Small"));
});
patchConsoleAndLogger();
});
socket.addEventListener("message", (message: any) => {
try {
const data = JSON.parse(message.data);
if (data.type === MessageType.Run && data.data?.code) {
try {
(0, eval)(data.data.code);
} catch (e) {
console.error("Error executing remote code:", e);
}
}
} catch (e) {
try {
(0, eval)(message.data);
} catch (err) {
console.error(err);
}
}
});
socket.addEventListener("close", () => {
showToast("Disconnected from debugger.", findAssetId("Small"));
unpatchConsoleAndLogger();
});
socket.addEventListener("error", (err: any) => {
console.log(`Debugger error: ${err.message}`);
showToast("An error occurred with the debugger connection!", findAssetId("Small"));
unpatchConsoleAndLogger();
});
} catch (e) {
logger.error("Failed to connect to debugger:", e);
showToast("Failed to connect to debugger!", findAssetId("Small"));
}
}
export function disconnectFromDebugger() {
if (socket) {
unpatchConsoleAndLogger();
socket.close();
socket = undefined;
showToast("Disconnected from debugger.", findAssetId("Check"));
}
}
export function isConnectedToDebugger(): boolean {
return socket?.readyState === WebSocket.OPEN;
}
// --- React DevTools (RDT) Logic ---
const rdtPort = 8097;
export let rdtClient: WebSocket | null = null;
export let rdtConnected = false;
const changeHooks = new Set<(value: boolean) => void>();
function bump() {
for (const x of changeHooks) x(rdtConnected);
}
function cleanupRdt() {
rdtClient = null;
rdtConnected = false;
bump();
}
/** @internal */
export function connectRdt(url: string, quiet?: boolean) {
if (!isReactDevToolsPreloaded() || rdtClient) return;
const base = url.split(":").slice(0, -1).join(":");
const ws = (rdtClient = new WebSocket(`ws://${base}:${rdtPort}`));
ws.addEventListener('open', () => {
if (!quiet) showToast("Connected to React DevTools", findAssetId("CheckmarkSmallIcon"));
rdtConnected = true;
bump();
});
ws.addEventListener('close', () => {
cleanupRdt();
});
ws.addEventListener('error', (e: any) => {
cleanupRdt();
const err = e?.message ?? e?.stack ?? String(e);
logger.error('React DevTools error:', err);
if (!quiet) showToast(err, findAssetId("CircleXIcon-primary"));
});
const devTools = window[getReactDevToolsProp() || "__vendetta_rdc"];
if (devTools?.connectToDevTools) {
devTools.connectToDevTools({
websocket: ws,
resolveRNStyle: StyleSheet.flatten,
});
}
}
export function disconnectRdt() {
rdtClient?.close();
}
export function useIsRdtConnected() {
const [connected, update] = React.useState(rdtConnected);
React.useEffect(() => {
changeHooks.add(update);
return () => void changeHooks.delete(update);
}, []);
return connected;
}
// --- Core Utils ---
/**
* @internal
*/
export function patchLogHook() {
const unpatch = after("nativeLoggingHook", globalThis, args => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
if (socket?.readyState === WebSocket.OPEN) {
sendLog(args[1] === 'error' ? LogLevel.Error : args[1] === 'warn' ? LogLevel.Warn : LogLevel.Default, args[0]);
}
logger.log(args[0]);
});
@ -89,19 +341,13 @@ export function patchLogHook() {
export const versionHash = version;
export function getDebugInfo() {
// Hermes
const hermesProps = window.HermesInternal.getRuntimeProperties();
const hermesVer = hermesProps["OSS Release Version"];
const padding = "for RN ";
// RN
const PlatformConstants = Platform.constants as RNConstants;
const rnVer = PlatformConstants.reactNativeVersion;
return {
/**
* @deprecated use `bunny` field
* */
vendetta: {
version: versionHash.split("-")[0],
loader: getLoaderName(),
@ -126,76 +372,35 @@ export function getDebugInfo() {
buildType: hermesProps.Build,
bytecodeVersion: hermesProps["Bytecode Version"],
},
...Platform.select(
{
android: {
os: {
name: "Android",
version: PlatformConstants.Release,
sdk: PlatformConstants.Version
},
},
ios: {
os: {
name: PlatformConstants.systemName,
version: PlatformConstants.osVersion
},
}
}
)!,
...Platform.select(
{
android: {
device: {
manufacturer: PlatformConstants.Manufacturer,
brand: PlatformConstants.Brand,
model: PlatformConstants.Model,
codename: NativeDeviceModule.device
}
},
ios: {
device: {
manufacturer: NativeDeviceModule.deviceManufacturer,
brand: NativeDeviceModule.deviceBrand,
model: NativeDeviceModule.deviceModel,
codename: NativeDeviceModule.device
}
}
}
)!
...Platform.select({
android: { os: { name: "Android", version: PlatformConstants.Release, sdk: PlatformConstants.Version } },
ios: { os: { name: PlatformConstants.systemName, version: PlatformConstants.osVersion } }
})!,
...Platform.select({
android: { device: { manufacturer: PlatformConstants.Manufacturer, brand: PlatformConstants.Brand, model: PlatformConstants.Model, codename: NativeDeviceModule.device } },
ios: { device: { manufacturer: NativeDeviceModule.deviceManufacturer, brand: NativeDeviceModule.deviceBrand, model: NativeDeviceModule.deviceModel, codename: NativeDeviceModule.device } }
})!
};
}
/**
* @internal
*/
export function initDebugger() {
if (settings.autoDebugger) {
try {
connectToDebugger(settings.debuggerUrl);
} catch (e) {
logger.error("Failed to connect to Debugger during startup:", e);
}
try {
connectToDebugger(settings.debuggerUrl);
} catch (e) {
logger.error("Failed to connect to Debugger during startup:", e);
}
}
if (settings.autoDevTools) {
try {
(async () => {
const devTools = window[getReactDevToolsProp() || "__vendetta_rdc"];
if (!devTools?.connectToDevTools) {
showToast("Invalid devTools URL!", findAssetId("Small"));
return;
}
await devTools.connectToDevTools({
host: settings.devToolsUrl.split(":")?.[0],
resolveRNStyle: StyleSheet.flatten,
});
})();
} catch (e) {
logger.error("Failed to connect to ReactDevTools during startup:", e);
}
try {
if (settings.devToolsUrl) {
connectRdt(settings.devToolsUrl, true);
}
} catch (e) {
logger.error("Failed to connect to ReactDevTools during startup:", e);
}
}
}
}

View file

@ -175,10 +175,7 @@ export function getThemeFilePath() {
export function isReactDevToolsPreloaded() {
if (isPyonLoader()) {
return Boolean(window.__reactDevTools);
}
if (isRa1nLoader()) {
return false;
return Boolean(window.__REACT_DEVTOOLS__);
}
if (isVendettaLoader()) {
return vendettaLoaderIdentity!!.features.devtools != null;
@ -191,7 +188,7 @@ export function getReactDevToolsProp(): string | null {
if (!isReactDevToolsPreloaded()) return null;
if (isPyonLoader()) {
window.__pyoncord_rdt = window.__reactDevTools.exports;
window.__pyoncord_rdt = window.__REACT_DEVTOOLS__.exports;
return "__pyoncord_rdt";
}
@ -206,7 +203,7 @@ export function getReactDevToolsVersion() {
if (!isReactDevToolsPreloaded()) return null;
if (isPyonLoader()) {
return window.__reactDevTools.version || null;
return window.__REACT_DEVTOOLS__.version || null;
}
if (isVendettaLoader()) {
return vendettaLoaderIdentity!!.features.devtools!!.version;

View file

@ -0,0 +1,67 @@
import { logger } from "@lib/utils/logger";
import { getReactDevToolsProp, isReactDevToolsPreloaded } from "./native/loader";
import { showToast } from "@lib/ui/toasts";
import { findAssetId } from "./assets";
const rdtPort = 8097;
export let rdtClient: WebSocket | null = null;
export let rdtConnected = false;
const changeHooks = new Set<(value: boolean) => void>();
function bump() {
for (const x of changeHooks) x(rdtConnected);
}
function cleanup() {
rdtClient = null;
rdtConnected = false;
bump();
}
/** @internal */
export function connectRdt(url: string, quiet?: boolean) {
if (!isReactDevToolsPreloaded() || rdtClient) return;
const base = url.split(":").slice(0, -1);
const ws = (rdtClient = new WebSocket(`ws://${base}:${rdtPort}`));
ws.addEventListener('open', () => {
if (!quiet) showToast("Connected to React DevTools", findAssetId("CheckmarkSmallIcon"));
rdtConnected = true;
bump();
});
ws.addEventListener('close', () => {
cleanup();
});
ws.addEventListener('error', (e: any) => {
cleanup();
const err = e?.message ?? e?.stack ?? String(e);
logger.error('React DevTools error:', err);
if (!quiet) showToast(err, findAssetId("CircleXIcon-primary"));
});
window[getReactDevToolsProp() || "__vendetta_rdc"].connectToDevTools({
websocket: ws,
});
}
export function disconnectRdt() {
rdtClient?.close();
}
export function useIsRdtConnected() {
const [connected, update] = React.useState(rdtConnected);
React.useEffect(() => {
changeHooks.add(update);
return () => void changeHooks.delete(update);
});
return connected;
}

View file

@ -1,8 +1,18 @@
import { Emitter } from "@core/vendetta/Emitter";
import { Observable, ObserverOptions } from "@gullerya/object-observer";
import type { Observable as ObservableType, ObserverOptions } from "@gullerya/object-observer";
import { fileExists, readFile, removeFile, writeFile } from "@lib/api/native/fs";
import { debounce } from "es-toolkit";
let _ObservableRuntime: any = null;
function getObservableRuntime() {
if (_ObservableRuntime) return _ObservableRuntime;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require("@gullerya/object-observer");
// the module may export named members or the Observable directly
_ObservableRuntime = mod.Observable ?? mod;
return _ObservableRuntime;
}
const storageInitErrorSymbol = Symbol.for("bunny.storage.initError");
const storagePromiseSymbol = Symbol.for("bunny.storage.promise");
@ -34,12 +44,12 @@ function createFileBackend<T extends object>(filePath: string) {
};
}
export function useObservable(observables: Observable[], opts?: ObserverOptions) {
export function useObservable(observables: ObservableType[], opts?: ObserverOptions) {
if (observables.some((o: any) => o?.[storageInitErrorSymbol])) throw new Error(
"An error occured while initializing the storage",
);
if (observables.some(o => !Observable.isObservable(o))) {
if (observables.some(o => !getObservableRuntime().isObservable(o))) {
throw new Error("Argument passed isn't an Observable");
}
@ -48,10 +58,10 @@ export function useObservable(observables: Observable[], opts?: ObserverOptions)
React.useEffect(() => {
const listener = () => forceUpdate();
observables.forEach(o => Observable.observe(o, listener, opts));
observables.forEach(o => getObservableRuntime().observe(o, listener, opts));
return () => {
observables.forEach(o => Observable.unobserve(o, listener));
observables.forEach(o => getObservableRuntime().unobserve(o, listener));
};
}, []);
}
@ -72,13 +82,13 @@ export function createStorageAndCallback<T extends object = {}>(
let emitter: Emitter;
const callback = (data: any) => {
const proxy = new Proxy(Observable.from(data), {
const proxy = new Proxy(getObservableRuntime().from(data), {
get(target, prop, receiver) {
if (prop === Symbol.for("vendetta.storage.emitter")) {
if (emitter) return emitter;
emitter = new Emitter();
Observable.observe(target, changes => {
getObservableRuntime().observe(target, changes => {
for (const change of changes) {
emitter.emit(change.type !== "delete" ? "SET" : "DEL", {
path: change.path,
@ -95,7 +105,7 @@ export function createStorageAndCallback<T extends object = {}>(
});
const handler = () => backend.set(proxy);
Observable.observe(proxy, handler);
getObservableRuntime().observe(proxy, handler);
cb(proxy);
};

View file

@ -4,34 +4,56 @@ import { createStyles } from "@ui/styles";
import { Platform, Text, TextInput } from "react-native";
export interface CodeblockProps {
selectable?: boolean;
style?: import("react-native").TextStyle;
children?: string;
selectable?: boolean;
style?: import("react-native").TextStyle;
children?: string;
}
const useStyles = createStyles({
codeBlock: {
fontFamily: constants.Fonts.CODE_NORMAL,
fontSize: 12,
textAlignVertical: "center",
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
color: semanticColors.TEXT_NORMAL,
borderWidth: 1,
borderRadius: 12,
borderColor: semanticColors.BACKGROUND_TERTIARY,
padding: 10,
},
codeBlock: {
fontFamily: constants.Fonts.CODE_NORMAL,
fontSize: 12,
textAlignVertical: "center",
backgroundColor: semanticColors.CARD_BACKGROUND_DEFAULT,
color: semanticColors.TEXT_DEFAULT,
borderWidth: 1,
borderRadius: 12,
borderColor: semanticColors.BORDER_SUBTLE,
padding: 10,
},
});
// iOS doesn't support the selectable property on RN.Text...
const InputBasedCodeblock = ({ style, children }: CodeblockProps) => <TextInput editable={false} multiline style={[useStyles().codeBlock, style && style]} value={children} />;
const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => <Text selectable={selectable} style={[useStyles().codeBlock, style && style]}>{children}</Text>;
const InputBasedCodeblock = ({ style, children }: CodeblockProps) => (
<TextInput
editable={false}
multiline
style={[useStyles().codeBlock, style && style]}
value={children}
/>
);
const TextBasedCodeblock = ({
selectable,
style,
children,
}: CodeblockProps) => (
<Text selectable={selectable} style={[useStyles().codeBlock, style && style]}>
{children}
</Text>
);
export default function Codeblock({ selectable, style, children }: CodeblockProps) {
if (!selectable) return <TextBasedCodeblock style={style} children={children} />;
export default function Codeblock({
selectable,
style,
children,
}: CodeblockProps) {
if (!selectable)
return <TextBasedCodeblock style={style} children={children} />;
return Platform.select({
ios: <InputBasedCodeblock style={style} children={children} />,
default: <TextBasedCodeblock style={style} children={children} selectable />,
});
return Platform.select({
ios: <InputBasedCodeblock style={style} children={children} />,
default: (
<TextBasedCodeblock style={style} children={children} selectable />
),
});
}

View file

@ -1,6 +1,7 @@
import { findByNameLazy } from "@metro/wrappers";
type LoggerFunction = (...messages: any[]) => void;
export interface Logger {
log: LoggerFunction;
info: LoggerFunction;
@ -13,3 +14,185 @@ export interface Logger {
export const LoggerClass = findByNameLazy("Logger");
export const logger: Logger = new LoggerClass("ShiggyCord");
let socket: WebSocket | undefined;
let originalConsoleLog: any;
let originalConsoleError: any;
let originalConsoleWarn: any;
let originalLoggerLog: any;
let originalLoggerError: any;
let originalLoggerWarn: any;
interface LogMessage {
type: 'log';
data: {
level: 'debug' | 'default' | 'warn' | 'error';
message: any[];
};
}
interface HelloMessage {
type: 'hello';
data: {
version: number;
};
}
const VERSION = 1;
function serializeMessage(msg: any): string {
return JSON.stringify(msg);
}
function sendLog(level: 'debug' | 'default' | 'warn' | 'error', ...args: any[]) {
if (socket?.readyState === WebSocket.OPEN) {
const message: LogMessage = {
type: 'log',
data: {
level,
message: args
}
};
socket.send(serializeMessage(message));
}
}
function patchConsoleAndLogger() {
originalConsoleLog = console.log;
console.log = function(...args: any[]) {
originalConsoleLog.apply(console, args);
sendLog('default', ...args);
};
originalConsoleError = console.error;
console.error = function(...args: any[]) {
originalConsoleError.apply(console, args);
sendLog('error', ...args);
};
originalConsoleWarn = console.warn;
console.warn = function(...args: any[]) {
originalConsoleWarn.apply(console, args);
sendLog('warn', ...args);
};
if (logger) {
originalLoggerLog = logger.log;
logger.log = function(...args: any[]) {
originalLoggerLog.apply(logger, args);
sendLog('default', ...args);
};
originalLoggerError = logger.error;
logger.error = function(...args: any[]) {
originalLoggerError.apply(logger, args);
sendLog('error', ...args);
};
originalLoggerWarn = logger.warn;
logger.warn = function(...args: any[]) {
originalLoggerWarn.apply(logger, args);
sendLog('warn', ...args);
};
}
}
function unpatchConsoleAndLogger() {
if (originalConsoleLog) {
console.log = originalConsoleLog;
originalConsoleLog = undefined;
}
if (originalConsoleError) {
console.error = originalConsoleError;
originalConsoleError = undefined;
}
if (originalConsoleWarn) {
console.warn = originalConsoleWarn;
originalConsoleWarn = undefined;
}
if (logger) {
if (originalLoggerLog) {
logger.log = originalLoggerLog;
originalLoggerLog = undefined;
}
if (originalLoggerError) {
logger.error = originalLoggerError;
originalLoggerError = undefined;
}
if (originalLoggerWarn) {
logger.warn = originalLoggerWarn;
originalLoggerWarn = undefined;
}
}
}
export function connectToDebugger(url: string) {
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) {
unpatchConsoleAndLogger();
socket.close();
}
if (!url) {
console.error("Invalid debugger URL!");
return;
}
try {
socket = new WebSocket(`ws://${url}`);
socket.addEventListener("open", () => {
console.log("Connected to debugger.");
const hello: HelloMessage = {
type: 'hello',
data: {
version: VERSION
}
};
socket?.send(serializeMessage(hello));
patchConsoleAndLogger();
});
socket.addEventListener("message", (message: any) => {
try {
const data = JSON.parse(message.data);
if (data.type === 'run' && data.data?.code) {
try {
(0, eval)(data.data.code);
} catch (e) {
console.error("Error executing remote code:", e);
}
}
} catch (e) {
console.error("Error processing message:", e);
}
});
socket.addEventListener("close", () => {
console.log("Disconnected from debugger.");
unpatchConsoleAndLogger();
});
socket.addEventListener("error", (err: any) => {
console.error(`Debugger error: ${err.message}`);
unpatchConsoleAndLogger();
});
} catch (e) {
console.error("Failed to connect to debugger:", e);
}
}
export function disconnectFromDebugger() {
if (socket) {
unpatchConsoleAndLogger();
socket.close();
socket = undefined;
}
}
export function isConnectedToDebugger(): boolean {
return socket?.readyState === WebSocket.OPEN;
}

268
src/lib/utils/timings.ts Normal file
View file

@ -0,0 +1,268 @@
/**
* Lightweight timing utility for boot / init instrumentation.
*
* Usage examples:
* import { timings } from "@lib/utils/timings";
*
* // measure synchronous function
* const res = timings.measureSync("initThemes", () => initThemes());
*
* // measure async function
* await timings.measureAsync("loadPlugins", async () => { await loadPlugins(); });
*
* // manual start/stop
* const stop = timings.start("heavyTask");
* doHeavyWork();
* stop();
*
* // inspect / log results
* timings.logReport(); // prints a concise table to console
*
* Goals:
* - Minimal, zero-dep helper to identify slow initializers.
* - Safe for environments without `performance` (falls back to Date.now).
* - Small memory footprint and simple API.
*/
type Stat = {
name: string;
calls: number;
totalMs: number;
lastMs: number;
minMs: number;
maxMs: number;
};
type ReportRow = {
name: string;
calls: number;
totalMs: number;
avgMs: number;
lastMs: number;
minMs: number;
maxMs: number;
};
const now = (() => {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return () => performance.now();
}
return () => Date.now();
})();
/**
* Internal stats map: key -> Stat
*/
const _stats = new Map<string, Stat>();
/**
* Start a manual timer for `name`. Returns a stop() function that records the
* elapsed time into the statistics and returns the duration.
*
* Example:
* const stop = start('db.init');
* // ... work ...
* const ms = stop();
*/
export function start(name: string) {
const t0 = now();
let stopped = false;
return function stop() {
if (stopped) return 0;
stopped = true;
const duration = Math.max(0, now() - t0);
record(name, duration);
return duration;
};
}
/**
* Measure a synchronous function and record its duration.
* Returns the function result.
*/
export function measureSync<T>(name: string, fn: () => T): T {
const t0 = now();
try {
return fn();
} finally {
const duration = Math.max(0, now() - t0);
record(name, duration);
}
}
/**
* Measure an async function (promise-returning) and record its duration.
* Returns the awaited result.
*/
export async function measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
const t0 = now();
try {
return await fn();
} finally {
const duration = Math.max(0, now() - t0);
record(name, duration);
}
}
/**
* Wrap a function (sync or async) preserving its return value and recording the timing.
* If fn returns a Promise it will await it and record the time.
*/
export function wrap<T extends (...a: any[]) => any>(name: string, fn: T): T {
const wrapped = function (this: any, ...args: any[]) {
const t0 = now();
try {
const res = (fn as any).apply(this, args);
if (res && typeof res.then === "function") {
// async
return res.finally(() => {
const duration = Math.max(0, now() - t0);
record(name, duration);
});
}
// sync
return res;
} finally {
// for synchronous returns, ensure timing recorded in finally above won't double-record.
if ((fn as any).constructor?.name !== "AsyncFunction") {
const duration = Math.max(0, now() - t0);
record(name, duration);
}
}
};
return (wrapped as unknown) as T;
}
/**
* Record a measured duration (milliseconds) under `name`.
*/
export function record(name: string, durationMs: number) {
if (!name) name = "<unnamed>";
const s = _stats.get(name);
if (!s) {
_stats.set(name, {
name,
calls: 1,
totalMs: durationMs,
lastMs: durationMs,
minMs: durationMs,
maxMs: durationMs,
});
return;
}
s.calls += 1;
s.lastMs = durationMs;
s.totalMs += durationMs;
if (durationMs < s.minMs) s.minMs = durationMs;
if (durationMs > s.maxMs) s.maxMs = durationMs;
}
/**
* Produce a sorted report array (desc by total time) limited to `limit` rows.
*/
export function report(limit = 25): ReportRow[] {
const rows: ReportRow[] = [];
for (const [, s] of _stats) {
rows.push({
name: s.name,
calls: s.calls,
totalMs: s.totalMs,
avgMs: s.calls > 0 ? s.totalMs / s.calls : 0,
lastMs: s.lastMs,
minMs: s.minMs,
maxMs: s.maxMs,
});
}
rows.sort((a, b) => b.totalMs - a.totalMs);
return rows.slice(0, limit);
}
/**
* Console-log a human-readable report.
*/
export function logReport(limit = 25) {
const rows = report(limit);
if (rows.length === 0) {
console.info("[timings] no recorded timings");
return;
}
// Build a compact table-like output
console.groupCollapsed(`[timings] top ${rows.length} (by total ms)`);
try {
console.table(
rows.map((r) => ({
name: r.name,
calls: r.calls,
totalMs: Number(r.totalMs.toFixed(2)),
avgMs: Number(r.avgMs.toFixed(2)),
lastMs: Number(r.lastMs.toFixed(2)),
minMs: Number(r.minMs.toFixed(2)),
maxMs: Number(r.maxMs.toFixed(2)),
})),
);
} catch {
// Some environments don't support console.table
for (const r of rows) {
console.log(
`${r.name} — total ${r.totalMs.toFixed(2)}ms • avg ${r.avgMs.toFixed(2)}ms • last ${r.lastMs.toFixed(
2,
)}ms • calls ${r.calls}`,
);
}
} finally {
console.groupEnd();
}
}
/**
* Clear collected timing stats.
*/
export function clear() {
_stats.clear();
}
/**
* Convenience: record but return value for fluent usage.
* Example:
* const data = await timings.withTimingAsync('fetch', () => fetchJSON());
*/
export async function withTimingAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
const t0 = now();
try {
return await fn();
} finally {
record(name, Math.max(0, now() - t0));
}
}
export function withTimingSync<T>(name: string, fn: () => T): T {
const t0 = now();
try {
return fn();
} finally {
record(name, Math.max(0, now() - t0));
}
}
/**
* Expose a small default export for convenience.
*/
export const timings = {
start,
measureSync,
measureAsync,
wrap,
record,
report,
logReport,
clear,
withTimingAsync,
withTimingSync,
};
export default timings;