Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/Dashboard extension: ACP dashboard blocks in Invision Community 5

The core/Dashboard extension point adds a block (widget) to the AdminCP dashboard at app=core&module=overview&controller=dashboard. Core calls it in two completely separate places: once on the full page render in IPS\core\modules\admin\overview\dashboard::manage(), and again — through a different code path with different arguments — on the AJAX endpoint do=getBlock that the dashboard's JavaScript hits every time a block is added, dragged or refreshed.

The contract

system/Extensions/DashboardAbstract.php declares four members, but only two of them are ever called by core.

abstract class DashboardAbstract
{
    /* REQUIRED. Called on every dashboard page load, for every registered
       Dashboard extension in every enabled app, whether or not the admin
       has your block on their dashboard. Return FALSE to hide the block
       from the "Add" dropdown entirely. */
    abstract public function canView(): bool;

    /* REQUIRED. Must return a string of HTML. Called only for blocks the
       admin actually has placed, and again on the do=getBlock AJAX route. */
    abstract public function getBlock(): string;

    /* DEAD CODE in 5.0.19. Nothing calls it. See below. */
    public function getInfo(): array { return array(); }

    /* DEAD CODE in 5.0.19. Nothing calls it. See below. */
    public function saveBlock() {}
}

Registration is by file plus a data/extensions.json entry. The block's key is {appDirectory}_{ExtensionName} — built in Application::allExtensions() as $application->directory . '_' . $key — and its title comes from the language key block_{appDirectory}_{ExtensionName}, not from anything your class returns.

A minimal example

Four things are needed. First, applications/myapp/extensions/core/Dashboard/ServerLoad.php:

<?php
namespace IPS\myapp\extensions\core\Dashboard;

use IPS\Extensions\DashboardAbstract;
use IPS\Member;
use IPS\Theme;
use function defined;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class ServerLoad extends DashboardAbstract
{
    public function canView(): bool
    {
        return Member::loggedIn()->hasAcpRestriction( 'myapp', 'settings', 'myapp_settings_manage' );
    }

    public function getBlock(): string
    {
        /* Re-check here too: the AJAX route does NOT call canView() */
        if ( !$this->canView() )
        {
            return '';
        }

        $load = function_exists( 'sys_getloadavg' ) ? sys_getloadavg() : array( 0, 0, 0 );

        /* Pass the app key AND the location explicitly */
        return (string) Theme::i()->getTemplate( 'dashboard', 'myapp', 'admin' )->serverLoad( $load );
    }
}

Second, applications/myapp/data/extensions.json:

{
    "core": {
        "Dashboard": {
            "ServerLoad": "IPS\\myapp\\extensions\\core\\Dashboard\\ServerLoad"
        }
    }
}

Third, the language string in applications/myapp/dev/lang.php. The key is not optional:

'block_myapp_ServerLoad' => "Server Load",

Fourth, applications/myapp/dev/html/admin/dashboard/serverLoad.phtml:

<ips:template parameters="$load" />
<div class='ipsPad'>
    <p>{$load[0]} / {$load[1]} / {$load[2]}</p>
</div>

The block title renders as the literal text "block_myapp_ServerLoad"

You see the raw key in both the widget header and the "Add" dropdown. Your class never gets a chance to supply a title. In manage() core builds the block name itself:

$info[ $key ] = array(
    'name' => Member::loggedIn()->language()->addToStack('block_' . $key ),
    'key'  => $key,
    'app'  => substr( $key, 0, strpos( $key, '_' ) )
);

and widgetWrapper.phtml re-resolves the same key with {lang="block_{$info['key']}"}. When a key has no row in core_sys_lang_words, Lang::replaceWords() falls back to the key itself:

else
{
    $values['options']['escape'] = TRUE;
    $replacement = $values[ 'key' ];
}

Add block_{appDirectory}_{ExtensionName} to your app's lang.php and rebuild the language pack. There is no way to override this from the extension class.

getInfo() and saveBlock() do nothing — the docblock describes an API that no longer exists

The abstract's docblock for getInfo() promises array( 'name' => 'Block title', 'key' => 'unique_key', 'size' => [1,2,3], 'by' => 'Author name' ). Grepping the whole 5.0.19 file set turns up exactly one occurrence of getInfo in the extension namespace: its own definition in DashboardAbstract.php. Nothing calls it. There is no size handling anywhere; the dashboard has a fixed 8/4 two-column grid in dashboard.phtml. The only surviving trace of the old API is a dangling {{by}} placeholder in ips.templates.dashboard.js, which addWidget() never populates.

saveBlock() is the same story — declared, documented as throwing LogicException, and called by nothing. The one core block that saves submitted data, core_AdminNotes, does it inside getBlock() by posting a form back to the do=getBlock route and handling $form->values() there. The stub that the Developer Center generates from applications/core/data/defaults/extensions/Dashboard.txt omits both methods, which is the honest version of the contract.

If you override either method expecting it to be honoured, you get no error and no effect — just a block with no title and no save behaviour.

The whole ACP dashboard 500s and the error blames core, not your app

This is the most important trap in this extension point. manage() calls your code with no protection at all:

foreach ( Application::allExtensions( 'core', 'Dashboard', TRUE, 'core' ) as $key => $extension )
{
    if ( $extension->canView() )
    {
        ...
        $blocks[ $key ] = $extension->getBlock();
    }
}

No try, no catch, no per-block isolation. Any uncaught throwable from any installed app's canView() or getBlock() takes down the entire dashboard for every administrator. The error page and the system log will name IPS\core\modules\admin\overview\dashboard, so the first thing an admin does is blame core or the last core upgrade. Your bug surfaces on a screen owned by core, in an app you did not write, with a stack trace that only mentions your class deep inside.

The exposure is wider than it looks, because canView() runs for every registered Dashboard extension on every dashboard load, whether or not any admin has ever added your block. A canView() that queries a table your migration has not created yet will break the dashboard on the very first page an admin sees after installing your app.

Keep canView() to permission checks and settings reads, and wrap everything in getBlock():

public function getBlock(): string
{
    try
    {
        return (string) Theme::i()->getTemplate( 'dashboard', 'myapp', 'admin' )->serverLoad( $this->data() );
    }
    catch ( \Throwable $e )
    {
        \IPS\Log::log( $e, 'myapp_dashboard' );
        return '';
    }
}

"Return value must be of type string, null returned"

getBlock(): string carries a declared return type on the abstract, so returning NULL, an array, or a template object that has no __toString() is a TypeError, not a silent empty block. Because of the previous trap, that TypeError is an ACP-wide dashboard failure rather than a broken widget. Cast template output explicitly — return (string) Theme::i()->getTemplate(...)->myBlock(); — and return '', never NULL, for the "nothing to show" case.

The block renders on page load, then comes back blank when it is dragged or re-added

Dragging a widget, adding one from the dropdown, or firing refreshWidget does not re-render the page. ips.dashboard.main.js issues:

ips.getAjax()( '?app=core&module=overview&controller=dashboard&do=getBlock', {
    data: {
        appKey: key.substr( 0, key.indexOf( '_' ) ),
        blockKey: key
    }
})

and the server side is a second, hand-rolled lookup that does not use allExtensions() at all:

$output = '';

foreach( Application::load( Request::i()->appKey )->extensions( 'core', 'Dashboard', 'core' ) as $key => $_extension )
{
    if( Request::i()->appKey . '_' . $key == Request::i()->blockKey )
    {
        if( method_exists( $_extension, 'getBlock' ) )
        {
            $output = $_extension->getBlock();
        }

        break;
    }
}

Output::i()->output = $output;

If the key comparison never matches, $output stays '' and you get an empty widget with no error, no log entry, and a 200 response. The comparison fails when the app key derived by JavaScript is wrong, and JavaScript splits on the first underscore. An app directory containing an underscore (say my_app) produces appKey = "my", Application::load('my') throws or returns the wrong app, and the block silently never reloads even though it rendered perfectly on the initial page load. Extension class names may contain underscores safely; app directories may not.

Note also the method_exists( $_extension, 'getBlock' ) guard. Core is re-checking the existence of a method its own abstract declares abstract. That guard exists because extensions.json is only validated with class_exists() in Application::extensions() — nothing verifies that your class actually extends DashboardAbstract. The two call sites disagree about this: the AJAX route guards getBlock and degrades to an empty string, while manage() calls canView() with no guard at all. A Dashboard extension that forgets extends DashboardAbstract therefore fatals the whole dashboard on page load but returns a quiet empty widget over AJAX.

"Argument #1 ($member) must be of type IPS\Member, null given" — but only when the block reloads

The two call paths construct your class with different arguments. Application::constructExtensionClass() does:

$obj = new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) );

manage() uses allExtensions( 'core', 'Dashboard', TRUE, 'core' ), so $checkAccess is TRUE and your constructor receives Member::loggedIn(). The AJAX route uses Application::extensions( 'core', 'Dashboard', 'core' ), whose signature is extensions( $app, $extension, bool $construct=TRUE, bool|Group|Member|null $checkAccess = FALSE ) — the string 'core' lands in $construct (coerced to TRUE) and $checkAccess stays at its default FALSE, so your constructor receives NULL.

Declare no constructor, or make the parameter nullable:

public function __construct( ?Member $member = NULL )
{
    $this->member = $member ?: Member::loggedIn();
}

Getting this wrong is invisible on the dashboard page itself and only bites the AJAX reload. Worse, constructExtensionClass() catches only RuntimeException | OutOfRangeException, so a TypeError or ArgumentCountError from your constructor escapes and takes out whichever screen triggered it.

The block's CSS and JS vanish after a drag or an add

IPS\nexus\extensions\core\Dashboard\PendingActions::getBlock() does this:

Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'widgets.css', 'nexus', 'front' ) );

That works on the full page render only. On an AJAX request the ACP dispatcher wraps all of its CSS/JS assembly in if ( !Request::i()->isAjax() ), and do=getBlock assigns your HTML directly to Output::i()->output with nothing else attached. The JS then does .html( response ) into the widget body. So anything you push onto Output::i()->cssFiles or jsFiles inside getBlock() is discarded whenever the block is added, dragged, or refreshed.

The symptom is confusing because it is order-dependent: the block looks right until someone moves it, then loses its styling until the page is reloaded. Either inline the styles you need in the block markup, or register your CSS from your app's Application.php / an ACP output hook so it is present on every dashboard page load regardless of which path rendered the block. The JS controller does fire $( document ).trigger( 'contentChange', [ widget ] ) after injection, so already-loaded controllers will re-bind — but only if their file was loaded by the page, not by the block.

canView() is not an access control check on the AJAX route

Read the do=getBlock method again: it never calls canView(). It also never calls Dispatcher::i()->checkAcpPermission( 'view_dashboard' ) — that call lives only in manage(). The only gate on the AJAX route is the dispatcher's generic module restriction check (hasAcpRestriction( $this->application, $this->module ) for core/overview).

The practical consequence: an administrator who can reach the overview module but fails your canView() — say they lack transactions_manage, which is what nexus_Income::canView() requires — can still retrieve your block's rendered HTML by requesting index.php?app=core&module=overview&controller=dashboard&do=getBlock&appKey=myapp&blockKey=myapp_ServerLoad. Treat canView() as a visibility hint for the "Add" dropdown, not as authorization. Repeat the permission check at the top of getBlock() and return '' if it fails.

State-changing work inside getBlock() has no CSRF protection unless you add it

The ACP dispatcher auto-CSRF-checks any request carrying query-string keys beyond app/module/controller/id, but only when the controller has not opted out:

if ( !isset( $this->classname::$csrfProtected ) and array_diff( array_keys( Request::i()->url()->queryString ), array( 'app', 'module', 'controller', 'id' ) ) )
{
    Session::i()->csrfCheck();
}

The dashboard controller declares public static bool $csrfProtected = TRUE;, which disables that automatic check for every method on it. Only update() calls Session::i()->csrfCheck() explicitly; getBlock() does not. This is why AdminNotes builds its form URL as Url::internal( "...&do=getBlock&appKey=core&blockKey=core_AdminNotes" )->csrf() — it is supplying its own token because the controller opted out of the framework's.

If your block posts back to do=getBlock and writes anything, append ->csrf() to the form action exactly as AdminNotes does.

Theme::i()->getTemplate('dashboard') silently resolves to core's templates, not yours

You get Call to undefined method IPS\Theme\class_core_admin_dashboard::serverLoad(), or — worse — core's dashboard group happens to have a method with the same name and you render the wrong markup. Theme::getTemplate() infers the app from the dispatcher:

if( $app === NULL )
{
    $app = Dispatcher::i()->application->directory;
}

if( $location === NULL )
{
    $location = Dispatcher::i()->controllerLocation;
}

On the dashboard the dispatched application is always core, no matter whose extension is running. Every first-party block outside core passes its app key for this reason (Theme::i()->getTemplate( 'dashboard', 'blog' ), ...( 'dashboard', 'nexus' )). Pass the location too — Theme::i()->getTemplate( 'dashboard', 'myapp', 'admin' ) — so the call still resolves correctly if you ever reuse the method outside an ACP request.

The new block does not appear in the "Add" dropdown at all

Three separate caches and one file have to line up.

  • Application::extensions() reads only applications/{app}/data/extensions.json. A file dropped into extensions/core/Dashboard/ without a matching JSON entry does not exist as far as core is concerned. The Developer Center writes this file via buildExtensionsJson().
  • Application::allExtensions() caches the entire resolved list in the datastore under Store::i()->extensions, keyed by extension type, and rebuilds only when the 'Dashboard' key is absent. Application declares protected array $caches = array( 'updatecount_applications', 'applications', 'extensions' ), so saving an Application record (install, upgrade, enable/disable) clears it — but hand-editing files on a live site does not. The Developer Center's create/remove extension actions unset( Store::i()->extensions ) explicitly.
  • Application::extensions() also keeps a per-request static, static::$_loadedExtensions, so nothing you change mid-request is picked up.

There is a fourth, nastier variant. The cache is written after apps have been filtered, and the filter includes recovery mode:

foreach ( $apps as $application )
{
    /* Skip third party apps if recovery mode is enabled */
    if( RECOVERY_MODE and !in_array( $application->directory, IPS::$ipsApps ) )
    {
        continue;
    }

    if ( !static::appIsEnabled( $application->directory ) )
    {
        continue;
    }
    ...
}

/* Store for next time */
Store::i()->extensions = $allExtensions;

Loading the dashboard once while RECOVERY_MODE is on bakes a first-party-only Dashboard list into the datastore, and that list survives after recovery mode is turned off. The symptom appears long after the cause: every third-party dashboard block quietly disappears, and nothing in the log connects it to a recovery-mode page view days earlier. Clearing the system cache (or any Application save) fixes it.

The block is installed and viewable but nobody ever sees it

Registering a Dashboard extension does not place it. The per-admin layout lives in the single acp_dashboard_blocks setting, a JSON blob keyed by member_id, and the defaults are hard-coded in dashboard::current():

$toShow = array(
    'main'      => array( 'core_Registrations', 'core_BackgroundQueue' ),
    'side'      => array( 'core_AdminNotes', 'core_OnlineUsers' ),
    'collapsed' => array( 'core_BackgroundQueue' ),
);

There is no extension hook into that list. Every administrator who already has a saved layout must add your block manually from the "Add" dropdown. Conversely, when your app is uninstalled the stale key stays in acp_dashboard_blocks forever; dashboard.phtml hides it with {{if array_key_exists( $cellKey, $blocks ) and isset($info[ $cellKey ])}}, so it fails silently rather than erroring — which also means an admin who removed your app and reinstalled it will find the block already positioned where they left it.

Verified against

Read from Invision Community 5.0.19 source (applications/core/data/versions.json top entry 5001908 => "5.0.19"). Files inspected: system/Extensions/DashboardAbstract.php; the consumer applications/core/modules/admin/overview/dashboard.php; Application::allExtensions(), Application::extensions() and Application::constructExtensionClass() in system/Application/Application.php; system/Dispatcher/Admin.php; system/Lang/Lang.php; system/Theme/Theme.php; the templates dashboard.phtml, dashboardHeader.phtml and widgetWrapper.phtml; the controller ips.dashboard.main.js; and all thirteen first-party implementations under applications/{core,blog,downloads,gallery,nexus}/extensions/core/Dashboard/. forums, cms and calendar ship no Dashboard extension.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.