Project Rules

Where files go, and the check behind each rule.

The layout and loading rules every plugin follows. ubd create scaffolds a plugin that already follows them, and lint rules from @unbound-app/cli/oxlint plus ubd build checks catch most departures.

RuleChecked by
LayoutBuild check layout (warning)
Features Start ThemselvesAddon review
Lazy LookupsLint unbound/no-module-scope-lookup
Platform CodeLint unbound/platform-check-only-in-platform-file (allows src/native/ and platform.ts), build check layout
Scoped APILint unbound/no-window-unbound
Generated CapabilitiesBuild check capabilities (error)
Native ViewsAddon review
Bundle SizeBuild report

Layout

Split the plugin by kind: what it patches, intercepts, draws and computes. The entry only lists features. Never nest deeper than src/<folder>/<file>, except platform-native code in src/native/ios/<file> and src/native/android/<file>. See Plugin Anatomy.

LocationHolds
src/ rootOnly index.tsx, settings.tsx, actions.ts, patches.ts, commands.ts, and the generated unbound-settings.d.ts and unbound-i18n.d.ts
src/patches/, src/commands/patches.ts or commands.ts once it grows, as named files such as patches/message-rows.ts
src/native/hooks.ts, views.ts, worklets.ts, calls.ts, split per platform into native/android/<kind>.ts and native/ios/<kind>.ts
src/flux/interceptors.ts, listeners.ts
src/components/Components and their hooks
src/lib/The plugin's own logic: data, network, storage, helpers. No Discord patches
Beside src/settings.ts, i18n/, assets/, tests/, ADDON.md, media/

ubd build warns for each departure. Warnings do not fail the build.

Features Start Themselves

src/index.tsx calls each feature. The feature checks its setting, finds its own modules and makes only the patches it needs. A function's name says its kind: patchX, interceptX, listenToX, registerX, hookX or drawX.

The feature's JSDoc names every patch it makes and every Discord module or store it depends on.

src/patches.ts
import { metro, patcher } from '@unbound-app/api';

/**
 * @description Logs each message the user sends.
 * Patches `sendMessage` on Discord's message actions module.
 */
export function patchSendMessage() {
	const Messages = metro.findByProps('sendMessage', 'receiveMessage');

	if (!Messages) {
		logger.warn('Greeting skipped: Discord moved sendMessage.');
		return;
	}

	patcher.before(Messages, 'sendMessage', ({ args }) => logger.log('Sending to', args[0]));
}

Lazy Lookups

Top-level code runs on every start, before start, and delays Discord at launch. Make no eager search there: no metro.find, findBy* or flux.getStore with { lazy: false }.

import { flux, metro } from '@unbound-app/api';

// Lazy: safe at the top of a file.
const Messages = metro.findByProps('sendMessage', 'receiveMessage');
const UserStore = flux.getStore('User');

// Eager: move it into the function that uses it.
const Button = metro.findByName('Button', { lazy: false });

Platform Code

Platform code lives in src/native/. One small file there may branch on the platform; a larger one splits into src/native/android/<kind>.ts and src/native/ios/<kind>.ts, and definePlatform in src/native/<kind>.ts picks between them. Nowhere else checks the platform.

src/native/android/views.ts
export function platformName() {
	return 'Android';
}
src/native/ios/views.ts
export function platformName() {
	return 'iOS';
}
src/native/views.ts
import { definePlatform } from '@unbound-app/api/native';

import { platformName as android } from '~/native/android/views';
import { platformName as ios } from '~/native/ios/views';

export const platformName = definePlatform({ ios, android });

ubd build reads the definePlatform keys to learn which platforms the plugin supports.

Scoped API

Import from @unbound-app/api, which reads the plugin's Addon scope. window.unbound is unscoped, so anything registered through it outlives the plugin.

Generated Capabilities

ubd build writes platforms and each platform's Capabilities into dist/manifest.json, from the native calls each handler can reach. Your manifest.json never lists them. A hand-written capabilities or platforms field fails the build, and so does dynamic access such as native.objc[name].

Native Views

Change how Discord's native views look from a worklet on the UI thread, so the change never flashes in. See UI Thread.

Bundle Size

The bundle loads on every start. Keep it under its budget, 50kb by default. Import a library the client provides, such as lodash, by name, not by file path, so the bundle carries no second copy. ubd build warns past a budget, and --strict-budget fails the build. See CLI.

All Checks

ubd build runs these after bundling. Errors fail the build; warnings print.

Build checkReports
namingWarnings for a folder, id, name or description that breaks the manifest naming rules
capabilitiesErrors for hand-written Capabilities or platforms, and dynamic native access
layoutWarnings for each departure from the layout
pageErrors and warnings for an ADDON.md that breaks the addon page rules

ubd check runs these checks and the lint without building. See CLI.

A scaffolded plugin's .oxlintrc.json turns on the three lint rules:

.oxlintrc.json
{
	"jsPlugins": [{ "name": "unbound", "specifier": "@unbound-app/cli/oxlint" }],
	"rules": {
		"unbound/no-window-unbound": "error",
		"unbound/platform-check-only-in-platform-file": "error",
		"unbound/no-module-scope-lookup": ["error", { "lazyByDefault": true }]
	}
}