feat: Custom fonts support (#28)

* [fonts] Working base

* [ui/fonts] Barebones UI

* [ui/fonts] Make remove-able

* [fonts] Use font name as a unique identifier

* fix(fonts): fetch fonts properly

* fix(fonts): optionally remove downloaded files after removing

* feat(fonts): Extract from Revenge themes

* feat(fonts): final touch
This commit is contained in:
pylixonly 2024-05-18 02:55:20 +08:00 • committed by GitHub
parent 58781a08ea
commit a0d1808cbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 369 additions and 12 deletions

View file

@ -31,24 +31,31 @@
"DEBUG": "Debug",
"DEBUGGER_URL": "Debugger URL",
"DELETE": "Delete",
"DESC_EXTRACT_FONTS_FROM_THEME": "Looks out for \"fonts\" field in your currently applied theme and install it.",
"DEVELOPER": "Developer",
"DEVELOPER_SETTINGS": "Developer Settings",
"DISABLE_THEME": "Disable Theme",
"DISABLE_UPDATES": "Disable updates",
"DISCORD_SERVER": "Discord Server",
"DONE": "Done",
"ENABLE_EVAL_COMMAND": "Enable /eval command",
"ENABLE_EVAL_COMMAND_DESC": "Evaluate JavaScript directly from command. Be cautious when using this command as it may pose a security risk. Make sure to know what you are doing.",
"ENABLE_UPDATES": "Enable updates",
"ERROR_BOUNDARY_TOOLS_LABEL": "ErrorBoundary Tools",
"EXTRACT": "Extract",
"FONT_NAME": "Font Name",
"FONTS": "Fonts",
"GENERAL": "General",
"GITHUB": "GitHub",
"HOLD_UP": "Hold Up",
"INFO": "Info",
"INSTALL": "Install",
"INSTALL_ADDON": "Install Add-on",
"INSTALL_PLUGIN": "Install Plugin",
"INSTALL_ADDON": "Install an add-on",
"INSTALL_FONT": "Install a font",
"INSTALL_PLUGIN": "Install a plugin",
"INSTALL_REACT_DEVTOOLS": "Install React DevTools",
"INSTALL_THEME": "Install Theme",
"LABEL_EXTRACT_FONTS_FROM_THEME": "Extract font from theme",
"LINKS": "Links",
"LOAD_FROM_CUSTOM_URL": "Load from custom URL",
"LOAD_FROM_CUSTOM_URL_DEC": "Load Bunny from a custom endpoint.",
@ -77,17 +84,21 @@
"RELOAD_IN_NORMAL_MODE_DESC": "This will reload Discord normally",
"RELOAD_IN_SAFE_MODE": "Reload in Safe Mode",
"RELOAD_IN_SAFE_MODE_DESC": "This will reload Discord without loading addons",
"RESTART_REQUIRED_TO_TAKE_EFFECT": "Restart required to take effect",
"REMOVE": "Remove",
"RESTART_REQUIRED_TO_TAKE_EFFECT": "Restart is required to take effect",
"RETRY": "Retry",
"RETRY_RENDER": "Retry Render",
"SAFE_MODE": "Safe Mode",
"SAFE_MODE_NOTICE_FONTS": "You are in Safe Mode, meaning fonts have been temporarily disabled. {enabled, select, true {If a font appears to be causing the issue, you can press below to disable it persistently.} other {}}",
"SAFE_MODE_NOTICE_PLUGINS": "You are in Safe Mode, so plugins cannot be loaded. Disable any misbehaving plugins, then return to Normal Mode from the General settings page.",
"SAFE_MODE_NOTICE_THEMES": "You are in Safe Mode, meaning themes have been temporarily disabled. {enabled, select, true {If a theme appears to be causing the issue, you can press below to disable it persistently.} other {}}",
"SEARCH": "Search",
"SEPARATOR": ", ",
"SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS": "Activate Discord Experiments",
"SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC": "Warning: Messing with this feature may lead to account termination. We are not responsible for what you do with this feature.",
"STACK_TRACE": "Stack Trace",
"SUCCESSFULLY_INSTALLED": "Successfully installed",
"THEME_EXTRACTOR_DESC": "This pack overrides the following: {fonts}",
"THEME_REFETCH_FAILED": "Failed to refetch theme!",
"THEME_REFETCH_SUCCESSFUL": "Successfully refetched theme.",
"THEMES": "Themes",

View file

@ -10,24 +10,30 @@ import { clipboard } from "@metro/common";
import { showInputAlert } from "@ui/alerts";
import { ErrorBoundary, Search } from "@ui/components";
import fuzzysort from "fuzzysort";
import { createContext } from "react";
import { FlatList, View } from "react-native";
export const RemoveModeContext = createContext(false);
interface AddonPageProps<T> {
title: string;
floatingButtonText: string;
fetchFunction: (url: string) => Promise<void>;
items: Record<string, T & { id: string; }>;
items: Record<string, T & ({ id: string; } | { name: string; })>;
safeModeMessage: string;
safeModeExtras?: JSX.Element | JSX.Element[];
card: React.ComponentType<CardWrapper<T>>;
isRemoveMode?: boolean;
headerComponent?: JSX.Element;
}
function getItemsByQuery<T extends { id?: string; }>(items: T[], query: string): T[] {
function getItemsByQuery<T extends AddonPageProps<unknown>["items"][string]>(items: T[], query: string): T[] {
if (!query) return items;
return fuzzysort.go(query, items, {
keys: [
"id",
"name",
"manifest.name",
"manifest.description",
"manifest.authors.0.name",
@ -39,9 +45,9 @@ function getItemsByQuery<T extends { id?: string; }>(items: T[], query: string):
const reanimated = findByProps("useSharedValue");
const { FloatingActionButton } = findByProps("FloatingActionButton");
export default function AddonPage<T>({ floatingButtonText, fetchFunction, items, safeModeMessage, safeModeExtras, card: CardComponent }: AddonPageProps<T>) {
useProxy(settings);
export default function AddonPage<T>({ floatingButtonText, fetchFunction, items, safeModeMessage, safeModeExtras, card: CardComponent, isRemoveMode, headerComponent }: AddonPageProps<T>) {
useProxy(items);
useProxy(settings);
const collapseText = reanimated.useSharedValue(0);
const yOffset = React.useRef<number>(0);
@ -61,6 +67,7 @@ export default function AddonPage<T>({ floatingButtonText, fetchFunction, items,
onChangeText={(v: string) => setSearch(v.toLowerCase())}
placeholder={Strings.SEARCH}
/>
{headerComponent}
</>}
onScroll={e => {
if (e.nativeEvent.contentOffset.y <= 0) return;
@ -69,8 +76,10 @@ export default function AddonPage<T>({ floatingButtonText, fetchFunction, items,
}}
style={{ paddingHorizontal: 10, paddingTop: 10 }}
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
data={getItemsByQuery(Object.values(items), search)}
renderItem={({ item, index }) => <CardComponent item={item} index={index} />}
data={getItemsByQuery(Object.values(items).filter(i => typeof i === "object"), search)}
renderItem={({ item, index }) => <RemoveModeContext.Provider value={!!isRemoveMode}>
<CardComponent item={item} index={index} />
</RemoveModeContext.Provider>}
/>
<FloatingActionButton
text={floatingButtonText}

View file

@ -0,0 +1,41 @@
import { Strings } from "@core/i18n";
import { CardWrapper } from "@core/ui/components/Card";
import { getAssetIDByName } from "@lib/api/assets";
import { useProxy } from "@lib/api/storage";
import { FontDefinition, fonts, removeFont, selectFont } from "@lib/managers/fonts";
import { FormCheckbox, IconButton, TableRow, TableRowGroup } from "@lib/ui/components/discord/Redesign";
import { showToast } from "@lib/ui/toasts";
import { useContext } from "react";
import { Pressable, View } from "react-native";
import { RemoveModeContext } from "./AddonPage";
export default function FontCard({ item: font, index }: CardWrapper<FontDefinition>) {
useProxy(fonts);
const removeMode = useContext(RemoveModeContext);
const selected = fonts.__selected === font.name;
return <View key={index} style={{ marginVertical: 4 }}>
<TableRowGroup>
<TableRow
label={font.name}
subLabel={font.previewText ?? "Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups."}
trailing={
<Pressable onPress={() => {
selectFont(selected ? null : font.name).then(() => {
showToast(Strings.RESTART_REQUIRED_TO_TAKE_EFFECT, getAssetIDByName("WarningIcon"));
});
}}>
{!removeMode ? <FormCheckbox checked={selected} /> : <IconButton
size="sm"
variant="secondary"
icon={getAssetIDByName("TrashIcon")}
onPress={() => removeFont(font.name)}
/>}
</Pressable>
}
/>
</TableRowGroup>
</View>;
}

View file

@ -1,6 +1,6 @@
import { Strings } from "@core/i18n";
import { getAssetIDByName } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader";
import { isFontSupported, isThemeSupported } from "@lib/api/native/loader";
import { useProxy } from "@lib/api/storage";
import { settings } from "@lib/settings";
import { registerSection } from "@lib/ui/settings";
@ -38,6 +38,13 @@ export default function initSettings() {
render: () => import("@core/ui/settings/pages/Themes"),
usePredicate: () => isThemeSupported()
},
{
key: "BUNNY_FONTS",
title: () => Strings.FONTS,
icon: getAssetIDByName("ic_add_text"),
render: () => import("@core/ui/settings/pages/Fonts"),
usePredicate: () => isFontSupported()
},
{
key: "BUNNY_DEVELOPER",
title: () => Strings.DEVELOPER,

View file

@ -0,0 +1,138 @@
import { formatString, Strings } from "@core/i18n";
import AddonPage from "@core/ui/components/AddonPage";
import FontCard from "@core/ui/components/FontCard";
import { getAssetIDByName } from "@lib/api/assets";
import { useProxy } from "@lib/api/storage";
import { FontDefinition, fonts, installFont, saveFont } from "@lib/managers/fonts";
import { getCurrentTheme } from "@lib/managers/themes";
import { findByProps } from "@lib/metro/filters";
import { settings } from "@lib/settings";
import { ErrorBoundary } from "@lib/ui/components";
import { FormText } from "@lib/ui/components/discord/Forms";
import { ActionSheet, BottomSheetTitleHeader, Button, RowButton, TableRow, Text, TextInput, useNavigation } from "@lib/ui/components/discord/Redesign";
import { useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native";
const actionSheet = findByProps("hideActionSheet");
function guessFontName(urls: string[]) {
const fileNames = urls.map(url => {
const { pathname } = new URL(url);
const fileName = pathname.replace(/\.[^/.]+$/, "");
return fileName.split("/").pop();
}).filter(Boolean) as string[];
const shortest = fileNames.reduce((shortest, name) => {
return name.length < shortest.length ? name : shortest;
}, fileNames[0] || "");
return shortest?.replace(/-[A-Za-z]*$/, "") || null;
}
function ExtractFontsComponent({ fonts }: { fonts: Record<string, string>; }) {
const [fontName, setFontName] = useState(guessFontName(Object.values(fonts)));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | void>(undefined);
return <View style={{ padding: 8, paddingBottom: 16, gap: 12 }}>
<TextInput
size="md"
label={Strings.FONT_NAME}
value={fontName}
placeholder={fontName || "Whitney"}
onChange={setFontName}
errorMessage={error}
status={error ? "error" : void 0}
/>
<Text variant="text-xs/normal" color="text-muted">
{formatString("THEME_EXTRACTOR_DESC", {
fonts: Object.keys(fonts).join(Strings.SEPARATOR)
})}
</Text>
<Button
size="md"
variant="primary"
text={Strings.EXTRACT}
disabled={!fontName || saving}
loading={saving}
onPress={() => {
setSaving(true);
saveFont({
spec: 1,
name: fontName!.trim(),
main: fonts
})
.then(() => actionSheet.hideActionSheet())
.catch(e => setError(String(e)))
.finally(() => setSaving(false));
}}
/>
</View>;
}
function promptFontExtractor() {
const currentTheme = getCurrentTheme()?.data;
if (!currentTheme || !("fonts" in currentTheme)) return;
const fonts = currentTheme.fonts as Record<string, string>;
actionSheet.openLazy(
Promise.resolve({
default: () => (
<ErrorBoundary>
<ActionSheet>
<BottomSheetTitleHeader title={Strings.LABEL_EXTRACT_FONTS_FROM_THEME} />
<ExtractFontsComponent fonts={fonts} />
</ActionSheet>
</ErrorBoundary>
)
}),
"FontsFromThemeExtractorActionSheet"
);
}
export default function Plugins() {
useProxy(settings);
useProxy(fonts);
const [removeMode, setRemoveMode] = useState(false);
const navigation = useNavigation();
useEffect(() => {
const onPressCallback = () => {
setRemoveMode(x => !x);
};
navigation.setOptions({
headerRight: () => <TouchableOpacity onPress={onPressCallback}>
<FormText style={{ marginRight: 12 }}>
{removeMode ? Strings.DONE : Strings.REMOVE}
</FormText>
</TouchableOpacity>
});
}, [removeMode]);
return (
<AddonPage<FontDefinition>
title={Strings.FONTS}
floatingButtonText={Strings.INSTALL_FONT}
fetchFunction={installFont}
items={fonts as Record<string, FontDefinition>}
safeModeMessage={Strings.SAFE_MODE_NOTICE_FONTS}
isRemoveMode={removeMode}
card={FontCard}
headerComponent={<>
{/* @ts-ignore */}
{getCurrentTheme()?.data?.fonts && <View style={{ marginVertical: 8 }}>
<RowButton
label={Strings.LABEL_EXTRACT_FONTS_FROM_THEME}
subLabel={Strings.DESC_EXTRACT_FONTS_FROM_THEME}
icon={<TableRow.Icon source={getAssetIDByName("HammerIcon")} />}
onPress={promptFontExtractor}
/>
</View>}
</>}
/>
);
}

View file

@ -10,6 +10,7 @@ import { removeFile, writeFile } from "@lib/api/native/fs";
import { isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
import { FileManager } from "@lib/api/native/modules";
import { patchLogHook } from "@lib/debug";
import { updateFonts } from "@lib/managers/fonts";
import { initPlugins } from "@lib/managers/plugins";
import { initThemes, patchChatBackground } from "@lib/managers/themes";
import { patchSettings } from "@lib/ui/settings";
@ -60,6 +61,9 @@ export default async () => {
// Once done, load plugins
lib.unload.push(await initPlugins());
// Update the fonts
updateFonts();
// We good :)
logger.log("Bunny is ready!");
};

View file

@ -1,5 +1,14 @@
import { FileManager } from "./modules";
/**
* Removes all files in a directory from the path given
* @param path Path to the targeted directory
*/
export async function clearFolder(path: string, prefix = "pyoncord/") {
if (typeof FileManager.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported");
return void await FileManager.clearFolder("documents", `${prefix}${path}`);
}
/**
* Remove file from given path, currently no check for any failure
* @param path Path to the file
@ -7,7 +16,7 @@ import { FileManager } from "./modules";
export async function removeFile(path: string, prefix = "pyoncord/") {
if (typeof FileManager.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
return void await FileManager.removeFile("documents", `${prefix}${path}`);
} // TODO: handle errors from native side properly (discord does not use Promise properly apparently)
}
/**
* Check if the file or directory given by the path exists

View file

@ -215,3 +215,9 @@ export function getLoaderConfigPath() {
return "loader.json";
}
export function isFontSupported() {
if (isPyonLoader()) return pyonLoaderIdentity.fontPatch === 2;
return false;
}

View file

@ -50,7 +50,6 @@ export interface FileManager {
readFile(path: string, encoding: "base64" | "utf8"): Promise<string>;
saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise<string>;
/**
* Beware! This function has differing functionality on iOS and Android.
* @param storageDir Either `cache` or `documents`.
* @param path Path in `storageDir`, parents are recursively created.
* @param data The data to write to the file
@ -58,7 +57,21 @@ export interface FileManager {
* @returns Promise that resolves to path of the file once it got written
*/
writeFile(storageDir: "cache" | "documents", path: string, data: string, encoding: "base64" | "utf8"): Promise<string>;
/**
* Removes a file from the path given.
* (!) On Android, this always returns false, regardless if it fails or not!
* @param storageDir Either `cache` or `documents`
* @param path Path to the file to be removed
*/
removeFile(storageDir: "cache" | "documents", path: string): Promise<unknown>;
/**
* Clear the folder from the path given
* (!) On Android, this only clears all *files* and not subdirectories!
* @param storageDir Either `cache` or `documents`
* @param path Path to the folder to be cleared
* @returns Whether the clearance succeeded
*/
clearFolder(storageDir: "cache" | "documents", path: string): Promise<boolean>;
getConstants: () => {
/**
* The path the `documents` storage dir (see {@link writeFile}) represents.

113
src/lib/managers/fonts.ts Normal file
View file

@ -0,0 +1,113 @@
import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs";
import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/api/storage";
import { safeFetch } from "@lib/utils";
type FontMap = Record<string, string>;
export interface FontDefinition {
spec: 1;
name: string;
previewText?: string;
main: FontMap;
__source?: string;
}
type FontStorage = Record<string, FontDefinition> & { __selected?: string; };
export const fonts = wrapSync(createStorage<FontStorage>(createMMKVBackend("BUNNY_FONTS")));
async function writeFont(font: FontDefinition | null) {
if (!font && font !== null) throw new Error("Arg font must be a valid object or null");
if (font) {
await writeFile("fonts.json", JSON.stringify(font));
} else {
await removeFile("fonts.json");
}
}
export function validateFont(font: FontDefinition) {
if (!font || typeof font !== "object") throw new Error("URL returned a null/non-object JSON");
if (typeof font.spec !== "number") throw new Error("Invalid font 'spec' number");
if (font.spec !== 1) throw new Error("Only fonts which follows spec:1 are supported");
const requiredFields = ["name", "main"] as const;
if (requiredFields.some(f => !font[f])) throw new Error(`Font is missing one of the fields: ${requiredFields}`);
if (font.name.startsWith("__")) throw new Error("Font names cannot start with __");
if (font.name in fonts) throw new Error(`There is already a font named ${font.name} installed`);
}
export async function saveFont(data: string | FontDefinition, selected = false) {
let fontDefJson: FontDefinition;
if (typeof data === "object" && data.__source) data = data.__source;
if (typeof data === "string") {
try {
fontDefJson = await (await safeFetch(data, { cache: "no-store" })).json();
fontDefJson.__source = data;
} catch (e) {
throw new Error(`Failed to fetch theme at ${data}`, { cause: e });
}
} else {
fontDefJson = data;
}
validateFont(fontDefJson);
try {
await Promise.all(Object.entries(fontDefJson.main).map(async ([font, url]) => {
let ext = url.split(".").pop();
if (ext !== "ttf" && ext !== "otf") ext = "ttf";
const path = `downloads/fonts/${fontDefJson.name}/${font}.${ext}`;
if (!await fileExists(path)) await downloadFile(url, path);
}));
} catch (e) {
throw new Error("Failed to download font assets", { cause: e });
}
fonts[fontDefJson.name] = fontDefJson;
if (selected) writeFont(fonts[fontDefJson.name]);
return fontDefJson;
}
export async function installFont(url: string, selected = false) {
if (typeof url !== "string" || Object.values(fonts).some(f => typeof f === "object" && f.__source === url)
) {
throw new Error("Invalid source or font was already installed");
}
const font = await saveFont(url);
if (selected) await selectFont(font.name);
}
export async function selectFont(name: string | null) {
if (name && !(name in fonts)) throw new Error("Selected font does not exist!");
if (name) {
fonts.__selected = name;
} else {
delete fonts.__selected;
}
await writeFont(name == null ? null : fonts[name]);
}
export async function removeFont(name: string) {
const selected = fonts.__selected === name;
if (selected) await selectFont(null);
delete fonts[name];
try {
await clearFolder(`downloads/fonts/${name}`);
} catch {
// ignore
}
}
export async function updateFonts() {
await awaitSyncWrapper(fonts);
await Promise.allSettled(
Object.keys(fonts).map(
name => saveFont(fonts[name], fonts.__selected === name)
)
);
}

View file

@ -1,2 +1,3 @@
export * as fonts from "./fonts";
export * as plugins from "./plugins";
export * as themes from "./themes";

View file

@ -229,6 +229,10 @@ export async function updateThemes() {
await allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id)));
}
export function getCurrentTheme() {
return currentTheme;
}
const origRawColor = { ...color.RawColor };
let inc = 0;

View file

@ -125,6 +125,7 @@ export const {
ActionSheetRow,
ActionSheetSwitchRow,
AnimatedEnterExitItem,
BottomSheetTitleHeader,
BottomSheetTextInput,
Dialog,
DisableCustomTheme,