Your First Plugin

Scaffold a plugin and run it on your phone.

Go from an empty folder to a plugin running on your phone. You need Bun on your computer and a phone with Unbound installed on the same network.

Quick Start

  1. Scaffold a workspace

    ubd asks for anything you leave out, such as your Discord user id.

    bunx @unbound-app/cli create --kind plugin --name hello-world --shape repository --git-init --install
    cd hello-world
  2. Start the bridge

    Leave it running in its own terminal. It prints a Devices address, such as 192.168.1.20:9090.

    bunx @unbound-app/debugger
  3. Connect your phone

    1. In Discord, open Unbound settings, then General, and turn on Developer Mode.
    2. Open Developer and turn on Enable Debugger.
    3. Enter the Devices address in Debugger Address.
    4. Restart Discord.

    The bridge prints Device connected.

  4. Push the plugin

    push builds, installs and starts the plugin. It greets you with a toast.

    bunx @unbound-app/cli debug push hello-world
  5. Rebuild on save

    dev rebuilds the plugin and reloads it on the phone each time you save.

    bun run dev

What You Got

The plugin lives in plugins/hello-world. Plugin Anatomy explains each file.

  • hello-world
    • manifest.jsonId, name and version
    • ADDON.mdThe addon page
    • settings.tsSettings; Unbound builds the panel
    • i18n
      • en-US.jsonEvery string the plugin shows
    • assets
      • icon.pngThe toast's icon
    • mediaAddon page images
    • src
      • index.tsxStarts each feature
      • unbound-i18n.d.tsGenerated. Commit it
      • unbound-settings.d.tsGenerated. Commit it
    • bunfig.tomlLoads the testing preset
    • tests
      • index.test.tsRuns with bun test

The entry exports start. It reads settings from @unbound-app/settings, which the build makes from settings.ts. toasts.success takes a message key and its placeholder values, and an imported image is its asset id.

src/index.tsx
import { settings } from '@unbound-app/settings';
import { toasts } from '@unbound-app/api';

import icon from '../assets/icon.png';

/** Greets the user, unless they turned the greeting off. */
export function start() {
	if (!settings.greetOnStart) return;

	toasts.success('GREETING_BODY', { name: addon.name }, { icon });
}

settings.ts declares one switch. Its title and subtitle are message keys.

settings.ts
import { defineSettings, setting } from '@unbound-app/settings/schema';

export default defineSettings({
	greetOnStart: setting.boolean({
		default: true,
		title: 'GREET_ON_START',
		subtitle: 'GREET_ON_START_SUBTITLE',
	}),
});

The test starts the plugin and checks its toast. Run it with bun test.

tests/index.test.ts
import { startPlugin, stopPlugin, toasts } from '@unbound-app/api/testing';
import { settings } from '@unbound-app/settings';
import { describe, expect, test } from 'bun:test';

describe('start', () => {
	test('greets with the plugin name', async () => {
		await startPlugin();

		expect(toasts.shown).toMatchObject([{ key: 'GREETING_BODY', title: 'Hello World is running.' }]);
		await stopPlugin();
	});

	test('stays quiet when greetOnStart is off', async () => {
		settings.greetOnStart = false;
		await startPlugin();

		expect(toasts.shown).toEqual([]);
		await stopPlugin();
	});
});

The scaffold also has a .gitignore and an .oxlintrc.json with the lint rules.

Change Discord

Add a feature that logs every message you send, in its own folder:

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

export function startMessageLog() {
	const Messages = metro.findByProps('sendMessage', 'receiveMessage');

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

	patcher.before(Messages, 'sendMessage', ({ args }) => {
		logger.log('Sending a message to channel', args[0]);
	});
}
CallWhat it does
metro.findByPropsFinds the module with both exports. Lazy. undefined when Discord moved it
patcher.beforeRuns your callback before each sendMessage, with its arguments
loggerA global that prefixes each line with the plugin's name

Start it from the entry:

src/index.tsx
import { settings } from '@unbound-app/settings';
import { toasts } from '@unbound-app/api';

import { startMessageLog } from '~/message-log';
import icon from '../assets/icon.png';

/** Starts the message log, and greets the user unless they turned the greeting off. */
export function start() {
	startMessageLog();

	if (!settings.greetOnStart) return;

	toasts.success('GREETING_BODY', { name: addon.name }, { icon });
}

Save, then send a message on your phone. The log line appears in the bridge's terminal.

Next