From 9500adbfc780c930a3e98e2a267b3b429a71a9a0 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Wed, 19 Aug 2026 00:42:25 -0400 Subject: [PATCH] MoreAlts: port from Apex, merge in rain's native multi-account switcher --- plugins/more-alts/manifest.json | 14 + plugins/more-alts/src/commands.ts | 112 +++++ plugins/more-alts/src/index.ts | 65 +++ plugins/more-alts/src/lib/accountActions.ts | 450 ++++++++++++++++++ plugins/more-alts/src/lib/accounts.ts | 94 ++++ plugins/more-alts/src/lib/logger.ts | 30 ++ plugins/more-alts/src/lib/nativeSwitcher.ts | 24 + plugins/more-alts/src/lib/passwordUtils.ts | 10 + plugins/more-alts/src/patches/contextMenu.ts | 46 ++ plugins/more-alts/src/patches/sidebarPin.ts | 204 ++++++++ plugins/more-alts/src/ui/Settings.tsx | 134 ++++++ plugins/more-alts/src/ui/pages/AddAccount.tsx | 62 +++ .../src/ui/pages/GeneralSettings.tsx | 174 +++++++ .../more-alts/src/ui/pages/PasswordPrompt.tsx | 50 ++ 14 files changed, 1469 insertions(+) create mode 100644 plugins/more-alts/manifest.json create mode 100644 plugins/more-alts/src/commands.ts create mode 100644 plugins/more-alts/src/index.ts create mode 100644 plugins/more-alts/src/lib/accountActions.ts create mode 100644 plugins/more-alts/src/lib/accounts.ts create mode 100644 plugins/more-alts/src/lib/logger.ts create mode 100644 plugins/more-alts/src/lib/nativeSwitcher.ts create mode 100644 plugins/more-alts/src/lib/passwordUtils.ts create mode 100644 plugins/more-alts/src/patches/contextMenu.ts create mode 100644 plugins/more-alts/src/patches/sidebarPin.ts create mode 100644 plugins/more-alts/src/ui/Settings.tsx create mode 100644 plugins/more-alts/src/ui/pages/AddAccount.tsx create mode 100644 plugins/more-alts/src/ui/pages/GeneralSettings.tsx create mode 100644 plugins/more-alts/src/ui/pages/PasswordPrompt.tsx diff --git a/plugins/more-alts/manifest.json b/plugins/more-alts/manifest.json new file mode 100644 index 0000000..945ecda --- /dev/null +++ b/plugins/more-alts/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "More Alts!", + "description": "Manage and switch between multiple Discord accounts, plus unlocks Discord's own native multi-account switcher.", + "authors": [ + { "name": "Win8.1VMUser", "id": "793935599702507542" }, + { "name": "John" }, + { "name": "cocobo1" }, + { "name": "Rosie" } + ], + "main": "src/index.ts", + "vendetta": { + "icon": "ic_full_server_gating_24px" + } +} diff --git a/plugins/more-alts/src/commands.ts b/plugins/more-alts/src/commands.ts new file mode 100644 index 0000000..18f1e2d --- /dev/null +++ b/plugins/more-alts/src/commands.ts @@ -0,0 +1,112 @@ +import { registerCommand } from "@vendetta/commands"; +import { findByProps } from "@vendetta/metro"; +import { showToast } from "@vendetta/ui/toasts"; +import { findAccountByInput, formatAccountName, getAccountOrder, getAccounts, getSettings, TokenManager } from "./lib/accounts"; +import { addCurrentAccount, removeAccount, switchToAccount } from "./lib/accountActions"; + +const STRING = 3; +const MessageActions = findByProps("sendMessage", "sendBotMessage"); + +function reply(channelId: string, content: string) { + if (!MessageActions?.sendMessage) { + showToast("Couldn't send response - message action not found", undefined); + return; + } + MessageActions.sendMessage(channelId, { content, tts: false, invalidEmojis: [], validNonShortcutEmojis: [] }, true, {}); +} + +export default function loadCommands(): (() => void)[] { + const unregisterMain = registerCommand({ + name: "accswitcher", + description: "Open the More Alts account switcher", + options: [], + execute: (_args, ctx) => { + reply(ctx.channel.id, getSettings().addToSidebar + ? "Account Manager is available in your settings sidebar.\n\nQuick commands:\n`/accswitcher login <#>` `/accswitcher add` `/accswitcher list` `/accswitcher remove <#>`" + : "Check the plugin's settings page to open the Account Manager.\n\nQuick commands:\n`/accswitcher login <#>` `/accswitcher add` `/accswitcher list` `/accswitcher remove <#>`"); + } + }); + + const unregisterAdd = registerCommand({ + name: "accswitcher add", + description: "Add your current account to saved accounts", + options: [], + execute: async (_args, ctx) => { + const ok = await addCurrentAccount(); + if (ok) reply(ctx.channel.id, "✅ Current account saved."); + } + }); + + const unregisterLogin = registerCommand({ + name: "accswitcher login", + description: "Switch to a saved account", + options: [{ name: "account", description: "Account number or username", type: STRING, required: true }], + execute: async (args, ctx) => { + const input = args.find((a) => a.name === "account")?.value; + if (!input) return; + + const found = findAccountByInput(input); + if (!found) { + reply(ctx.channel.id, `❌ Could not find account "${input}". Use \`/accswitcher list\` to see saved accounts.`); + return; + } + + const [accountId] = found; + await switchToAccount(accountId); + } + }); + + const unregisterList = registerCommand({ + name: "accswitcher list", + description: "Show all saved accounts", + options: [], + execute: (_args, ctx) => { + const order = getAccountOrder(); + if (!order.length) { + reply(ctx.channel.id, "No saved accounts. Use `/accswitcher add` to save your current account."); + return; + } + + const accounts = getAccounts(); + const lines = order + .filter((id) => accounts[id]) + .map((id, i) => `**${i + 1}.** ${formatAccountName(accounts[id])}`) + .join("\n"); + reply(ctx.channel.id, `**Saved accounts (${order.length})**\n${lines}\n\nUse \`/accswitcher login \` to switch.`); + } + }); + + const unregisterRemove = registerCommand({ + name: "accswitcher remove", + description: "Remove a saved account", + options: [{ name: "account", description: "Account number or username", type: STRING, required: true }], + execute: (args, ctx) => { + const input = args.find((a) => a.name === "account")?.value; + if (!input) return; + + const found = findAccountByInput(input); + if (!found) { + reply(ctx.channel.id, `❌ Could not find account "${input}".`); + return; + } + + const [accountId] = found; + removeAccount(accountId); + } + }); + + const unregisterToken = registerCommand({ + name: "accswitcher token", + description: "Get your current account token (requires unsafe features)", + options: [], + execute: (_args, ctx) => { + if (!getSettings().enableUnsafeFeatures) { + showToast("Enable 'Unsafe features' in plugin settings to use this command", undefined); + return; + } + reply(ctx.channel.id, `||${TokenManager.getToken()}||\n⚠️ Keep this secure and never share it.`); + } + }); + + return [unregisterMain, unregisterAdd, unregisterLogin, unregisterList, unregisterRemove, unregisterToken]; +} diff --git a/plugins/more-alts/src/index.ts b/plugins/more-alts/src/index.ts new file mode 100644 index 0000000..0c5f9e7 --- /dev/null +++ b/plugins/more-alts/src/index.ts @@ -0,0 +1,65 @@ +import { logger } from "@vendetta"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import Settings from "./ui/Settings"; +import patchMoreAltsSection, { SectionRow } from "./patches/sidebarPin"; +import patchContextMenu from "./patches/contextMenu"; +import patchNativeSwitcher from "./lib/nativeSwitcher"; +import loadCommands from "./commands"; +import { ensureStorage, getSettings } from "./lib/accounts"; + +let unpatchSidebar: (() => void) | undefined; +let unpatchContextMenu: (() => void) | undefined; +let unpatchNativeSwitcher: (() => void) | undefined; +let unregisterCommands: (() => void)[] = []; + +function buildRows(): SectionRow[] { + if (!getSettings().addToSidebar) return []; + return [ + { + key: "MORE_ALTS_SWITCHER", + title: () => "Account Switcher", + icon: getAssetIDByName("UserIcon"), + page: Settings + } + ]; +} + +export default { + settings: Settings, + + onLoad() { + ensureStorage(); + + try { + unpatchSidebar = patchMoreAltsSection(buildRows); + } catch (e: any) { + logger.error("[MoreAlts] Failed to patch sidebar:", e?.message ?? e); + } + + try { + unpatchContextMenu = patchContextMenu(); + } catch (e: any) { + logger.error("[MoreAlts] Failed to patch context menu:", e?.message ?? e); + } + + try { + unpatchNativeSwitcher = patchNativeSwitcher(); + } catch (e: any) { + logger.error("[MoreAlts] Failed to patch native switcher:", e?.message ?? e); + } + + try { + unregisterCommands = loadCommands(); + } catch (e: any) { + logger.error("[MoreAlts] Failed to register commands:", e?.message ?? e); + } + }, + + onUnload() { + unpatchSidebar?.(); + unpatchContextMenu?.(); + unpatchNativeSwitcher?.(); + unregisterCommands.forEach((u) => u()); + unregisterCommands = []; + } +}; diff --git a/plugins/more-alts/src/lib/accountActions.ts b/plugins/more-alts/src/lib/accountActions.ts new file mode 100644 index 0000000..a14cd11 --- /dev/null +++ b/plugins/more-alts/src/lib/accountActions.ts @@ -0,0 +1,450 @@ +import { showConfirmationAlert } from "@vendetta/ui/alerts"; +import { showToast } from "@vendetta/ui/toasts"; +import { clipboard, ReactNative } from "@vendetta/metro/common"; +import { + Account, + AuthActions, + TokenManager, + UserStore, + addAccountToOrder, + getAccounts, + getSettings, + formatAccountName, + removeAccountFromOrder +} from "./accounts"; +import { addLog } from "./logger"; +import { simpleHash } from "./passwordUtils"; + +export type RequirePassword = (opts: { title: string; message: string; onSuccess: () => void }) => void; + +export function exportAccounts(requirePassword: RequirePassword) { + const run = () => { + showConfirmationAlert({ + title: "⚠️ Export Accounts - Security Warning", + content: "Exporting accounts is UNSAFE and may lead to account takeover if shared with others. The export contains sensitive authentication tokens that give full access to your accounts. Only proceed if you understand the risks and will keep the data secure.", + confirmText: "I Understand - Export", + cancelText: "Cancel", + confirmColor: "brand", + onConfirm: () => { + try { + const accounts = getAccounts(); + const accountData = Object.values(accounts).map((account) => ({ + username: account.username, + discriminator: account.discriminator, + avatar: account.avatar, + id: account.id, + token: account.token, + addedAt: account.addedAt + })); + + const exportData = { + accounts: accountData, + exportPasswordHash: getSettings().exportPasswordHash ?? null, + exportedAt: Date.now(), + version: "2.0" + }; + + clipboard.setString(JSON.stringify(exportData, null, 2)); + addLog("info", "Accounts exported successfully", { count: accountData.length }); + showToast(`Exported ${accountData.length} accounts to clipboard`, undefined); + } catch (e: any) { + addLog("error", "Failed to export accounts", { error: e?.message }); + showToast("Failed to export accounts", undefined); + } + } + }); + }; + + if (getSettings().exportPasswordHash) { + requirePassword({ title: "Enter Export Password", message: "Enter your password to export accounts", onSuccess: run }); + } else { + run(); + } +} + +export async function importAccounts(requirePassword: RequirePassword, importText: string, onComplete?: () => void) { + const run = async () => { + showConfirmationAlert({ + title: "⚠️ Import Accounts - Security Warning", + content: "Importing accounts is UNSAFE and may compromise your security if the data comes from untrusted sources. Only import data you exported yourself or from sources you completely trust. Malicious imports could lead to account takeover.", + confirmText: "I Understand - Import", + cancelText: "Cancel", + confirmColor: "brand", + onConfirm: async () => { + try { + let dataToImport = importText.trim(); + if (!dataToImport) { + try { + dataToImport = await clipboard.getString(); + } catch (e: any) { + addLog("error", "Failed to get clipboard data", { error: e?.message }); + showToast("No data in clipboard or input field", undefined); + return; + } + } + + if (!dataToImport) { + showToast("No data to import", undefined); + return; + } + + const importData = JSON.parse(dataToImport); + let accountsArray: any[]; + const settings = getSettings(); + + if (importData.accounts && Array.isArray(importData.accounts)) { + if (importData.exportPasswordHash) { + if (!settings.exportPasswordHash || settings.exportPasswordHash !== importData.exportPasswordHash) { + addLog("error", "Import password mismatch"); + showToast("Import password mismatch or not set locally", undefined); + return; + } + } + accountsArray = importData.accounts; + if (importData.exportPasswordHash && !settings.exportPasswordHash) { + settings.exportPasswordHash = importData.exportPasswordHash; + } + } else if (Array.isArray(importData)) { + accountsArray = importData; + } else { + addLog("error", "Invalid import format"); + showToast("Invalid import format", undefined); + return; + } + + const accounts = getAccounts(); + let importedCount = 0; + let skippedCount = 0; + + for (const accountData of accountsArray) { + if (accountData.id && accountData.token && accountData.username) { + if (!accounts[accountData.id]) { + accounts[accountData.id] = { + id: accountData.id, + username: accountData.username, + discriminator: accountData.discriminator ?? "0", + avatar: accountData.avatar ?? null, + displayName: accountData.displayName ?? accountData.username, + token: accountData.token, + addedAt: accountData.addedAt ?? Date.now() + }; + addAccountToOrder(accountData.id); + importedCount++; + } else { + skippedCount++; + } + } + } + + addLog("info", "Import completed", { imported: importedCount, skipped: skippedCount }); + showToast(`Imported ${importedCount} accounts, skipped ${skippedCount} duplicates`, undefined); + onComplete?.(); + } catch (e: any) { + addLog("error", "Import failed", { error: e?.message }); + showToast("Failed to import - invalid format or password mismatch", undefined); + } + } + }); + }; + + if (getSettings().exportPasswordHash) { + requirePassword({ title: "Enter Import Password", message: "Enter your password to import accounts", onSuccess: run }); + } else { + run(); + } +} + +export function setExportPassword(newPassword: string, confirmPassword: string): boolean { + if (!newPassword.trim()) { + showToast("Please enter a password", undefined); + return false; + } + if (newPassword !== confirmPassword) { + showToast("Passwords don't match", undefined); + return false; + } + + getSettings().exportPasswordHash = simpleHash(newPassword); + addLog("info", "Export password set successfully"); + showToast("Export password set successfully", undefined); + return true; +} + +export function removeExportPassword(requirePassword: RequirePassword, onComplete?: () => void) { + requirePassword({ + title: "Enter Password to Remove", + message: "Enter your current password to remove protection", + onSuccess: () => { + showConfirmationAlert({ + title: "Remove Export Password", + content: "Are you sure you want to remove the export password? This will make exports/imports less secure.", + confirmText: "Remove Password", + cancelText: "Cancel", + confirmColor: "brand", + onConfirm: () => { + delete getSettings().exportPasswordHash; + addLog("info", "Export password removed"); + showToast("Export password removed", undefined); + onComplete?.(); + } + }); + } + }); +} + +export async function addAccountWithToken(rawToken: string): Promise { + if (!getSettings().enableUnsafeFeatures) return false; + + let token = rawToken.trim(); + if (!token) token = TokenManager.getToken(); + + if (!token.startsWith("Bot ") && !token.match(/^[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}$/)) { + showToast("Invalid token format", undefined); + return false; + } + + try { + const response = await fetch("https://discord.com/api/v9/users/@me", { + headers: { Authorization: token, "Content-Type": "application/json" } + }); + + if (!response.ok) { + addLog("error", "Discord API request failed", { status: response.status }); + showToast("Invalid or expired token", undefined); + return false; + } + + const user = await response.json(); + const accounts = getAccounts(); + + if (accounts[user.id]) { + showToast(`Account ${user.username} already saved`, undefined); + return false; + } + + accounts[user.id] = { + id: user.id, + username: user.username, + discriminator: user.discriminator, + avatar: user.avatar, + displayName: user.global_name || user.username, + token, + addedAt: Date.now() + }; + addAccountToOrder(user.id); + + addLog("info", "Account added successfully via token", { username: user.username }); + showToast(`Account ${user.username} added!`, undefined); + return true; + } catch (e: any) { + addLog("error", "Failed to add account via token", { error: e?.message }); + showToast("Failed to add account", undefined); + return false; + } +} + +export async function addAccountWithCredentials(email: string, password: string): Promise { + if (!email.trim() || !password.trim()) { + showToast("Please enter both email and password", undefined); + return false; + } + + try { + const response = await fetch("https://discord.com/api/v9/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ login: email.trim(), password: password.trim() }) + }); + + const loginData = await response.json(); + + if (!response.ok || !loginData.token) { + let errorMessage = "Login failed"; + if (loginData.captcha_key) { + errorMessage = "Captcha required - please login through Discord first"; + } else if (loginData.message) { + const msg = loginData.message.toLowerCase(); + if (msg.includes("password")) errorMessage = "Invalid password"; + else if (msg.includes("mfa") || msg.includes("2fa")) errorMessage = "2FA/MFA accounts not supported via credentials"; + else if (msg.includes("invalid")) errorMessage = "Invalid email or username"; + else errorMessage = "Login failed - check your credentials"; + } else if (response.status === 429) { + errorMessage = "Rate limited - please wait and try again"; + } + showToast(errorMessage, undefined); + return false; + } + + const userResponse = await fetch("https://discord.com/api/v9/users/@me", { + headers: { Authorization: loginData.token } + }); + if (!userResponse.ok) { + showToast("Login succeeded but failed to get user info", undefined); + return false; + } + + const user = await userResponse.json(); + const accounts = getAccounts(); + + if (accounts[user.id]) { + showToast(`Account ${user.username} already saved`, undefined); + return false; + } + + accounts[user.id] = { + id: user.id, + username: user.username, + discriminator: user.discriminator, + avatar: user.avatar, + displayName: user.global_name || user.username, + token: loginData.token, + addedAt: Date.now() + }; + addAccountToOrder(user.id); + + addLog("info", "Account added successfully via credentials", { username: user.username }); + showToast(`Account ${user.username} added successfully!`, undefined); + return true; + } catch (e: any) { + addLog("error", "Credential login failed", { error: e?.message }); + showToast("Login failed - check your connection and try again", undefined); + return false; + } +} + +export function forceLogout() { + showConfirmationAlert({ + title: "Force Logout", + content: "This will logout your current session by using an invalid token. Your saved accounts will remain intact. Continue?", + confirmText: "Force Logout", + cancelText: "Cancel", + confirmColor: "brand", + onConfirm: async () => { + try { + const fakeToken = ["MTExMTExMTExMTExMTExMTEx", "GxxxXx", "xXxXxXxXxXxXxXxXxXxXxXxXxXxXx"].join("."); + await AuthActions.switchAccountToken(fakeToken); + addLog("info", "Force logout completed successfully"); + showToast("Force logout successful - you can now login to a different account", undefined); + } catch (e: any) { + addLog("warn", "Force logout completed with error", { error: e?.message }); + showToast("Force logout completed", undefined); + } + } + }); +} + +export async function switchToAccount(accountId: string): Promise { + const account = getAccounts()[accountId]; + if (!account) return false; + + try { + showToast(`Switching to ${account.username}...`, undefined); + await AuthActions.switchAccountToken(account.token); + addLog("info", "Account switch successful", { username: account.username }); + showToast(`Switched to ${account.username}!`, undefined); + return true; + } catch (e: any) { + addLog("error", "Account switch failed", { username: account.username, error: e?.message }); + showToast(`Failed to switch: ${e?.message}`, undefined); + return false; + } +} + +export function copyToken(accountId: string) { + if (!getSettings().enableUnsafeFeatures) return; + const account = getAccounts()[accountId]; + if (!account) return; + + clipboard.setString(account.token); + addLog("info", "Token copied to clipboard", { username: account.username }); + showToast(`Token for ${account.username} copied`, undefined); +} + +function deleteFromStorage(accountId: string) { + delete getAccounts()[accountId]; + removeAccountFromOrder(accountId); +} + +export function removeAccount(accountId: string, onComplete?: () => void) { + const account = getAccounts()[accountId]; + if (!account) return; + + const currentUserId = UserStore.getCurrentUser()?.id; + const isCurrent = accountId === currentUserId; + const accountName = account.username; + + const removeFromSwitcherOnly = () => { + deleteFromStorage(accountId); + addLog("info", "Account removed from switcher only", { username: accountName }); + showToast(`Account ${accountName} removed from switcher`, undefined); + onComplete?.(); + }; + + const logoutAndDelete = async () => { + try { + const currentToken = TokenManager.getToken(); + await AuthActions.switchAccountToken(account.token); + await AuthActions.logout(); + setTimeout(() => AuthActions.switchAccountToken(currentToken).catch(() => {}), 100); + } catch (e: any) { + addLog("error", "Failed to logout account from Discord", { error: e?.message }); + showToast("Failed to logout, but removed from switcher", undefined); + } + deleteFromStorage(accountId); + addLog("info", "Account removed and logged out", { username: accountName }); + showToast(`Account ${accountName} removed and logged out`, undefined); + onComplete?.(); + }; + + if (!getSettings().confirmBeforeDelete) { + if (isCurrent) removeFromSwitcherOnly(); + else logoutAndDelete(); + return; + } + + ReactNative.Alert.alert( + isCurrent ? "Remove Current Account" : "Remove Account", + isCurrent + ? "Do you want to remove the current account from the switcher? (To logout, use Force Logout in settings)" + : `What do you want to do with ${accountName}?`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Delete from switcher only", onPress: removeFromSwitcherOnly }, + !isCurrent && { text: "Delete and logout", onPress: logoutAndDelete } + ].filter(Boolean) as any + ); +} + +export async function addCurrentAccount(): Promise { + try { + const token = TokenManager.getToken(); + const currentUser = UserStore.getCurrentUser(); + const accounts = getAccounts(); + + if (!currentUser || accounts[currentUser.id]) { + showToast(accounts[currentUser?.id] ? "Current account already saved" : "Failed to get current user", undefined); + return false; + } + + accounts[currentUser.id] = { + id: currentUser.id, + username: currentUser.username, + discriminator: currentUser.discriminator, + avatar: currentUser.avatar, + displayName: currentUser.globalName || currentUser.username, + token, + addedAt: Date.now() + }; + addAccountToOrder(currentUser.id); + + addLog("info", "Current account added successfully", { username: currentUser.username }); + showToast(`Current account ${currentUser.username} added!`, undefined); + return true; + } catch (e: any) { + addLog("error", "Failed to add current account", { error: e?.message }); + showToast("Failed to add current account", undefined); + return false; + } +} + +export type { Account }; diff --git a/plugins/more-alts/src/lib/accounts.ts b/plugins/more-alts/src/lib/accounts.ts new file mode 100644 index 0000000..b0263aa --- /dev/null +++ b/plugins/more-alts/src/lib/accounts.ts @@ -0,0 +1,94 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { storage } from "@vendetta/plugin"; + +export interface Account { + id: string; + username: string; + discriminator: string; + avatar: string | null; + displayName: string; + token: string; + addedAt: number; +} + +export interface MoreAltsSettings { + enableCLI: boolean; + confirmBeforeDelete: boolean; + enableUnsafeFeatures: boolean; + addToSidebar: boolean; + enableNativeSwitcher: boolean; + exportPasswordHash?: string; +} + +export const UserStore = findByStoreName("UserStore"); +export const TokenManager = findByProps("getToken"); +export const AuthActions = findByProps("login", "logout", "switchAccountToken"); + +export function ensureStorage() { + storage.accounts ??= {}; + storage.accountOrder ??= []; + storage.settings ??= { + enableCLI: true, + confirmBeforeDelete: true, + enableUnsafeFeatures: false, + addToSidebar: true, + enableNativeSwitcher: true + } satisfies MoreAltsSettings; +} + +export function getAccounts(): Record { + ensureStorage(); + return storage.accounts; +} + +export function getAccountOrder(): string[] { + ensureStorage(); + return storage.accountOrder; +} + +export function getSettings(): MoreAltsSettings { + ensureStorage(); + return storage.settings; +} + +export function orderedAccounts(): Account[] { + return getAccountOrder() + .filter((id) => getAccounts()[id]) + .map((id) => getAccounts()[id]); +} + +export function formatAccountName(account?: Account | null): string { + return account?.username ?? "Unknown Account"; +} + +export function getAccountIndex(accountId: string): number | null { + const index = getAccountOrder().indexOf(accountId); + return index >= 0 ? index + 1 : null; +} + +export function addAccountToOrder(accountId: string) { + const order = getAccountOrder(); + if (!order.includes(accountId)) order.push(accountId); +} + +export function removeAccountFromOrder(accountId: string) { + const order = getAccountOrder(); + const idx = order.indexOf(accountId); + if (idx > -1) order.splice(idx, 1); +} + +export function findAccountByInput(input: string): [string, Account] | null { + const accounts = getAccounts(); + const order = getAccountOrder(); + + const index = parseInt(input, 10); + if (!isNaN(index) && index >= 1 && index <= order.length) { + const accountId = order[index - 1]; + return accounts[accountId] ? [accountId, accounts[accountId]] : null; + } + + const entry = Object.entries(accounts).find( + ([, account]) => account.username.toLowerCase() === input.toLowerCase() + ); + return entry ? (entry as [string, Account]) : null; +} diff --git a/plugins/more-alts/src/lib/logger.ts b/plugins/more-alts/src/lib/logger.ts new file mode 100644 index 0000000..3b87a07 --- /dev/null +++ b/plugins/more-alts/src/lib/logger.ts @@ -0,0 +1,30 @@ +import { getSettings } from "./accounts"; + +export interface LogEntry { + timestamp: string; + type: "debug" | "info" | "warn" | "error"; + message: string; + data?: unknown; +} + +const TAG = "[MoreAlts]"; +const MAX_LOGS = 1000; +const logs: LogEntry[] = []; + +export function addLog(type: LogEntry["type"], message: string, data?: unknown) { + logs.unshift({ timestamp: new Date().toISOString(), type, message, data }); + if (logs.length > MAX_LOGS) logs.pop(); + + if (getSettings().enableUnsafeFeatures) { + console.log(`${TAG} ${type.toUpperCase()}: ${message}`, data ?? ""); + } +} + +export function getLogs(): LogEntry[] { + return logs; +} + +export function clearLogs() { + logs.length = 0; + addLog("info", "Logs cleared"); +} diff --git a/plugins/more-alts/src/lib/nativeSwitcher.ts b/plugins/more-alts/src/lib/nativeSwitcher.ts new file mode 100644 index 0000000..4f7419d --- /dev/null +++ b/plugins/more-alts/src/lib/nativeSwitcher.ts @@ -0,0 +1,24 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { getSettings } from "./accounts"; + +const AccountDispatcher = findByProps("getCanUseMultiAccountMobile"); +const MultiAccountStore = findByStoreName("MultiAccountStore"); + +export default function patchNativeSwitcher(): () => void { + if (!getSettings().enableNativeSwitcher || !AccountDispatcher || !MultiAccountStore) { + return () => {}; + } + + const unpatch = after("getCanUseMultiAccountMobile", AccountDispatcher, () => true); + + Object.defineProperty(MultiAccountStore, "canUseMultiAccountNotifications", { + get: () => true, + configurable: true + }); + + return () => { + unpatch(); + delete (MultiAccountStore as any).canUseMultiAccountNotifications; + }; +} diff --git a/plugins/more-alts/src/lib/passwordUtils.ts b/plugins/more-alts/src/lib/passwordUtils.ts new file mode 100644 index 0000000..ae5efff --- /dev/null +++ b/plugins/more-alts/src/lib/passwordUtils.ts @@ -0,0 +1,10 @@ +export function simpleHash(str: string): string { + let hash = 0; + if (str.length === 0) return hash.toString(); + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); +} diff --git a/plugins/more-alts/src/patches/contextMenu.ts b/plugins/more-alts/src/patches/contextMenu.ts new file mode 100644 index 0000000..750469c --- /dev/null +++ b/plugins/more-alts/src/patches/contextMenu.ts @@ -0,0 +1,46 @@ +import { findByProps } from "@vendetta/metro"; +import { before } from "@vendetta/patcher"; +import { findInReactTree } from "@vendetta/utils"; +import { showToast } from "@vendetta/ui/toasts"; +import { clipboard } from "@vendetta/metro/common"; +import { getSettings, TokenManager, UserStore } from "../lib/accounts"; +import { addLog } from "../lib/logger"; + +const OPTION_LABEL = "Copy Token"; + +export default function patchContextMenu(): () => void { + return before("render", findByProps("ScrollView").View, (args: any[]) => { + try { + if (!getSettings().enableUnsafeFeatures) return; + + const sheet = findInReactTree(args, (r: any) => r.key === ".$UserProfileOverflow"); + if (!sheet?.props || sheet.props.sheetKey !== "UserProfileOverflow") return; + + const props = sheet.props.content.props; + if (props.options.some((option: any) => option?.label === OPTION_LABEL)) return; + + const currentUserId = UserStore.getCurrentUser()?.id; + const focusedUserId = Object.keys(sheet._owner.stateNode._keyChildMapping) + .find((str: string) => sheet._owner.stateNode._keyChildMapping[str] && str.match(/(?<=\$UserProfile)\d+/)) + ?.slice(".$UserProfile".length) ?? currentUserId; + const token = TokenManager.getToken(); + + props.options.unshift({ + isDestructive: true, + label: OPTION_LABEL, + onPress: () => { + try { + clipboard.setString(focusedUserId === currentUserId ? token : ""); + showToast(focusedUserId === currentUserId ? "Copied your token" : "Can only copy the current account's token", undefined); + props.hideActionSheet(); + } catch (e: any) { + addLog("error", "Failed to copy token from context menu", { error: e?.message }); + showToast("Failed to copy token", undefined); + } + } + }); + } catch (e: any) { + addLog("error", "Context menu patch error", { error: e?.message }); + } + }); +} diff --git a/plugins/more-alts/src/patches/sidebarPin.ts b/plugins/more-alts/src/patches/sidebarPin.ts new file mode 100644 index 0000000..1039ea9 --- /dev/null +++ b/plugins/more-alts/src/patches/sidebarPin.ts @@ -0,0 +1,204 @@ +import { React, NavigationNative } from "@vendetta/metro/common"; +import { findByProps, findByName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { Forms } from "@vendetta/ui/components"; +import { findInReactTree } from "@vendetta/utils"; + +const { FormSection, FormRow } = Forms; + +const SECTION_LABEL = "More Alts!"; +const SECTION_TITLE = "More Alts!"; + +const tabsNavigationRef = findByProps("getRootNavigationRef"); +const settingConstants = findByProps("SETTING_RENDERER_CONFIG"); +const createListModule = findByProps("createList"); +const SettingsOverviewScreen = findByName("SettingsOverviewScreen", false); +const TableRowIconModule = findByProps("TableRowIcon"); + +export interface SectionRow { + key: string; + title: () => string; + icon?: any; + page: React.ComponentType; +} + +const rendererRowCache: Record = {}; + +function cacheRows(rows: SectionRow[]) { + for (const row of rows) rendererRowCache[row.key] = buildRendererRow(row); +} + +function navigateToRow(row: SectionRow) { + const navigation = tabsNavigationRef.getRootNavigationRef(); + const Component = row.page; + navigation.navigate("VendettaCustomPage", { + title: row.title(), + render: () => React.createElement(Component) + }); +} + +function buildRendererRow(row: SectionRow) { + return { + type: "pressable", + useTitle: row.title, + title: row.title, + icon: row.icon, + IconComponent: + row.icon && TableRowIconModule && + (() => React.createElement(TableRowIconModule.TableRowIcon, { source: row.icon })), + usePredicate: () => true, + onPress: () => navigateToRow(row), + withArrow: true + }; +} + +function Section({ tabs }: { tabs: SectionRow }) { + const navigation = NavigationNative.useNavigation(); + + return React.createElement(FormRow, { + label: tabs.title(), + leading: tabs.icon ? React.createElement(FormRow.Icon, { source: tabs.icon }) : undefined, + trailing: React.createElement(FormRow.Arrow), + onPress: () => { + const Component = tabs.page; + navigation.navigate("VendettaCustomPage", { + title: tabs.title(), + render: () => React.createElement(Component) + }); + } + }); +} + +function patchPanelUI(getRows: () => SectionRow[], patches: (() => void)[]) { + const target = findByProps("renderTitle", "sections"); + if (!target) return; + + try { + patches.push( + after("default", target, (_: any, ret: any) => { + const UserSettingsOverview = findInReactTree( + ret.props.children, + (n: any) => n.type?.name === "UserSettingsOverview" + ); + + if (UserSettingsOverview) { + patches.push( + after("render", UserSettingsOverview.type.prototype, (_args: any, res: any) => { + const sections = findInReactTree( + res.props.children, + (n: any) => n?.children?.[1]?.type === FormSection + )?.children; + + if (sections) { + const rows = getRows(); + rows.forEach((row, i) => { + sections.splice(i, 0, React.createElement(Section, { key: row.key, tabs: row })); + }); + } + }, true) + ); + } + }, true) + ); + } catch { + // This surface may not exist on this build - patchTabsUI below covers the mobile list. + } +} + +function patchTabsUI(getRows: () => SectionRow[], patches: (() => void)[]) { + if (!settingConstants || !tabsNavigationRef) { + console.warn("[MoreAlts] Missing constants for tabs UI patch"); + return; + } + + let baseConfigValue: any = settingConstants.SETTING_RENDERER_CONFIG; + + const configGetter = () => { + cacheRows(getRows()); + return { ...baseConfigValue, ...rendererRowCache }; + }; + + function ensureConfigOverride() { + const current = Object.getOwnPropertyDescriptor(settingConstants, "SETTING_RENDERER_CONFIG"); + if (current?.get === configGetter) return; + + baseConfigValue = current?.get ? current.get.call(settingConstants) : (current?.value ?? baseConfigValue); + + Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", { + enumerable: true, + configurable: true, + get: configGetter, + set(v: any) { baseConfigValue = v; } + }); + } + + ensureConfigOverride(); + + const firstRender = Symbol("morealts-first-render"); + + try { + if (!createListModule) return; + patches.push( + after("createList", createListModule, function (args: any) { + ensureConfigOverride(); + if (args[0][firstRender]) return; + args[0][firstRender] = true; + + const [config] = args; + const sections = config.sections; + const rows = getRows(); + if (!rows.length || !sections) return; + cacheRows(rows); + + const anchorIndex = sections.findIndex((x: any) => + ["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].some( + (mod) => x.label === mod && x.title === mod + ) + ); + + sections.splice( + anchorIndex >= 0 ? anchorIndex + 1 : 0, + 0, + { label: SECTION_LABEL, title: SECTION_TITLE, settings: rows.map((r) => r.key) } + ); + }) + ); + } catch { + if (!SettingsOverviewScreen) return; + patches.push( + after("default", SettingsOverviewScreen, (args: any, ret: any) => { + ensureConfigOverride(); + if (args[0][firstRender]) return; + args[0][firstRender] = true; + + const { sections } = findInReactTree(ret, (i: any) => i.props?.sections)?.props ?? {}; + const rows = getRows(); + if (!rows.length || !sections) return; + cacheRows(rows); + + const anchorIndex = sections.findIndex((x: any) => + ["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].some( + (mod) => x.label === mod && x.title === mod + ) + ); + + sections.splice( + anchorIndex >= 0 ? anchorIndex + 1 : 0, + 0, + { label: SECTION_LABEL, title: SECTION_TITLE, settings: rows.map((r) => r.key) } + ); + }) + ); + } +} + +export default function patchMoreAltsSection(getRows: () => SectionRow[]): () => void { + const patches: (() => void)[] = []; + + patchPanelUI(getRows, patches); + patchTabsUI(getRows, patches); + + return () => { + for (const p of patches) p?.(); + }; +} diff --git a/plugins/more-alts/src/ui/Settings.tsx b/plugins/more-alts/src/ui/Settings.tsx new file mode 100644 index 0000000..c2b6372 --- /dev/null +++ b/plugins/more-alts/src/ui/Settings.tsx @@ -0,0 +1,134 @@ +import { React, NavigationNative, ReactNative } from "@vendetta/metro/common"; +import { useProxy } from "@vendetta/storage"; +import { getSettings, orderedAccounts, UserStore } from "../lib/accounts"; +import { copyToken, removeAccount, switchToAccount } from "../lib/accountActions"; +import AddAccount from "./pages/AddAccount"; +import GeneralSettings from "./pages/GeneralSettings"; + +const { View, Text, ScrollView, TouchableOpacity, Image } = ReactNative; + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +export default function Settings() { + const settings = getSettings(); + useProxy(settings); + const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0); + const navigation = NavigationNative.useNavigation(); + + const [switchingTo, setSwitchingTo] = React.useState(null); + + const currentUserId = UserStore.getCurrentUser()?.id; + const accounts = orderedAccounts(); + + const handleSwitch = async (accountId: string) => { + setSwitchingTo(accountId); + await switchToAccount(accountId); + setSwitchingTo(null); + forceUpdate(); + }; + + const handleRemove = (accountId: string) => { + removeAccount(accountId, forceUpdate); + }; + + return ( + + + Saved accounts ({accounts.length}) + navigation.navigate("VendettaCustomPage", { title: "Settings", render: () => })} + style={{ padding: 8, borderRadius: 6, backgroundColor: "#4f545c" }} + > + ⚙️ + + + + {accounts.length === 0 ? ( + + No accounts saved yet + + Tap "Add Account" below to save this account or another one. + + + ) : ( + + {accounts.map((account, index) => { + const isCurrent = account.id === currentUserId; + const isSwitching = switchingTo === account.id; + const avatarUrl = account.avatar + ? `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.png?size=48` + : "https://cdn.discordapp.com/embed/avatars/1.png"; + + return ( + + handleSwitch(account.id)} + disabled={isCurrent || isSwitching} + style={{ marginRight: 12 }} + > + + + + + + {index + 1}. {settings.enableCLI ? `${account.username}${account.discriminator !== "0" ? `#${account.discriminator}` : ""}` : account.displayName} + + + {isCurrent ? "✓ Current account" : isSwitching ? "Switching..." : "Tap avatar to switch"} + + + Added: {formatDate(account.addedAt)} + + + + + {!!settings.enableUnsafeFeatures && ( + copyToken(account.id)} + style={{ backgroundColor: "#4f545c", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, minWidth: 80, alignItems: "center", marginBottom: 6 }} + > + Copy token + + )} + handleRemove(account.id)} + style={{ backgroundColor: "#f04747", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, minWidth: 80, alignItems: "center" }} + > + Remove + + + + ); + })} + + )} + + + navigation.navigate("VendettaCustomPage", { title: "Add Account", render: () => })} + style={{ backgroundColor: "#7289da", paddingVertical: 16, borderRadius: 8, alignItems: "center", flexDirection: "row", justifyContent: "center" }} + > + + + Add account + + + + ); +} diff --git a/plugins/more-alts/src/ui/pages/AddAccount.tsx b/plugins/more-alts/src/ui/pages/AddAccount.tsx new file mode 100644 index 0000000..5057e8e --- /dev/null +++ b/plugins/more-alts/src/ui/pages/AddAccount.tsx @@ -0,0 +1,62 @@ +import { React, NavigationNative, ReactNative } from "@vendetta/metro/common"; +import { TableRowGroup, TextInput } from "@shared/ui/table"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import NoteBox from "@shared/ui/NoteBox"; +import { addAccountWithCredentials, addCurrentAccount } from "../../lib/accountActions"; + +const { View } = ReactNative; + +export default function AddAccount() { + const navigation = NavigationNative.useNavigation(); + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [isAdding, setIsAdding] = React.useState(false); + + const loginWithCredentials = async () => { + setIsAdding(true); + const ok = await addAccountWithCredentials(email, password); + setIsAdding(false); + if (ok) navigation.goBack(); + }; + + const addCurrent = async () => { + setIsAdding(true); + const ok = await addCurrentAccount(); + setIsAdding(false); + if (ok) navigation.goBack(); + }; + + return ( + + 2FA/MFA accounts aren't supported through email & password login. Use "Add Current Account" instead, or add a token in General Settings. + + + + + + + + + + ); +} diff --git a/plugins/more-alts/src/ui/pages/GeneralSettings.tsx b/plugins/more-alts/src/ui/pages/GeneralSettings.tsx new file mode 100644 index 0000000..39a6e8f --- /dev/null +++ b/plugins/more-alts/src/ui/pages/GeneralSettings.tsx @@ -0,0 +1,174 @@ +import { React, NavigationNative, ReactNative, clipboard } from "@vendetta/metro/common"; +import { useProxy } from "@vendetta/storage"; +import { showToast } from "@vendetta/ui/toasts"; +import { TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import NoteBox from "@shared/ui/NoteBox"; +import { getSettings } from "../../lib/accounts"; +import { + RequirePassword, + addAccountWithToken, + exportAccounts, + forceLogout, + importAccounts, + removeExportPassword, + setExportPassword +} from "../../lib/accountActions"; +import { addLog, clearLogs, getLogs } from "../../lib/logger"; +import PasswordPrompt from "./PasswordPrompt"; + +const { View } = ReactNative; + +export default function GeneralSettings() { + const settings = getSettings(); + useProxy(settings); + const navigation = NavigationNative.useNavigation(); + const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0); + + const [importText, setImportText] = React.useState(""); + const [newToken, setNewToken] = React.useState(""); + const [isAddingToken, setIsAddingToken] = React.useState(false); + const [newPassword, setNewPassword] = React.useState(""); + const [confirmPassword, setConfirmPassword] = React.useState(""); + + const requirePassword: RequirePassword = ({ title, message, onSuccess }) => { + navigation.navigate("VendettaCustomPage", { + title, + render: () => + }); + }; + + const copyLogs = () => { + const logs = getLogs(); + const text = logs + .map((l) => `[${l.timestamp}] ${l.type.toUpperCase()}: ${l.message}${l.data ? " | Data: " + JSON.stringify(l.data) : ""}`) + .join("\n"); + clipboard.setString(text); + addLog("info", "Logs copied to clipboard", { count: logs.length }); + showToast(`Copied ${logs.length} log entries to clipboard`, undefined); + forceUpdate(); + }; + + const addToken = async () => { + setIsAddingToken(true); + const ok = await addAccountWithToken(newToken); + setIsAddingToken(false); + if (ok) setNewToken(""); + forceUpdate(); + }; + + return ( + + + { settings.enableNativeSwitcher = v; forceUpdate(); showToast("Restart app to apply", undefined); }} + /> + { settings.enableCLI = v; forceUpdate(); }} + /> + { settings.addToSidebar = v; forceUpdate(); showToast("Restart app to apply", undefined); }} + /> + { settings.confirmBeforeDelete = v; forceUpdate(); }} + /> + + + + exportAccounts(requirePassword)} + style={{ marginHorizontal: 16, marginTop: 8 }} + /> + + importAccounts(requirePassword, importText, forceUpdate)} + style={{ marginHorizontal: 16, marginBottom: 8 }} + /> + + + + + {settings.exportPasswordHash ? ( + <> + Export password is set. + removeExportPassword(requirePassword, forceUpdate)} + style={{ marginHorizontal: 16, marginBottom: 8 }} + /> + + ) : ( + <> + + + { + if (setExportPassword(newPassword, confirmPassword)) { + setNewPassword(""); + setConfirmPassword(""); + } + forceUpdate(); + }} + style={{ marginHorizontal: 16, marginBottom: 8 }} + /> + + )} + + + + Token copying, manual token adding, and detailed logging. These can compromise account security if misused - never share a token with anyone. + { settings.enableUnsafeFeatures = v; forceUpdate(); }} + /> + + + {settings.enableUnsafeFeatures && ( + <> + + + + + + + + + { clearLogs(); forceUpdate(); }} style={{ flex: 1 }} /> + + + + )} + + ); +} diff --git a/plugins/more-alts/src/ui/pages/PasswordPrompt.tsx b/plugins/more-alts/src/ui/pages/PasswordPrompt.tsx new file mode 100644 index 0000000..f4d8288 --- /dev/null +++ b/plugins/more-alts/src/ui/pages/PasswordPrompt.tsx @@ -0,0 +1,50 @@ +import { React, NavigationNative } from "@vendetta/metro/common"; +import { showToast } from "@vendetta/ui/toasts"; +import { TableRowGroup, TextInput } from "@shared/ui/table"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import { getSettings } from "../../lib/accounts"; +import { simpleHash } from "../../lib/passwordUtils"; +import { addLog } from "../../lib/logger"; + +interface PasswordPromptProps { + title: string; + message: string; + onSuccess: () => void; +} + +export default function PasswordPrompt({ title, message, onSuccess }: PasswordPromptProps) { + const navigation = NavigationNative.useNavigation(); + const [input, setInput] = React.useState(""); + + const confirm = () => { + if (!input.trim()) { + showToast("Please enter the password", undefined); + return; + } + + if (simpleHash(input) === getSettings().exportPasswordHash) { + addLog("info", "Password verification successful"); + navigation.goBack(); + onSuccess(); + } else { + addLog("warn", "Incorrect password attempt"); + showToast("Incorrect password", undefined); + } + }; + + return ( + + + + + + + ); +}