Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
Rosie Val
f28eba295c Update & reload from startup update alert 2026-08-09 00:58:55 -04:00
Rosie Val
a7385a58df Add startup update-confirmation alert with plugin details 2026-08-09 00:54:54 -04:00
Rosie Val
2cf998542f Add per-plugin Check for updates row 2026-08-09 00:41:35 -04:00
Rosie Val
b6e5cdb318 Re-add fork-only host-bundle workflow 2026-08-09 00:15:26 -04:00
Rosie Val
6ae7b9a3b1 Remove fork-only host-bundle workflow 2026-08-09 00:13:44 -04:00
Rosie Val
bb7b480e6b Emit errored events for plugin native and JS errors 2026-08-08 23:27:35 -04:00
Rosie Val
efb6eb1801 Add fallback FlashList fingerprints without ImportTracker 2026-08-08 23:19:31 -04:00
Rosie Val
df891b938c Add fallback fingerprint for main_tabs_v2 without ImportTracker 2026-08-08 22:07:10 -04:00
Rosie Val
366936db8c Add Rosie to contributors 2026-08-08 22:05:27 -04:00
Rosie Val
c848cec45b Fix PluginStateProvider import path 2026-08-08 21:53:02 -04:00
Rosie Val
b520419d43 Add manual restore default repositories button 2026-08-08 21:37:27 -04:00
Rosie Val
1e446bf8df Remove TODO now that default repo restore has UI 2026-08-08 21:37:26 -04:00
Rosie Val
48880cb8a2 Compute and apply user-selected release channel in browse 2026-08-08 21:36:15 -04:00
Rosie Val
d2cef996fd Add release channel picker to browse filter sheet 2026-08-08 21:36:14 -04:00
Rosie Val
b8453d7cfd Use repo removal confirmation when deleting repositories 2026-08-08 21:30:35 -04:00
Rosie Val
5ef88bd5e8 Wire up repo removal confirmation alert 2026-08-08 21:30:24 -04:00
Rosie Val
baca849042 Add repo removal confirmation alert component 2026-08-08 21:30:23 -04:00
Rosie Val
d8b0d5971a Remove (TODO) labels and add dependency/dependent popup menus 2026-08-08 21:04:20 -04:00
Rosie Val
be54a06e31 Add PluginListActionSheet for dependency and dependent popups 2026-08-08 21:02:10 -04:00
Rosie Val
ec51cbedcb Add bundle hosting workflow for test instances 2026-08-08 20:34:46 -04:00
15 changed files with 504 additions and 31 deletions

54
.github/workflows/host-bundle.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Host Revenge Bundle
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build-and-host:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Restore Bun cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile --production
- name: Build production bundle
run: bun run build
- name: Publish bundle to hosting branch
run: |
cp dist/revenge.bundle /tmp/revenge.bundle
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git show-ref --verify --quiet "refs/heads/hosting"; then
git checkout hosting
git rm -rf .
else
git checkout --orphan hosting
fi
rm -rf *
cp /tmp/revenge.bundle ./revenge.bundle
git add revenge.bundle
git commit -m "Host bundle from ${GITHUB_SHA}" || true
git push --force origin hosting

5
CONTRIBUTORS.md Normal file
View file

@ -0,0 +1,5 @@
# Contributors
Thanks to everyone who has helped make Revenge Next better!
- Rosie (ressurexitcalcator)

View file

@ -29,9 +29,18 @@ export let RootNavigationRef: RootNavigationRef = proxify(
2,
),
]),
// TODO: Decouple?
ImportTrackerModuleId,
]),
]).or(
withDependencies([
loose([
relative.withDependencies([], 1),
relative.withDependencies(
loose([relative(1), relative(2)]),
2,
),
]),
]),
),
)
.keyAs('revenge.discord.modules.mainTabsV2.RootNavigationRef'),
)

View file

@ -25,7 +25,6 @@ export let FlashList: typeof import('@shopify/flash-list') = proxify(
null,
null,
null,
// TODO: Decouple?
ImportTrackerModuleId,
]).or(
// TODO: Remove when stable > 340206+
@ -38,9 +37,32 @@ export let FlashList: typeof import('@shopify/flash-list') = proxify(
null,
null,
null,
// TODO: Decouple?
ImportTrackerModuleId,
]),
).or(
withDependencies([
ReactModuleId,
ReactNativeModuleId,
ReactJSXRuntimeModuleId,
null,
null,
null,
null,
null,
null,
]),
).or(
// [React, JSXRuntime, (Platform), (FlashListExports), (Reanimated), (RNBottomSheet), (BottomSheet)]
withDependencies([
ReactModuleId,
ReactJSXRuntimeModuleId,
null,
null,
null,
null,
null,
null,
]),
),
)
.keyAs('revenge.externals.Shopify.FlashList'),

View file

@ -309,8 +309,7 @@ registerJSMethod(
const meta = getInternalPluginMeta(plugin)
meta.nativeErrors = Object.freeze(errors)
// TODO: Do we need an errored event specifically?
// Nudge open UI to re-render the errors row
pEmitter.emit('errored', plugin, errors)
pEmitter.emit('flagUpdate', plugin)
},
)
@ -577,9 +576,7 @@ export function isPluginStartable(plugin: AnyPlugin): boolean {
*/
export async function handlePluginError(e: unknown, plugin: AnyPlugin) {
;(plugin.errors as unknown[]).push(e)
// TODO: Emit errored event so UI can update?
// Update: errored event removed (but status changes are still emitted), so UI should update fine
pEmitter.emit('errored', plugin, e)
nativeLoggingHook(
`\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${

View file

@ -160,6 +160,8 @@ export interface RepoUpdate {
installed: string
available: string
channel: string
/** Repository URL this update was found in, added by {@link listAllUpdates}. */
repo?: string
}
/**
@ -227,7 +229,7 @@ export async function listAllUpdates(): Promise<{
.map(repo =>
listUpdates(repo.url).then(
result => {
updates.push(...result)
updates.push(...result.map(u => ({ ...u, repo: repo.url })))
},
error => {
errors.push({ url: repo.url, error })

View file

@ -32,6 +32,11 @@ export interface BrowseFilterAndSortActionSheetProps {
/** Checked repository URLs. */
checked: string[]
setChecked: (urls: string[]) => void
/** Available release channels across all listings. */
channels: string[]
/** Selected release channel. Empty string means default. */
channel: string
setChannel: (channel: string) => void
sort: BrowseSortKey
setSort: (sort: BrowseSortKey) => void
reverse: boolean
@ -42,6 +47,9 @@ export default function BrowseFilterAndSortActionSheet({
repos,
checked,
setChecked,
channels,
channel,
setChannel,
sort,
setSort,
reverse,
@ -76,6 +84,19 @@ export default function BrowseFilterAndSortActionSheet({
)
})}
</TableRowGroup>
<TableRadioGroup
title="Release channel"
defaultValue={channel}
onChange={v => setChannel(v as string)}
>
<TableRadioRow
label="Default (latest / first available)"
value=""
/>
{channels.map(c => (
<TableRadioRow key={c} label={c} value={c} />
))}
</TableRadioGroup>
<TableRadioGroup
title="Sort by"
defaultValue={sort}

View file

@ -0,0 +1,33 @@
import { TableRowAssetIcon } from '@revenge-mod/components'
import { Design } from '@revenge-mod/discord/design'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { ActionSheet, Stack, TableRow, TableRowGroup } = Design
export interface PluginListActionSheetProps {
title: string
plugins: AnyPlugin[]
sheetKey: string
}
export default function PluginListActionSheet({
title,
plugins,
}: PluginListActionSheetProps) {
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
<TableRowGroup title={title}>
{plugins.map(plugin => (
<TableRow
key={plugin.manifest.id}
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label={plugin.manifest.name}
subLabel={plugin.manifest.id}
/>
))}
</TableRowGroup>
</Stack>
</ActionSheet>
)
}

View file

@ -22,7 +22,14 @@ import {
runPluginLate,
stopPlugin,
} from '@revenge-mod/plugins/_'
import { listRepos } from '@revenge-mod/plugins/_/repositories'
import {
installFromRepo,
listRepos,
listUpdates,
planInstall,
refreshRepo,
} from '@revenge-mod/plugins/_/repositories'
import type { RepoUpdate } from '@revenge-mod/plugins/_/repositories'
import { PluginStatus } from '@revenge-mod/plugins/constants'
import { formatVersion } from '@revenge-mod/plugins/utils'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
@ -34,7 +41,7 @@ import {
showPluginClearDataConfirmation,
showPluginUninstallConfirmation,
} from '../utils/alerts'
import { messageOf, showErrorToast } from '../utils/repos'
import { confirmPlan, messageOf, showErrorToast } from '../utils/repos'
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
import {
@ -117,7 +124,7 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
<TableRowGroup title="Status">
<TableRow
icon={<TableRowAssetIcon name="CircleInformationIcon" />}
label="Status (TODO)"
label="Status"
subLabel={bitFieldToString(PluginStatus, status)}
/>
{errors.length > 0 && (
@ -143,6 +150,85 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
)
}
function UpdateRow({ plugin }: { plugin: AnyPlugin }) {
const [busy, setBusy] = useState(false)
const [checked, setChecked] = useState(false)
const [update, setUpdate] = useState<RepoUpdate | null>(null)
const meta = getInternalPluginMeta(plugin)
const repoUrl = meta.source?.repo
if (isPluginInternal(meta) || !repoUrl) return null
const check = async () => {
setBusy(true)
try {
await refreshRepo(repoUrl)
const updates = await listUpdates(repoUrl)
const found =
updates.find(u => u.id === plugin.manifest.id) ?? null
setUpdate(found)
setChecked(true)
} catch (e) {
showErrorToast(messageOf(e))
} finally {
setBusy(false)
}
}
const doUpdate = async () => {
if (!update) return
setBusy(true)
try {
const plan = await planInstall(
update.id,
undefined,
update.channel,
)
const accepted = await confirmPlan(plan)
if (!accepted) return
await installFromRepo(plan)
setUpdate(null)
setChecked(true)
ToastActionCreators.open({
key: 'revenge-plugin-update-pending',
content: 'Update downloaded. Reload to apply.',
})
} catch (e) {
showErrorToast(messageOf(e))
} finally {
setBusy(false)
}
}
const icon = update
? 'DownloadIcon'
: checked
? 'CircleCheckIcon'
: 'RefreshIcon'
const label = update
? 'Update'
: checked
? 'Up to date'
: 'Check for updates'
const subLabel = update
? `${update.installed} → ${update.available} (${update.channel})`
: checked
? 'This plugin is on the latest available version'
: 'Tap to refresh this plugin\'s repository'
return (
<TableRow
icon={<TableRowAssetIcon name={icon} />}
label={label}
subLabel={subLabel}
onPress={update ? doUpdate : check}
disabled={busy}
/>
)
}
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
const meta = getInternalPluginMeta(plugin)
const dependents = getPluginDependents(plugin, true)
@ -152,6 +238,7 @@ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
return (
<TableRowGroup title="Advanced">
<UpdateRow plugin={plugin} />
<IdRow id={id} />
<RepositoryRow
text={repositoryText}
@ -175,15 +262,37 @@ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
{dependencies.length > 0 && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label="Dependencies (TODO)"
label="Dependencies"
subLabel={`${name} depends on ${dependencies.length} other plugins`}
onPress={() => {
ActionSheetActionCreators.openLazy(
import('./PluginListActionSheet'),
`plugin-deps-${id}`,
{
title: `Dependencies of ${name}`,
plugins: dependencies,
sheetKey: `plugin-deps-${id}`,
},
)
}}
/>
)}
{dependents.length > 0 && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label="Dependents (TODO)"
label="Dependents"
subLabel={`${dependents.length} other plugins depend on ${name}`}
onPress={() => {
ActionSheetActionCreators.openLazy(
import('./PluginListActionSheet'),
`plugin-dependents-${id}`,
{
title: `Dependents of ${name}`,
plugins: dependents,
sheetKey: `plugin-dependents-${id}`,
},
)
}}
/>
)}
</TableRowGroup>

View file

@ -0,0 +1,112 @@
import { Design } from '@revenge-mod/discord/design'
import {
listRepoPlugins,
type RepoPluginListing,
type RepoUpdate,
} from '@revenge-mod/plugins/_/repositories'
import { useEffect, useState } from 'react'
import { View } from 'react-native'
const { AlertModal, AlertActionButton, Text } = Design
interface UpdateDetail {
id: string
name: string
author: string
installed: string
available: string
channel: string
}
export default function PluginUpdatesFoundAlert({
updates,
action,
}: {
updates: RepoUpdate[]
action: () => Promise<void>
}) {
const [details, setDetails] = useState<UpdateDetail[] | null>(null)
useEffect(() => {
const repoMap = new Map<string, RepoUpdate[]>()
for (const update of updates) {
const repo = update.repo!
if (!repoMap.has(repo)) repoMap.set(repo, [])
repoMap.get(repo)!.push(update)
}
const allDetails: UpdateDetail[] = []
Promise.all(
[...repoMap.entries()].map(async ([repo, repoUpdates]) => {
let listings: RepoPluginListing[]
try {
listings = await listRepoPlugins(repo)
} catch {
listings = []
}
for (const update of repoUpdates) {
const listing = listings.find(l => l.id === update.id)
allDetails.push({
id: update.id,
name: listing?.name ?? update.id,
author: listing?.author ?? 'Unknown',
installed: update.installed,
available: update.available,
channel: update.channel,
})
}
}),
).then(() => setDetails(allDetails))
}, [updates])
return (
<AlertModal
title="Updates Found"
content={
<Text color="text-default">
The following plugins have updates available:
</Text>
}
extraContent={
<View style={{ paddingTop: 8 }}>
{details === null ? (
<Text color="text-muted">Loading...</Text>
) : (
details.map(detail => (
<View
key={detail.id}
style={{ marginBottom: 12 }}
>
<Text
variant="text-md/semibold"
color="text-default"
>
{detail.name}
</Text>
<Text color="text-muted">
{detail.installed} → {detail.available} (
{detail.channel})
</Text>
<Text color="text-muted">
by {detail.author}
</Text>
</View>
))
)}
</View>
}
actions={
<>
<AlertActionButton
onPress={action}
text="Update & Reload"
variant="primary"
/>
<AlertActionButton text="Not now" variant="secondary" />
</>
}
/>
)
}

View file

@ -0,0 +1,37 @@
import { Design } from '@revenge-mod/discord/design'
import type { Repo } from '@revenge-mod/plugins/_/repositories'
const { AlertModal, AlertActionButton, Text } = Design
export default function RepoRemoveConfirmationAlert({
repo,
action,
}: {
repo: Repo
action: () => Promise<void> | void
}) {
return (
<AlertModal
title="Remove repository?"
content={
<Text color="text-default">
<Text variant="text-md/semibold" color="text-default">
{repo.name ?? repo.url}
</Text>{' '}
will be removed. You can restore the default repositories by
clearing Plugin Settings data.
</Text>
}
actions={
<>
<AlertActionButton
onPress={action}
text="Remove"
variant="destructive"
/>
<AlertActionButton text="Cancel" variant="secondary" />
</>
}
/>
)
}

View file

@ -1,18 +1,22 @@
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
import { JsonStorageUpdateMode } from '@revenge-mod/json-storage'
import { reloadApp } from '@revenge-mod/modules/native/app'
import {
InternalPluginFlags,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import {
listAllUpdates,
refreshAllRepos,
updateAllPlugins,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import pluginSettings from '../settings'
import { showPluginUpdatesFoundAlert } from './utils/alerts'
import { addDefaultRepoIfNeeded } from './repos'
import { showErrorToast } from './utils/repos'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { PluginApi } from '@revenge-mod/plugins/types'
@ -78,12 +82,34 @@ function autoUpdateService(settings: Storage) {
setTimeout(async () => {
try {
const { errors } = await refreshAllRepos()
await updateAllPlugins()
if (!errors.length)
await api.jsonStorage.set({
lastUpdateCheck: Date.now(),
if (errors.length) {
ToastActionCreators.open({
key: 'PLUGIN_UPDATE_CHECK_FAILED',
content: 'Failed to refresh some plugin repositories',
IconComponent: CircleXIconComponent,
})
return
}
const { updates } = await listAllUpdates()
await api.jsonStorage.set({ lastUpdateCheck: Date.now() })
if (!updates.length) return
showPluginUpdatesFoundAlert(updates, async () => {
const { errors: updateErrors } = await updateAllPlugins()
if (updateErrors.length) {
showErrorToast(
updateErrors
.map(e => `${e.id}: ${e.error}`)
.join('\n'),
)
} else {
reloadApp()
}
})
} catch (e) {
ToastActionCreators.open({
key: 'PLUGIN_UPDATE_CHECK_FAILED',
@ -96,7 +122,6 @@ function autoUpdateService(settings: Storage) {
}, AUTO_UPDATE_CHECK_DELAY)
}
// TODO: Add UI for this?
function defaultRepoRestoreService(
settings: Storage,
storage: JsonStorage<Storage>,

View file

@ -20,8 +20,9 @@ import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { useCallback, useEffect, useState } from 'react'
import { ScrollView, View } from 'react-native'
import { api } from '..'
import { toConfig } from '../repos'
import { addDefaultRepoIfNeeded, toConfig } from '../repos'
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
import { showRemoveRepoConfirmation } from '../utils/alerts'
import type {
DownloadProgressEvent,
Repo,
@ -101,7 +102,7 @@ function UserRepoRow({
label: 'Delete',
IconComponent: TrashIconComponent,
variant: 'destructive' as const,
action: () => onRemove(repo),
action: () => showRemoveRepoConfirmation(repo, () => onRemove(repo)),
},
],
]
@ -212,7 +213,6 @@ export default function RevengePluginsAdvancedSettingScreen() {
const removeRepo = useCallback(
async (repo: Repo) => {
// TODO: Add warning for default repo removal, user can clear "Plugin Settings" data to restore the default repo
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
@ -370,6 +370,16 @@ export default function RevengePluginsAdvancedSettingScreen() {
)
}
/>
<TableRow
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
label="Restore default repositories"
subLabel="Add the bundled default repository if it's missing"
onPress={() => {
addDefaultRepoIfNeeded().catch(e =>
showErrorToast(messageOf(e)),
)
}}
/>
</TableRowGroup>
</Stack>
</ScrollView>

View file

@ -49,9 +49,9 @@ export default function RevengePluginsBrowseSettingScreen() {
)
}
// TODO: Let the user pick a release channel
/** The channel a listing displays: `latest`, else its first channel. */
function displayChannelOf(listing: RepoPluginListing): string | undefined {
/** The channel a listing displays: preferred, `latest`, else its first channel. */
function displayChannelOf(listing: RepoPluginListing, preferredChannel: string): string | undefined {
if (preferredChannel && listing.channels[preferredChannel]) return preferredChannel
if (listing.channels.latest) return 'latest'
return Object.keys(listing.channels)[0]
}
@ -82,15 +82,18 @@ function Screen() {
const [excluded, setExcluded] = useState<string[]>([])
const [sort, setSort] = useState<BrowseSortKey>('name')
const [reverse, setReverse] = useState(false)
const [channel, setChannel] = useState('')
const [channels, setChannels] = useState<string[]>([])
const hasFilter = useMemo(
() => excluded.length > 0 || sort !== 'name' || reverse,
[excluded, sort, reverse],
() => excluded.length > 0 || sort !== 'name' || reverse || channel !== '',
[excluded, sort, reverse, channel],
)
const load = useCallback(async () => {
const repos = await listRepos()
const all: BrowseEntry[] = []
const allChannels = new Set<string>()
setInternalRepos(
repos.filter(repo => repo.internal).map(repo => repo.url),
@ -108,7 +111,9 @@ function Screen() {
for (const listing of listings) {
const plugin = pList.get(listing.id)
const displayChannel = displayChannelOf(listing)
for (const c of Object.keys(listing.channels))
allChannels.add(c)
const displayChannel = displayChannelOf(listing, channel)
const displayVersion = displayChannel
? (listing.channels[displayChannel] ?? '')
: ''
@ -119,7 +124,6 @@ function Screen() {
repoName: repo.name ?? null,
repositoryText,
version: displayVersion,
// TODO: Let the user pick a release channel
channel: displayChannel,
size:
listing.versions[displayVersion]
@ -139,8 +143,9 @@ function Screen() {
),
)
setChannels([...allChannels].sort())
setEntries(all)
}, [])
}, [channel, setChannels])
useEffect(() => {
// Show cached indexes right away, then refresh everything
@ -234,6 +239,9 @@ function Screen() {
.map(repo => repo.url)
.filter(url => !urls.includes(url)),
),
channels,
channel,
setChannel,
sort,
setSort,
reverse,

View file

@ -13,6 +13,9 @@ import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
import PluginMissingDependenciesAlert from '../components/PluginMissingDependenciesAlert'
import PluginStatesProvider from '../components/PluginStateProvider'
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
import PluginUpdatesFoundAlert from '../components/PluginUpdatesFoundAlert'
import RepoRemoveConfirmationAlert from '../components/RepoRemoveConfirmationAlert'
import type { Repo, RepoUpdate } from '@revenge-mod/plugins/_/repositories'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export function showPluginClearDataConfirmation(
@ -100,6 +103,22 @@ export function showPluginMissingDependenciesAlert(
)
}
export function showRemoveRepoConfirmation(
repo: Repo,
callback: () => Promise<void> | void,
) {
const KEY = 'repo-remove-confirmation'
async function action() {
await callback()
}
AlertActionCreators.openAlert(
KEY,
<RepoRemoveConfirmationAlert repo={repo} action={action} />,
)
}
export function showPluginHasDependentsAlert(
plugin: AnyPlugin,
dependents: AnyPlugin[],
@ -116,3 +135,13 @@ export function showPluginHasDependentsAlert(
</PluginStatesProvider>,
)
}
export function showPluginUpdatesFoundAlert(
updates: RepoUpdate[],
action: () => Promise<void>,
) {
AlertActionCreators.openAlert(
'plugin-updates-found',
<PluginUpdatesFoundAlert updates={updates} action={action} />,
)
}