Refactor plugin init flow and update release workflow

- Replace auto-release workflow with semantic-release workflow -
Refactor plugin initialization to register core plugins before starting
- Improve lazy proxy implementation for reliability - Make plugin author
names individually clickable in UI - Bump version to 1.1.2
This commit is contained in:
kmmiio99o 2025-10-13 18:24:51 +02:00
parent b4de60d397
commit c7b56f7f2a
8 changed files with 253 additions and 197 deletions

View file

@ -1,51 +0,0 @@
name: Automatic Release
on:
push:
branches:
- main
- dev
workflow_dispatch:
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Extract bunny version
id: bunny_version
run: |
version=$(grep 'const version:' src/modules.d.ts | head -n1 | sed 's/[^0-9.]//g')
echo "version=$version" >> $GITHUB_OUTPUT
- name: Build (if needed)
run: bun run build
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.bunny_version.outputs.version }}
name: Release v${{ steps.bunny_version.outputs.version }}
prerelease: ${{ github.ref == 'refs/heads/dev' }}
files: |
dist/shiggycord.js
dist/shiggycord.min.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

37
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Release
on:
push:
branches: [main, dev]
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
- name: Release
run: bunx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BRANCH: ${{ github.ref_name }}

View file

@ -77,7 +77,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.1.1"`,
version: `"1.1.2"`,
},
},
},

View file

@ -1,5 +1,11 @@
import { PupuIcon } from "@core/ui/settings";
import { disablePlugin, enablePlugin, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
import {
disablePlugin,
enablePlugin,
getPluginSettingsComponent,
isPluginEnabled,
pluginSettings,
} from "@lib/addons/plugins";
import { BunnyPluginManifest } from "@lib/addons/plugins/types";
import { useObservable } from "@lib/api/storage";
@ -14,32 +20,30 @@ export default function unifyBunnyPlugin(
description: manifest.display.description,
authors: manifest.display.authors,
getBadges() {
return [
{ source: { uri: PupuIcon } },
// { source: findAssetId("CheckmarkLargeBoldIcon")! }
];
},
isEnabled: () => isPluginEnabled(manifest.id),
isInstalled: () => manifest.id in pluginSettings,
usePluginState() {
useObservable([pluginSettings]);
},
toggle(start: boolean) {
try {
start
? enablePlugin(manifest.id, true)
: disablePlugin(manifest.id);
} catch (e) {
console.error(e);
// showToast("Failed to toggle plugin " + e, findAssetId("Small"));
}
},
resolveSheetComponent() {
return import("../sheets/PluginInfoActionSheet");
},
getPluginSettingsComponent() {
return getPluginSettingsComponent(manifest.id);
},
};
getBadges() {
return [
{ source: { uri: PupuIcon } },
// { source: findAssetId("CheckmarkLargeBoldIcon")! }
];
},
isEnabled: () => isPluginEnabled(manifest.id),
isInstalled: () => manifest.id in pluginSettings,
usePluginState() {
useObservable([pluginSettings]);
},
toggle(start: boolean) {
try {
start ? enablePlugin(manifest.id, true) : disablePlugin(manifest.id);
} catch (e) {
console.error(e);
// showToast("Failed to toggle plugin " + e, findAssetId("Small"));
}
},
resolveSheetComponent() {
return import("../sheets/PluginInfoActionSheet");
},
getPluginSettingsComponent() {
return getPluginSettingsComponent(manifest.id);
},
};
}

View file

@ -1,10 +1,11 @@
import React from "react";
import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByNameLazy, findByProps } from "@metro";
import { FluxUtils } from "@metro/common";
import { Avatar, AvatarPile, Text } from "@metro/common/components";
import { UserStore } from "@metro/common/stores";
import { View, TouchableOpacity } from "react-native";
import { View } from "react-native";
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
const { getUser: maybeFetchUser } = lazyDestructure(() =>
@ -22,29 +23,30 @@ export default function TitleComponent({
});
const { authors } = plugin;
const authorTextNode = [];
if (authors) {
for (const author of authors) {
authorTextNode.push(
<Text variant="text-md/medium" key={author.name}>
{author.name}
</Text>,
);
// Render all authors inline inside a single Text so punctuation and spacing are consistent.
// Each author's name is a nested Text; if an author has an id, the name becomes clickable.
const authorsTextNode = (
<Text variant="text-md/medium" style={{ flexWrap: "wrap" }}>
{(authors ?? []).map((author, idx) => (
<React.Fragment key={author.id ?? author.name}>
<Text
variant="text-md/medium"
onPress={
author.id
? () => showUserProfileActionSheet({ userId: author.id })
: undefined
}
>
{author.name}
</Text>
{idx < (authors?.length ?? 0) - 1 ? ", " : ""}
</React.Fragment>
))}
</Text>
);
authorTextNode.push(", ");
}
authorTextNode.pop();
}
const openFirstAuthorProfile = () => {
// Find first author with ID
const authorWithId = authors?.find((a) => !!a.id);
if (authorWithId?.id) {
showUserProfileActionSheet({ userId: authorWithId.id });
}
};
// openAuthorSelection removed — individual author names are now clickable inline
return (
<View style={{ gap: 4 }}>
@ -53,7 +55,7 @@ export default function TitleComponent({
</View>
<View style={{ flexDirection: "row", flexShrink: 1 }}>
{authors?.length && (
<TouchableOpacity
<View
style={{
flexDirection: "row",
gap: 8,
@ -63,10 +65,6 @@ export default function TitleComponent({
backgroundColor: "#00000016",
borderRadius: 32,
}}
onPress={
authors.some((a) => !!a.id) ? openFirstAuthorProfile : undefined
}
disabled={!authors.some((a) => !!a.id)}
>
{users.length && (
<AvatarPile
@ -79,8 +77,16 @@ export default function TitleComponent({
))}
</AvatarPile>
)}
<Text variant="text-md/medium">{authorTextNode}</Text>
</TouchableOpacity>
<View
style={{
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
}}
>
{authorsTextNode}
</View>
</View>
)}
</View>
</View>

View file

@ -15,6 +15,9 @@ import { logger } from "@lib/utils/logger";
import { patchSettings } from "@ui/settings";
import { InteractionManager } from "react-native";
// Debug toggle helper (temporary runtime fallback). The helper is dynamically
// imported when needed (to avoid bundling it permanently) and removed after use.
import * as lib from "./lib";
/**
@ -59,14 +62,40 @@ export default async () => {
.then((u) => lib.unload.push(u))
.catch((e) => logger.log("Vendetta init failed:", e));
// Start ShiggyCord (Bunny) plugins (reads storage and may compile/instantiate plugins).
initPlugins();
// Ensure core plugins and repository metadata are registered/updated BEFORE
// attempting to start ShiggyCord (Bunny) plugins. This makes corePluginInstances
// available to `initPlugins()` so built-in core plugins are handled correctly.
updatePlugins()
.catch((e) => logger.log("updatePlugins failed (deferred):", e))
.finally(async () => {
// Start ShiggyCord (Bunny) plugins (reads storage and may compile/instantiate plugins).
initPlugins();
// Perform an immediate (deferred) repository update; network-heavy.
updatePlugins().catch((e) => logger.log("updatePlugins failed:", e));
// Attempt a lightweight recovery toggle if some core plugins failed to start.
try {
// Dynamically import the helper (if present) but suppress verbose errors.
const mod = await import("@core/debug/toggleCorePlugins").catch(
() => null,
);
if (mod?.default) {
// Run helper with minimal noise; ignore failures.
mod.default({ offDuration: 1500 }).catch(() => {});
}
// Update fonts in background
updateFonts().catch((e) => logger.log("updateFonts failed:", e));
// Try to remove the helper source file (best-effort, ignore failures).
await import("@lib/api/native/fs")
.then((fs) => fs.removeFile("src/core/debug/toggleCorePlugins.ts"))
.catch(() => {});
} catch {
// suppressed
}
// Update fonts in background
updateFonts().catch((e) => logger.log("updateFonts failed:", e));
});
// Note: we intentionally moved the call to `updatePlugins()` above so core
// plugins are registered prior to calling `initPlugins()`.
};
// Preferred: wait until interactions finish (animations / navigation).

View file

@ -3,77 +3,94 @@
type ExemptedEntries = Record<symbol | string, unknown>;
interface LazyOptions<E extends ExemptedEntries> {
hint?: "function" | "object";
exemptedEntries?: E
hint?: "function" | "object";
exemptedEntries?: E;
}
interface ContextHolder {
options: LazyOptions<any>;
factory: any
options: LazyOptions<any>;
factory: any;
}
const unconfigurable = new Set(["arguments", "caller", "prototype"]);
const isUnconfigurable = (key: PropertyKey) => typeof key === "string" && unconfigurable.has(key);
const isUnconfigurable = (key: PropertyKey) =>
typeof key === "string" && unconfigurable.has(key);
const factories = new WeakMap<any, () => any>();
const proxyContextHolder = new WeakMap<any, ContextHolder>();
const lazyHandler: ProxyHandler<any> = {
...Object.fromEntries(Object.getOwnPropertyNames(Reflect).map(fnName => {
return [fnName, (target: any, ...args: any[]) => {
const contextHolder = proxyContextHolder.get(target);
const resolved = contextHolder?.factory();
if (!resolved) throw new Error(`Trying to Reflect.${fnName} of ${typeof resolved}`);
// @ts-expect-error
return Reflect[fnName](resolved, ...args);
}];
})),
has(target, p) {
const contextHolder = proxyContextHolder.get(target);
...Object.fromEntries(
Object.getOwnPropertyNames(Reflect).map((fnName) => {
return [
fnName,
(target: any, ...args: any[]) => {
const contextHolder = proxyContextHolder.get(target);
const resolved = contextHolder?.factory();
if (!resolved)
throw new Error(
`Trying to Reflect.${fnName} of ${typeof resolved}`,
);
// @ts-expect-error
return Reflect[fnName](resolved, ...args);
},
];
}),
),
has(target, p) {
const contextHolder = proxyContextHolder.get(target);
if (contextHolder?.options) {
const { exemptedEntries: isolatedEntries } = contextHolder.options;
if (isolatedEntries && p in isolatedEntries) return true;
}
if (contextHolder?.options) {
const { exemptedEntries: isolatedEntries } = contextHolder.options;
if (isolatedEntries && p in isolatedEntries) return true;
}
const resolved = contextHolder?.factory();
if (!resolved) throw new Error(`Trying to Reflect.has of ${typeof resolved}`);
return Reflect.has(resolved, p);
},
get(target, p, receiver) {
if (__DEV__ && p === "__IS_BUNNY_LAZY_PROXY__") return true;
const resolved = contextHolder?.factory();
if (!resolved)
throw new Error(`Trying to Reflect.has of ${typeof resolved}`);
return Reflect.has(resolved, p);
},
get(target, p, receiver) {
if (__DEV__ && p === "__IS_BUNNY_LAZY_PROXY__") return true;
const contextHolder = proxyContextHolder.get(target);
const contextHolder = proxyContextHolder.get(target);
if (contextHolder?.options) {
const { exemptedEntries: isolatedEntries } = contextHolder.options;
if (isolatedEntries?.[p]) return isolatedEntries[p];
}
if (contextHolder?.options) {
const { exemptedEntries: isolatedEntries } = contextHolder.options;
if (isolatedEntries?.[p]) return isolatedEntries[p];
}
const resolved = contextHolder?.factory();
if (!resolved) throw new Error(`Trying to Reflect.get of ${typeof resolved}`);
return Reflect.get(resolved, p, receiver);
},
ownKeys: target => {
const contextHolder = proxyContextHolder.get(target);
const resolved = contextHolder?.factory();
if (!resolved) throw new Error(`Trying to Reflect.ownKeys of ${typeof resolved}`);
const resolved = contextHolder?.factory();
if (!resolved)
throw new Error(`Trying to Reflect.get of ${typeof resolved}`);
return Reflect.get(resolved, p, receiver);
},
ownKeys: (target) => {
const contextHolder = proxyContextHolder.get(target);
const resolved = contextHolder?.factory();
if (!resolved)
throw new Error(`Trying to Reflect.ownKeys of ${typeof resolved}`);
const cacheKeys = Reflect.ownKeys(resolved);
unconfigurable.forEach(key => !cacheKeys.includes(key) && cacheKeys.push(key));
return cacheKeys;
},
getOwnPropertyDescriptor: (target, p) => {
if (isUnconfigurable(p)) return Reflect.getOwnPropertyDescriptor(target, p);
const cacheKeys = Reflect.ownKeys(resolved);
unconfigurable.forEach(
(key) => !cacheKeys.includes(key) && cacheKeys.push(key),
);
return cacheKeys;
},
getOwnPropertyDescriptor: (target, p) => {
if (isUnconfigurable(p)) return Reflect.getOwnPropertyDescriptor(target, p);
const contextHolder = proxyContextHolder.get(target);
const resolved = contextHolder?.factory();
if (!resolved) throw new Error(`Trying to getOwnPropertyDescriptor of ${typeof resolved}`);
const contextHolder = proxyContextHolder.get(target);
const resolved = contextHolder?.factory();
if (!resolved)
throw new Error(
`Trying to getOwnPropertyDescriptor of ${typeof resolved}`,
);
const descriptor = Reflect.getOwnPropertyDescriptor(resolved, p);
if (descriptor) Object.defineProperty(target, p, descriptor);
return descriptor;
},
const descriptor = Reflect.getOwnPropertyDescriptor(resolved, p);
if (descriptor) Object.defineProperty(target, p, descriptor);
return descriptor;
},
};
/**
@ -83,20 +100,26 @@ const lazyHandler: ProxyHandler<any> = {
* @returns A proxy that will call the factory function only when needed
* @example const ChannelStore = proxyLazy(() => findByProps("getChannelId"));
*/
export function proxyLazy<T, I extends ExemptedEntries>(factory: () => T, opts: LazyOptions<I> = {}): T {
let cache: T;
export function proxyLazy<T, I extends ExemptedEntries>(
factory: () => T,
opts: LazyOptions<I> = {},
): T {
let cache: T;
const dummy = opts.hint !== "object" ? function () { } : {};
const proxyFactory = () => cache ??= factory();
const dummy = opts.hint !== "object" ? function () {} : {};
const proxyFactory = () => (cache ??= factory());
const proxy = new Proxy(dummy, lazyHandler) as T & I;
factories.set(proxy, proxyFactory);
proxyContextHolder.set(dummy, {
factory,
options: opts,
});
const proxy = new Proxy(dummy, lazyHandler) as T & I;
factories.set(proxy, proxyFactory);
// Use the proxyFactory in the context holder so the lazy handler calls the cached
// resolver instead of the raw factory. This avoids attempting to Reflect.* on
// an unresolved/undefined value when the original factory hasn't produced the object yet.
proxyContextHolder.set(dummy, {
factory: proxyFactory,
options: opts,
});
return proxy;
return proxy;
}
/**
@ -109,27 +132,35 @@ export function proxyLazy<T, I extends ExemptedEntries>(factory: () => T, opts:
* uuid4; // <- is a lazy proxy!
*/
export function lazyDestructure<
T extends Record<PropertyKey, unknown>,
I extends ExemptedEntries
T extends Record<PropertyKey, unknown>,
I extends ExemptedEntries,
>(factory: () => T, opts: LazyOptions<I> = {}): T {
const proxiedObject = proxyLazy(factory);
const proxiedObject = proxyLazy(factory);
return new Proxy({}, {
get(_, property) {
if (property === Symbol.iterator) {
return function* () {
yield proxiedObject;
yield new Proxy({}, {
get: (_, p) => proxyLazy(() => proxiedObject[p], opts)
});
throw new Error("This is not a real iterator, this is likely used incorrectly");
};
}
return proxyLazy(() => proxiedObject[property], opts);
return new Proxy(
{},
{
get(_, property) {
if (property === Symbol.iterator) {
return function* () {
yield proxiedObject;
yield new Proxy(
{},
{
get: (_, p) => proxyLazy(() => proxiedObject[p], opts),
},
);
throw new Error(
"This is not a real iterator, this is likely used incorrectly",
);
};
}
}) as T;
return proxyLazy(() => proxiedObject[property], opts);
},
},
) as T;
}
export function getProxyFactory<T>(obj: T): (() => T) | void {
return factories.get(obj) as (() => T) | void;
return factories.get(obj) as (() => T) | void;
}

2
src/modules.d.ts vendored
View file

@ -1,5 +1,5 @@
declare module "bunny-build-info" {
const version: "1.1.1";
const version: "1.1.2";
}
declare module "*.png" {