From 1a474313913fe89e457c1c6bb8027483f8d5e191 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Thu, 20 Aug 2026 11:56:23 -0400 Subject: [PATCH] Add auto-update + fail-closed block gate to every plugin --- docs/shared-modules-audit.md | 110 ++++++++++++++++++ plugins/better-timestamps/src/index.ts | 7 +- plugins/copy-role-color/src/index.ts | 6 +- plugins/custom-user-tags/src/index.ts | 7 +- .../src/index.ts | 7 +- plugins/key-inspector/src/index.tsx | 5 + plugins/message-logger/src/index.ts | 30 +++-- plugins/message-snippets/src/index.ts | 11 +- plugins/more-alts/src/index.ts | 68 ++++++----- plugins/pronoun-db/src/index.tsx | 31 +++-- plugins/quest-orbs/src/index.ts | 15 ++- plugins/radial-status/src/index.ts | 7 +- plugins/reminders/src/index.ts | 19 +-- plugins/reverse-image-search/src/index.ts | 11 +- plugins/role-color-everywhere/src/index.ts | 7 +- plugins/rose-plugs/src/index.ts | 21 ++-- plugins/rose-utils/src/index.ts | 35 +++--- plugins/staff-tags/src/index.ts | 7 +- plugins/typing-avatars/src/index.ts | 6 +- plugins/urban-dictionary/src/index.ts | 11 +- plugins/view-raw/src/index.tsx | 17 ++- plugins/you-bar-plus/src/index.ts | 51 ++++++-- shared/lib/backend.ts | 48 ++++++++ shared/lib/guard.ts | 23 ++++ shared/lib/reload.ts | 18 +++ 25 files changed, 456 insertions(+), 122 deletions(-) create mode 100644 docs/shared-modules-audit.md create mode 100644 shared/lib/backend.ts create mode 100644 shared/lib/guard.ts create mode 100644 shared/lib/reload.ts diff --git a/docs/shared-modules-audit.md b/docs/shared-modules-audit.md new file mode 100644 index 0000000..66aa491 --- /dev/null +++ b/docs/shared-modules-audit.md @@ -0,0 +1,110 @@ +# Shared modules audit (classic revenge-plugins) + +Date: 2026-08-16 +Scope: `/root/revenge-plugins` only (Next repo not covered). Audit only — no code changed. + +Goal: (1) give a reference sheet of what `shared/` already offers so new/edited plugins reach for +it instead of reinventing, and (2) list concrete cases where 2+ plugins built the same thing +independently, so we know what's actually worth promoting to `shared/` next. + +## 1. Reference: what's already in `shared/` and who uses it + +| Module | What it does | Adoption | +|---|---|---| +| `ui/table.ts` | `TableRow`/`TableRowGroup`/`TableSwitchRow`/`TableRadioRow` — Discord's redesigned settings rows, falling back to legacy `Forms.Form*` | 24 files — well adopted, the default for any settings screen | +| `ui/SettingsScaffold.tsx` | ScrollView + bottom padding wrapper for a plugin's settings screen | 19 files — well adopted | +| `lib/color.ts` | `isValidHex`, `normalizeHex`, `resolveSemanticColor`/`resolveSemanticColorSafe` (theme-aware color token resolution) | 17 files — well adopted | +| `ui/NoteBox.tsx` | Bordered callout box for explanatory settings text | 15 files — well adopted | +| `ui/PrimaryButton.tsx` | Standard filled action button | 9 files — well adopted | +| `lib/rawFind.ts` | Walks `window.modules` directly for a module lookup that survives Revenge's negative-result caching | 8 files | +| `lib/patcher.ts` | `safePatch`/`applyPatches` — one broken patch surface can't crash a whole plugin's `onLoad` | 8 files — every plugin should use this for `onLoad`, worth spot-checking stragglers | +| `lib/createElementIntercept.ts` | Intercepts `React.createElement`/`jsx`/`jsxs` to replace or transform components that aren't exported and can't be `after()`-patched directly | 7 files | +| `lib/flux.ts` | `fluxSubscribe(event, cb, once?)` with built-in unsubscribe | 4 files | +| `lib/waitFor.ts` | Polls until a lazily-registered module/value appears, cancelable | 4 files | +| `ui/ListSection.tsx` | Titled section of tappable rows with an empty-state note | 3 files | +| `lib/lazy.ts` | Memoizing wrapper for a Metro lookup (resolve once, cache forever) | 2 files — low adoption, worth checking for plugins hand-rolling the same "cache after first resolve" pattern | +| `lib/patchRows.ts` | `patchRows(handler)` — hooks the native chat row bridge (`DCDChatManager.updateRows` / `RowManager.generate`) so a handler sees every message row as JSON | Used by rose-utils, role-color-everywhere, message-logger | +| `ui/ColorInput.tsx` | Hex color field with swatches | **1 file (radial-status)** — see finding #2, this is the stale version | + +**Note on `patchRows.ts` vs the member-list "UserRow" pattern below:** these are unrelated despite +similar names. `patchRows` is for the *native chat message row bridge* (JSON crossing to native +code). The `findByTypeNameAll("UserRow")` pattern in finding #1 is a *React component* used in the +member list / profile popout. Don't conflate them when extracting shared code. + +## 2. Findings: duplicated or fork-worthy code + +### Finding 1 — `staff-tags` and `custom-user-tags` duplicate five files byte-for-byte +`ui/IconPicker.tsx`, `ui/Icon.tsx`, `lib/icons.ts`, and `patches/tag.tsx` are **100% identical** +between the two plugins (confirmed via `diff`, zero output). `ui/ColorInput.tsx` is also identical +between the two but has drifted from `shared/`'s version — see finding 2. + +`patches/name.tsx`, `patches/details.tsx`, `patches/chat.ts`, `patches/profile.tsx` are not +byte-identical but are structurally the same patch applied twice: same target modules +(`DisplayName`, `UserRow`, `getTagProperties`, `UserProfilePrimaryInfo`), same comments explaining +*why* (e.g. "isn't a top-level export" copied verbatim into both `profile.tsx` files), and — not +in scope for this audit, but worth knowing — the **same latent bug** in both: the +`existingTag.props?.type !== 0` guard can't tell "a real Discord tag is here" from "the other +plugin already set a tag here," so having both plugins enabled causes one to silently clobber the +other's tag on a user. That's the parked staff-tags/custom-user-tags coexistence design (separate +task). + +**Recommendation:** promote `IconPicker.tsx`, `Icon.tsx`, `icons.ts` to `shared/ui` and `shared/lib` +as-is (genuine, no-decision-needed duplication). Leave the four patch files alone for now — their +near-duplication is entangled with the coexistence bug fix, so refactor them together with that +work rather than as a mechanical move. + +### Finding 2 — `shared/ui/ColorInput.tsx` is a stale stub; the real version lives unshared in two plugins +`shared/ui/ColorInput.tsx` is a minimal 2-swatch color field. `staff-tags/src/ui/ColorInput.tsx` +and `custom-user-tags/src/ui/ColorInput.tsx` (identical to each other) are a fuller implementation: +28 swatches, hex↔RGB conversion helpers, uses `normalizeHex` from `shared/lib/color`. Only +`radial-status` uses the shared stub today; everyone who needs a real color picker forked instead +of upstreaming. + +**Recommendation:** replace `shared/ui/ColorInput.tsx`'s contents with the fuller version, point +`staff-tags`/`custom-user-tags`/`radial-status` at it, delete the two local copies. Check +`radial-status`'s usage still renders correctly with the richer swatch set before removing its +reliance on the stub. + +### Finding 3 — `server-drawer` forked `createElementIntercept.ts` instead of extending the shared one +`server-drawer/src/patches/createElementIntercept.ts` is a deliberate fork of +`shared/lib/createElementIntercept.ts`, not accidental drift: it adds a `collapseAncestors` option +(ancestor-collapsing, used to hide the guilds bar and reclaim its layout space) that the shared +version doesn't have. 88 lines of diff, all additive on server-drawer's side. + +**Recommendation:** this is a real capability gap in `shared/`, not a copy-paste duplication. Worth +upstreaming `collapseAncestors` into `shared/lib/createElementIntercept.ts` and switching +server-drawer back onto the shared module — but do it deliberately (it's shared infra used by 7 +files across the repo; changing its behavior needs a look at every caller, not just server-drawer's +four files that reference it). + +### Finding 4 — per-ID storage-map boilerplate repeated in at least 5 plugins +The pattern `storage. ??= {}` followed by hand-written get/set/(sometimes remove) helpers for +a per-user or per-ID settings map shows up independently in: +- `staff-tags/src/lib/getTag.ts` (`tagSettings(id)`) +- `custom-user-tags/src/lib/tags.ts` (`allTags()`, `getUserTag()`, `setUserTag()`, `removeUserTag()`) +- `message-snippets/src/lib/snippets.ts` +- `radial-status/src/ui/Settings.tsx` +- `message-logger/src/index.ts` (single-key, simpler case) + +**Recommendation:** a small `shared/lib/storageMap.ts` exporting something like +`createStorageMap(storage, key)` → `{ get(id), set(id, value), remove(id), all() }` would +collapse ~15-20 lines per plugin into one call. Low risk, no behavior change, good first +extraction to pair with finding 1's icon files. + +### Finding 5 — superseded, `raidens-themes` removed +Originally: `raidens-themes/src/patches/settings.ts` and `rose-plugs/src/patches/settings.ts` +independently solved "pin a settings entry into Discord's native App Settings" with different, +non-trivial APIs (272 lines of diff). `raidens-themes` was deleted from the repo on 2026-08-16, so +this is no longer a duplication to reconcile — `rose-plugs/src/patches/settings.ts` is now the only +implementation. Worth revisiting only if a future plugin wants the same capability; its +`SectionRow[]` API (`patches/settings.ts` exports `patchRosiesPlugsSection`) is the one to build on, +not extract into `shared/` preemptively with no second consumer. + +## 3. Suggested order if/when this gets acted on +1. Finding 4 (storage map) — mechanical, zero behavior change, unblocks nothing else. +2. Finding 1's static files (icons/IconPicker) — mechanical, identical code, low risk. +3. Finding 2 (ColorInput) — mechanical but touches 3 plugins' rendering, needs a visual check. +4. Finding 3 (createElementIntercept collapseAncestors) — real shared-infra change, needs care. +5. Finding 1's patch files (name/details/chat/profile) — do together with the parked + staff-tags/custom-user-tags coexistence design, not standalone. +6. Finding 5 (settings pinning) — defer until a third consumer exists. diff --git a/plugins/better-timestamps/src/index.ts b/plugins/better-timestamps/src/index.ts index 53f601b..eeddc82 100644 --- a/plugins/better-timestamps/src/index.ts +++ b/plugins/better-timestamps/src/index.ts @@ -1,6 +1,7 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import patchRowManager from "./patches/rowManager"; import patchTimestamp from "./patches/timestamp"; import Settings from "./ui/Settings"; @@ -13,10 +14,10 @@ export default { storage.customFormat ??= "dddd, MMMM Do YYYY, h:mm:ss a"; storage.separateMessages ??= false; - unpatchAll = applyPatches("BetterTimestamps", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("BetterTimestamps", logger, { "message & day timestamps": patchRowManager, "message timestamp component": patchTimestamp - }); + })); }, onUnload: () => unpatchAll(), settings: Settings diff --git a/plugins/copy-role-color/src/index.ts b/plugins/copy-role-color/src/index.ts index 1ea1a72..365a378 100644 --- a/plugins/copy-role-color/src/index.ts +++ b/plugins/copy-role-color/src/index.ts @@ -1,4 +1,5 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; import { findByName, findByStoreName } from "@vendetta/metro"; import { after } from "@vendetta/patcher"; import { findInReactTree } from "@vendetta/utils"; @@ -6,6 +7,7 @@ import { clipboard } from "@vendetta/metro/common"; import { getAssetIDByName } from "@vendetta/ui/assets"; import { showToast } from "@vendetta/ui/toasts"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept"; // A gradient role's real colors live at role.colorStrings ({primaryColor, secondaryColor, @@ -119,10 +121,10 @@ let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unpatchAll = applyPatches("CopyRoleColor", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("CopyRoleColor", logger, { "role pill long-press": patchRolePill, "profile role item long-press": patchProfileRoleItem - }); + })); }, onUnload: () => unpatchAll() }; diff --git a/plugins/custom-user-tags/src/index.ts b/plugins/custom-user-tags/src/index.ts index ddbd7c6..25a13aa 100644 --- a/plugins/custom-user-tags/src/index.ts +++ b/plugins/custom-user-tags/src/index.ts @@ -1,6 +1,7 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import patchChat from "./patches/chat"; import patchName from "./patches/name"; import patchDetails from "./patches/details"; @@ -14,13 +15,13 @@ export default { onLoad: () => { storage.tags ??= {}; - unpatchAll = applyPatches("Custom User Tags", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("Custom User Tags", logger, { tag: patchTag, chat: patchChat, name: patchName, details: patchDetails, profile: patchProfile - }); + })); }, onUnload: () => unpatchAll(), settings: Settings diff --git a/plugins/fake-profile-themes-and-effects/src/index.ts b/plugins/fake-profile-themes-and-effects/src/index.ts index 4e6498f..fe6db5e 100644 --- a/plugins/fake-profile-themes-and-effects/src/index.ts +++ b/plugins/fake-profile-themes-and-effects/src/index.ts @@ -1,6 +1,7 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import { FluxDispatcher } from "@fpte/lib/flux"; import { UserProfileStore, UserStore } from "@fpte/lib/stores"; import { @@ -36,7 +37,7 @@ export default { // self-contained fallback picker instead. storage.forceFallbackEffectPicker ??= true; - unpatchAll = applyPatches("FakeProfileThemesAndEffects", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("FakeProfileThemesAndEffects", logger, { "purchase check": patchGetPurchase, "user profile data": patchGetUserProfile, "profile effect sections": () => { @@ -46,7 +47,7 @@ export default { "profile theme": patchUseProfileTheme, "profile edit form": patchUserProfileEditForm, "nitro upsell card": patchNitroUpsellCard - }); + })); updateProfileThemeAndEffect(); }, onUnload() { diff --git a/plugins/key-inspector/src/index.tsx b/plugins/key-inspector/src/index.tsx index a18fc20..7967148 100644 --- a/plugins/key-inspector/src/index.tsx +++ b/plugins/key-inspector/src/index.tsx @@ -1,5 +1,10 @@ +import { id } from "@vendetta/plugin"; +import { checkForUpdate } from "@shared/lib/reload"; import Settings from "./ui/Settings"; export default { + onLoad: () => { + checkForUpdate(id).catch(() => {}); + }, settings: Settings }; diff --git a/plugins/message-logger/src/index.ts b/plugins/message-logger/src/index.ts index 6ccfc8e..45364c2 100644 --- a/plugins/message-logger/src/index.ts +++ b/plugins/message-logger/src/index.ts @@ -1,17 +1,19 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import Settings from "./ui/Settings"; -import { patchFluxIntercept, revertFakedMessages } from "./patches/fluxIntercept"; +import { patchFluxIntercept, rehydrateFromLog, revertFakedMessages } from "./patches/fluxIntercept"; import { patchRowStyling } from "./patches/rowStyling"; +import { initCloudSync } from "./lib/cloudSync"; const TAG = "[MessageLogger]"; -const cleanups: (() => void)[] = []; +let unpatchAll: () => void = () => {}; function initStorage() { storage.options ??= {}; const o = storage.options; o.logDeleted ??= true; - o.keepDeletedInline ??= true; + o.keepDeletedInline ??= false; o.logEdited ??= true; o.ignoreBots ??= false; o.ignoreOwnMessages ??= false; @@ -31,11 +33,20 @@ export default { initStorage(); console.log(TAG, "onLoad"); - let patched = 0; - if (patchFluxIntercept(cleanups)) patched++; - if (patchRowStyling(cleanups)) patched++; + unpatchAll = guardPlugin(id, () => { + const cleanups: (() => void)[] = []; + let patched = 0; + if (patchFluxIntercept(cleanups)) patched++; + if (patchRowStyling(cleanups)) patched++; + cleanups.push(initCloudSync()); + rehydrateFromLog(); - console.log(TAG, `onLoad done - ${patched}/2 patches applied`); + console.log(TAG, `onLoad done - ${patched}/2 patches applied`); + return () => { + for (const fn of cleanups) fn(); + cleanups.length = 0; + }; + }); }, onUnload() { console.log(TAG, "onUnload"); @@ -44,8 +55,7 @@ export default { } catch (e: any) { logger.error("[MessageLogger] Failed to revert faked messages:", e?.message ?? e); } - for (const fn of cleanups) fn(); - cleanups.length = 0; + unpatchAll(); }, settings: Settings, }; diff --git a/plugins/message-snippets/src/index.ts b/plugins/message-snippets/src/index.ts index ea9caa6..a543666 100644 --- a/plugins/message-snippets/src/index.ts +++ b/plugins/message-snippets/src/index.ts @@ -1,12 +1,17 @@ +import { id } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import loadCommands from "./commands"; import Settings from "./ui/Settings"; -let unregisterFns: (() => void)[] = []; +let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unregisterFns = loadCommands(); + unpatchAll = guardPlugin(id, () => { + const unregisterFns = loadCommands(); + return () => unregisterFns.forEach((fn) => fn()); + }); }, - onUnload: () => unregisterFns.forEach((fn) => fn()), + onUnload: () => unpatchAll(), settings: Settings }; diff --git a/plugins/more-alts/src/index.ts b/plugins/more-alts/src/index.ts index 0c5f9e7..740b8d3 100644 --- a/plugins/more-alts/src/index.ts +++ b/plugins/more-alts/src/index.ts @@ -1,5 +1,7 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; import { getAssetIDByName } from "@vendetta/ui/assets"; +import { guardPlugin } from "@shared/lib/guard"; import Settings from "./ui/Settings"; import patchMoreAltsSection, { SectionRow } from "./patches/sidebarPin"; import patchContextMenu from "./patches/contextMenu"; @@ -7,10 +9,7 @@ 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)[] = []; +let teardown: () => void = () => {}; function buildRows(): SectionRow[] { if (!getSettings().addToSidebar) return []; @@ -30,36 +29,47 @@ export default { onLoad() { ensureStorage(); - try { - unpatchSidebar = patchMoreAltsSection(buildRows); - } catch (e: any) { - logger.error("[MoreAlts] Failed to patch sidebar:", e?.message ?? e); - } + teardown = guardPlugin(id, () => { + let unpatchSidebar: (() => void) | undefined; + let unpatchContextMenu: (() => void) | undefined; + let unpatchNativeSwitcher: (() => void) | undefined; + let unregisterCommands: (() => void)[] = []; - try { - unpatchContextMenu = patchContextMenu(); - } catch (e: any) { - logger.error("[MoreAlts] Failed to patch context menu:", e?.message ?? e); - } + try { + unpatchSidebar = patchMoreAltsSection(buildRows); + } catch (e: any) { + logger.error("[MoreAlts] Failed to patch sidebar:", e?.message ?? e); + } - try { - unpatchNativeSwitcher = patchNativeSwitcher(); - } catch (e: any) { - logger.error("[MoreAlts] Failed to patch native switcher:", e?.message ?? e); - } + try { + unpatchContextMenu = patchContextMenu(); + } catch (e: any) { + logger.error("[MoreAlts] Failed to patch context menu:", e?.message ?? e); + } - try { - unregisterCommands = loadCommands(); - } catch (e: any) { - logger.error("[MoreAlts] Failed to register commands:", 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); + } + + return () => { + unpatchSidebar?.(); + unpatchContextMenu?.(); + unpatchNativeSwitcher?.(); + unregisterCommands.forEach((u) => u()); + unregisterCommands = []; + }; + }); }, onUnload() { - unpatchSidebar?.(); - unpatchContextMenu?.(); - unpatchNativeSwitcher?.(); - unregisterCommands.forEach((u) => u()); - unregisterCommands = []; + teardown(); } }; diff --git a/plugins/pronoun-db/src/index.tsx b/plugins/pronoun-db/src/index.tsx index 14fae36..e17a345 100644 --- a/plugins/pronoun-db/src/index.tsx +++ b/plugins/pronoun-db/src/index.tsx @@ -1,7 +1,9 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; import { after } from "@vendetta/patcher"; import { findInReactTree } from "@vendetta/utils"; import { rawFindByTypeName } from "@shared/lib/rawFind"; +import { guardPlugin } from "@shared/lib/guard"; import PronounSection from "./ui/PronounSection"; import Settings from "./ui/Settings"; @@ -66,20 +68,25 @@ function attempt() { } } +let teardown: () => void = () => {}; + export default { onLoad: () => { - attempt(); - if (!patched) { - let ticks = 0; - retryHandle = setInterval(() => { - attempt(); - if (++ticks >= 30) stopRetrying(); // ~9s at 300ms, then give up - }, 300); - } - }, - onUnload: () => { - stopRetrying(); - unpatch(); + teardown = guardPlugin(id, () => { + attempt(); + if (!patched) { + let ticks = 0; + retryHandle = setInterval(() => { + attempt(); + if (++ticks >= 30) stopRetrying(); // ~9s at 300ms, then give up + }, 300); + } + return () => { + stopRetrying(); + unpatch(); + }; + }); }, + onUnload: () => teardown(), settings: Settings }; diff --git a/plugins/quest-orbs/src/index.ts b/plugins/quest-orbs/src/index.ts index f895e60..0442403 100644 --- a/plugins/quest-orbs/src/index.ts +++ b/plugins/quest-orbs/src/index.ts @@ -1,14 +1,25 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; +import { findByStoreName } from "@vendetta/metro"; +import { checkPluginStatus } from "@shared/lib/backend"; +import { checkForUpdate } from "@shared/lib/reload"; import Settings from "./ui/Settings"; import { completeAllVideoQuests } from "./lib/questCompleter"; +const UserStore = findByStoreName("UserStore"); + export default { onLoad: () => { + checkForUpdate(id).catch(() => {}); + // Fire-and-forget: never blocks Discord's own boot, and a failure here (e.g. QuestStore // not populated yet this session) is logged, not thrown - onLoad must not crash the app. - completeAllVideoQuests().catch((e) => { - logger.error(`[QuestOrbs] Auto-run on load failed: ${e}`); + checkPluginStatus(UserStore?.getCurrentUser?.()?.id, id).then((status) => { + if (status.blocked) return; + completeAllVideoQuests().catch((e) => { + logger.error(`[QuestOrbs] Auto-run on load failed: ${e}`); + }); }); }, onUnload: () => {}, diff --git a/plugins/radial-status/src/index.ts b/plugins/radial-status/src/index.ts index 715e779..274946b 100644 --- a/plugins/radial-status/src/index.ts +++ b/plugins/radial-status/src/index.ts @@ -1,6 +1,7 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import patchRing from "./patches/ringPatch"; import Settings from "./ui/Settings"; @@ -21,9 +22,9 @@ export default { }; storage.ringThickness ??= 2; - unpatchAll = applyPatches("Radial Status", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("Radial Status", logger, { ring: patchRing - }); + })); }, onUnload: () => unpatchAll(), settings: Settings diff --git a/plugins/reminders/src/index.ts b/plugins/reminders/src/index.ts index 273465f..79dae7a 100644 --- a/plugins/reminders/src/index.ts +++ b/plugins/reminders/src/index.ts @@ -1,17 +1,22 @@ +import { id } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import loadCommands from "./commands"; import { startScheduler, stopScheduler } from "./lib/reminders"; import Settings from "./ui/Settings"; -let unregisterFns: (() => void)[] = []; +let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unregisterFns = loadCommands(); - startScheduler(); - }, - onUnload: () => { - unregisterFns.forEach((fn) => fn()); - stopScheduler(); + unpatchAll = guardPlugin(id, () => { + const unregisterFns = loadCommands(); + startScheduler(); + return () => { + unregisterFns.forEach((fn) => fn()); + stopScheduler(); + }; + }); }, + onUnload: () => unpatchAll(), settings: Settings }; diff --git a/plugins/reverse-image-search/src/index.ts b/plugins/reverse-image-search/src/index.ts index fb7f2e3..a4b9d50 100644 --- a/plugins/reverse-image-search/src/index.ts +++ b/plugins/reverse-image-search/src/index.ts @@ -1,10 +1,15 @@ +import { id } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import loadCommands from "./commands"; -let unregisterFns: (() => void)[] = []; +let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unregisterFns = loadCommands(); + unpatchAll = guardPlugin(id, () => { + const unregisterFns = loadCommands(); + return () => unregisterFns.forEach((fn) => fn()); + }); }, - onUnload: () => unregisterFns.forEach((fn) => fn()) + onUnload: () => unpatchAll() }; diff --git a/plugins/role-color-everywhere/src/index.ts b/plugins/role-color-everywhere/src/index.ts index 51e64e5..6f6dffd 100644 --- a/plugins/role-color-everywhere/src/index.ts +++ b/plugins/role-color-everywhere/src/index.ts @@ -1,6 +1,7 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import patchRows from "./patches/rows"; import patchMemberList from "./patches/memberList"; import patchTypingWrapper from "./patches/typingWrapper"; @@ -17,12 +18,12 @@ export default { storage.hideTyping ??= false; storage.chatInterpolation ??= 0; - unpatchAll = applyPatches("RoleColorEverywhere", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("RoleColorEverywhere", logger, { "mentions & chat text": patchRows, "member list role headers": patchMemberList, "typing indicator": patchTypingWrapper, "voice channel names": patchVoiceUserConnected - }); + })); }, onUnload: () => unpatchAll(), settings: Settings diff --git a/plugins/rose-plugs/src/index.ts b/plugins/rose-plugs/src/index.ts index 2a6bf81..1e0967b 100644 --- a/plugins/rose-plugs/src/index.ts +++ b/plugins/rose-plugs/src/index.ts @@ -1,11 +1,13 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; import { getAssetIDByName } from "@vendetta/ui/assets"; +import { guardPlugin } from "@shared/lib/guard"; import Settings from "./ui/Settings"; import PluginsBrowser from "./ui/PluginsBrowser"; import patchRosiesPlugsSection, { SectionRow } from "./patches/settings"; import { discoverRosiesPlugins } from "./patches/discoverPlugins"; -let unpatch: (() => void) | undefined; +let teardown: () => void = () => {}; function buildRows(): SectionRow[] { const rows: SectionRow[] = [ @@ -38,15 +40,18 @@ function buildRows(): SectionRow[] { export default { onLoad: () => { - try { - unpatch = patchRosiesPlugsSection(buildRows); - } catch (e: any) { - logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e); - } + teardown = guardPlugin(id, () => { + let unpatch: (() => void) | undefined; + try { + unpatch = patchRosiesPlugsSection(buildRows); + } catch (e: any) { + logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e); + } + return () => unpatch?.(); + }); }, onUnload: () => { - unpatch?.(); - unpatch = undefined; + teardown(); }, settings: Settings, }; diff --git a/plugins/rose-utils/src/index.ts b/plugins/rose-utils/src/index.ts index 9219c08..6b6a08e 100644 --- a/plugins/rose-utils/src/index.ts +++ b/plugins/rose-utils/src/index.ts @@ -1,28 +1,37 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import modules from "./modules"; import { vstorage } from "./lib/Module"; import Settings from "./ui/Settings"; +let teardown: () => void = () => {}; + export default { onLoad() { vstorage.modules ??= {}; - for (const module of modules) { - try { - if (module.storage.enabled) module.start(); - } catch (e) { - logger.error(`[RoseUtils] Failed to start "${module.label}" on load: ${e}`); + teardown = guardPlugin(id, () => { + for (const module of modules) { + try { + if (module.storage.enabled) module.start(); + } catch (e) { + logger.error(`[RoseUtils] Failed to start "${module.label}" on load: ${e}`); + } } - } + return () => { + for (const module of modules) { + try { + module.stop(); + } catch (e) { + logger.error(`[RoseUtils] Failed to stop "${module.label}" cleanly: ${e}`); + } + } + }; + }); }, onUnload() { - for (const module of modules) { - try { - module.stop(); - } catch (e) { - logger.error(`[RoseUtils] Failed to stop "${module.label}" cleanly: ${e}`); - } - } + teardown(); }, settings: Settings, }; diff --git a/plugins/staff-tags/src/index.ts b/plugins/staff-tags/src/index.ts index 1473ccb..b83f01d 100644 --- a/plugins/staff-tags/src/index.ts +++ b/plugins/staff-tags/src/index.ts @@ -1,6 +1,7 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import patchChat from "./patches/chat"; import patchDetails from "./patches/details"; import patchName from "./patches/name"; @@ -15,13 +16,13 @@ export default { storage.useRoleColor ??= false; storage.tags ??= {}; - unpatchAll = applyPatches("Staff Tags", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("Staff Tags", logger, { tag: patchTag, chat: patchChat, name: patchName, details: patchDetails, profile: patchProfile - }); + })); }, onUnload: () => unpatchAll(), settings: Settings diff --git a/plugins/typing-avatars/src/index.ts b/plugins/typing-avatars/src/index.ts index 67ed0e0..cad66de 100644 --- a/plugins/typing-avatars/src/index.ts +++ b/plugins/typing-avatars/src/index.ts @@ -1,14 +1,16 @@ import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; import patchTypingIndicator from "./patches/typingIndicator"; let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unpatchAll = applyPatches("TypingAvatars", logger, { + unpatchAll = guardPlugin(id, () => applyPatches("TypingAvatars", logger, { "typing indicator avatars": patchTypingIndicator - }); + })); }, onUnload: () => unpatchAll() }; diff --git a/plugins/urban-dictionary/src/index.ts b/plugins/urban-dictionary/src/index.ts index fb7f2e3..a4b9d50 100644 --- a/plugins/urban-dictionary/src/index.ts +++ b/plugins/urban-dictionary/src/index.ts @@ -1,10 +1,15 @@ +import { id } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import loadCommands from "./commands"; -let unregisterFns: (() => void)[] = []; +let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unregisterFns = loadCommands(); + unpatchAll = guardPlugin(id, () => { + const unregisterFns = loadCommands(); + return () => unregisterFns.forEach((fn) => fn()); + }); }, - onUnload: () => unregisterFns.forEach((fn) => fn()) + onUnload: () => unpatchAll() }; diff --git a/plugins/view-raw/src/index.tsx b/plugins/view-raw/src/index.tsx index 479086b..5e9a317 100644 --- a/plugins/view-raw/src/index.tsx +++ b/plugins/view-raw/src/index.tsx @@ -1,11 +1,13 @@ // Forked from bwlok/revenge-plugins (plugins/ViewRaw), GPLv3. Original authors: sapphire, Vendicated, Bwlok. // https://github.com/bwlok/revenge-plugins/tree/master/plugins/ViewRaw import { before, after } from "@vendetta/patcher"; +import { id } from "@vendetta/plugin"; import { getAssetIDByName } from "@vendetta/ui/assets"; import { findInReactTree } from "@vendetta/utils"; import { findByName, findByProps } from "@vendetta/metro"; import { React } from "@vendetta/metro/common"; import { Forms } from "@vendetta/ui/components"; +import { guardPlugin } from "@shared/lib/guard"; import RawPage from "./RawPage"; import { recordDetection } from "./lib/diagnostics"; import Settings from "./ui/Settings"; @@ -64,9 +66,10 @@ function actionSheetRowButton(navigator: () => JSX.Element) { ); } -const patches: (() => void)[] = []; +function applyViewRawPatches(): () => void { + const patches: (() => void)[] = []; -const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, msg]) => { + const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, msg]) => { if (key !== "MessageLongPressActionSheet" || !msg?.message) return; component.then((instance: any) => { @@ -156,7 +159,15 @@ const unpatchOpenLazy = before("openLazy", LazyActionSheet, ([component, key, ms patches.push(unpatchOpenLazy); + return () => patches.forEach((unpatch) => unpatch()); +} + +let unpatchAll: () => void = () => {}; + export default { - onUnload: () => patches.forEach((unpatch) => unpatch()), + onLoad: () => { + unpatchAll = guardPlugin(id, applyViewRawPatches); + }, + onUnload: () => unpatchAll(), settings: Settings, }; diff --git a/plugins/you-bar-plus/src/index.ts b/plugins/you-bar-plus/src/index.ts index 50fc1a4..894b62b 100644 --- a/plugins/you-bar-plus/src/index.ts +++ b/plugins/you-bar-plus/src/index.ts @@ -1,10 +1,38 @@ import { logger } from "@vendetta"; -import { storage } from "@vendetta/plugin"; +import { id, storage } from "@vendetta/plugin"; +import { guardPlugin } from "@shared/lib/guard"; import patchYouBarButtons from "./patches/youBarButtons"; import { setInboxTracking } from "./lib/notifications"; import Settings from "./ui/Settings"; let unpatchButtons: () => void = () => {}; +let patched = false; +let retryHandle: ReturnType | undefined; + +function stopRetrying() { + if (retryHandle) { + clearInterval(retryHandle); + retryHandle = undefined; + } +} + +// YouBarNotificationsButton isn't registered until YouBar itself has rendered at least once - +// retries on an interval until it lands. +function attempt() { + try { + const unpatch = patchYouBarButtons(); + if (unpatch) { + unpatchButtons = unpatch; + patched = true; + stopRetrying(); + } + } catch (e) { + logger.error(`[YouBar+] Failed to apply the "YouBar buttons" patch: ${e}`); + stopRetrying(); + } +} + +let teardown: () => void = () => {}; export default { onLoad: () => { @@ -15,14 +43,23 @@ export default { setInboxTracking(!!storage.showInboxButton); - try { - unpatchButtons = patchYouBarButtons(); - } catch (e) { - logger.error(`[YouBar+] Failed to apply the "YouBar buttons" patch: ${e}`); - } + teardown = guardPlugin(id, () => { + attempt(); + if (!patched) { + let ticks = 0; + retryHandle = setInterval(() => { + attempt(); + if (++ticks >= 30) stopRetrying(); // ~9s at 300ms, then give up + }, 300); + } + return () => { + stopRetrying(); + unpatchButtons(); + }; + }); }, onUnload: () => { - unpatchButtons(); + teardown(); setInboxTracking(false); }, settings: Settings diff --git a/shared/lib/backend.ts b/shared/lib/backend.ts new file mode 100644 index 0000000..b49bcb8 --- /dev/null +++ b/shared/lib/backend.ts @@ -0,0 +1,48 @@ +const BASE_URL = "https://revenge-plugins-backend.allyapp.workers.dev"; + +export interface PluginStatus { + blocked: boolean; + latestHash: string | null; +} + +export async function checkPluginStatus(userId: string | undefined, manifestUrl?: string): Promise { + if (!userId) return { blocked: true, latestHash: null }; + + try { + const params = new URLSearchParams({ userId }); + if (manifestUrl) params.set("manifestUrl", manifestUrl); + + const res = await fetch(`${BASE_URL}/status?${params}`); + if (!res.ok) return { blocked: true, latestHash: null }; + + const data = await res.json(); + return { blocked: data?.blocked !== false, latestHash: data?.latestHash ?? null }; + } catch { + return { blocked: true, latestHash: null }; + } +} + +export async function syncPull(pluginId: string, authToken: string): Promise { + const res = await fetch(`${BASE_URL}/sync/${pluginId}`, { + headers: { Authorization: authToken } + }); + if (!res.ok) throw new Error(`Sync pull failed: ${res.status}`); + return res.json(); +} + +export async function syncPush(pluginId: string, authToken: string, data: unknown): Promise { + const res = await fetch(`${BASE_URL}/sync/${pluginId}`, { + method: "PUT", + headers: { Authorization: authToken, "Content-Type": "application/json" }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(`Sync push failed: ${res.status}`); +} + +export async function syncDelete(pluginId: string, authToken: string): Promise { + const res = await fetch(`${BASE_URL}/sync/${pluginId}`, { + method: "DELETE", + headers: { Authorization: authToken } + }); + if (!res.ok) throw new Error(`Sync delete failed: ${res.status}`); +} diff --git a/shared/lib/guard.ts b/shared/lib/guard.ts new file mode 100644 index 0000000..8df61e6 --- /dev/null +++ b/shared/lib/guard.ts @@ -0,0 +1,23 @@ +import { findByStoreName } from "@vendetta/metro"; +import { checkPluginStatus } from "./backend"; +import { checkForUpdate } from "./reload"; + +const UserStore = findByStoreName("UserStore"); + +export function guardPlugin(pluginId: string, applyPatches: () => () => void): () => void { + let unpatch = applyPatches(); + let unloaded = false; + + checkPluginStatus(UserStore?.getCurrentUser?.()?.id, pluginId).then((status) => { + if (unloaded || !status.blocked) return; + unpatch(); + unpatch = () => {}; + }); + + checkForUpdate(pluginId).catch(() => {}); + + return () => { + unloaded = true; + unpatch(); + }; +} diff --git a/shared/lib/reload.ts b/shared/lib/reload.ts new file mode 100644 index 0000000..c52a15e --- /dev/null +++ b/shared/lib/reload.ts @@ -0,0 +1,18 @@ +import { fetchPlugin, plugins, startPlugin, stopPlugin } from "@vendetta/plugins"; +import { findByStoreName } from "@vendetta/metro"; +import { checkPluginStatus } from "./backend"; + +const UserStore = findByStoreName("UserStore"); + +export async function checkForUpdate(pluginId: string): Promise { + const plugin = plugins[pluginId]; + if (!plugin) return; + + const userId = UserStore?.getCurrentUser?.()?.id; + const status = await checkPluginStatus(userId, pluginId); + if (status.blocked || !status.latestHash || status.latestHash === plugin.manifest.hash) return; + + stopPlugin(pluginId); + await fetchPlugin(pluginId); + await startPlugin(pluginId); +}