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:
parent
58781a08ea
commit
a0d1808cbc
13 changed files with 369 additions and 12 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
41
src/core/ui/components/FontCard.tsx
Normal file
41
src/core/ui/components/FontCard.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
138
src/core/ui/settings/pages/Fonts.tsx
Normal file
138
src/core/ui/settings/pages/Fonts.tsx
Normal 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>}
|
||||
</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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!");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -215,3 +215,9 @@ export function getLoaderConfigPath() {
|
|||
|
||||
return "loader.json";
|
||||
}
|
||||
|
||||
export function isFontSupported() {
|
||||
if (isPyonLoader()) return pyonLoaderIdentity.fontPatch === 2;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
113
src/lib/managers/fonts.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * as fonts from "./fonts";
|
||||
export * as plugins from "./plugins";
|
||||
export * as themes from "./themes";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export const {
|
|||
ActionSheetRow,
|
||||
ActionSheetSwitchRow,
|
||||
AnimatedEnterExitItem,
|
||||
BottomSheetTitleHeader,
|
||||
BottomSheetTextInput,
|
||||
Dialog,
|
||||
DisableCustomTheme,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue