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/Uninstall extension in Invision Community 5

The core/Uninstall extension is the only hook an application gets into its own removal, and the only way one application can react to a different application being removed. Core calls it from IPS\Application::delete() (system/Application/Application.php) — preUninstall() before any data is touched, onOtherUninstall() on every enabled application immediately after, and postUninstall() near the end, after your database tables have already been dropped.

Despite the docblock wording, there is no plugin system in IC5. There is no plugins/ directory and no IPS\Plugin class in 5.0.19; onOtherUninstall() only ever fires for applications.

The contract

system/Extensions/UninstallAbstract.php declares three methods. None of them are abstract — every one has an empty body, so you only override the ones you need. None return a value, and none declare a thrown exception.

namespace IPS\Extensions;

abstract class UninstallAbstract
{
    /* Called on the app being removed, BEFORE core deletes anything.
       $application is always your own app directory here. */
    public function preUninstall( string $application ) : void {}

    /* Called on the app being removed, AFTER core has dropped your
       tables, deleted your settings, lang words, themes and widgets. */
    public function postUninstall( string $application ) : void {}

    /* Called on EVERY enabled application (including the one being
       removed) when any application is uninstalled.
       $application is the directory of the app going away. */
    public function onOtherUninstall( string $application ) : void {}
}

The order core executes them in, taken from Application::delete():

StepWhat happens
1$this->extensions( 'core', 'Uninstall' ) — your extension objects are constructed once and kept in an array
2preUninstall( $this->directory ) on each of your extensions
3onOtherUninstall( $this->directory ) on Application::allExtensions( 'core', 'Uninstall', FALSE ) — every enabled app
4Profile steps, front navigation, club node maps, search index, then ~25 DELETEs from shared core_* tables
5ContentRouter class cleanup, attachment maps, tasks, lang words, email templates, theme templates/CSS/resources
6FileStorage extension delete(), upload settings, notification defaults, login handlers
7Tables in data/schema.json are dropped; addColumn/addIndex from setup/install/queries.json are reverted
8Widget rows, widget areas, widget trash
9postUninstall( $this->directory ) on the same objects from step 1
10FURL definitions, parent::delete() (the core_applications row), data store flush, then the applications/<dir>/ folder is deleted from disk

A minimal example

File: applications/myapp/extensions/core/Uninstall/Cleanup.php

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

use IPS\Data\Store;
use IPS\Db;
use IPS\Extensions\UninstallAbstract;
use function defined;

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

class Cleanup extends UninstallAbstract
{
    /**
     * Before anything is removed - your tables still exist here.
     */
    public function preUninstall( string $application ) : void
    {
        /* Rows we put in a core table that core will not match on app= */
        if ( Db::i()->checkForTable( 'myapp_widget_map' ) )
        {
            Db::i()->delete( 'core_item_markers', array( 'item_app=?', 'myapp' ) );
        }
    }

    /**
     * After our tables are gone. Guard EVERY table access.
     */
    public function postUninstall( string $application ) : void
    {
        /* Columns we added in an UPGRADE step are not reverted by core */
        foreach ( array( 'myapp_score', 'myapp_last_seen' ) as $column )
        {
            if ( Db::i()->checkForColumn( 'core_members', $column ) )
            {
                Db::i()->dropColumn( 'core_members', $column );
            }
        }

        /* Our own datastore keys */
        if ( isset( Store::i()->myapp_config ) )
        {
            unset( Store::i()->myapp_config );
        }
    }

    /**
     * Someone else is being uninstalled. Never throw from here.
     */
    public function onOtherUninstall( string $application ) : void
    {
        /* We are also called with our OWN directory - skip that case */
        if ( $application === 'myapp' )
        {
            return;
        }

        try
        {
            if ( Db::i()->checkForTable( 'myapp_integrations' ) )
            {
                Db::i()->delete( 'myapp_integrations', array( 'int_app=?', $application ) );
            }
        }
        catch ( \Exception $e )
        {
            /* Swallow: an exception here aborts the OTHER app's uninstall */
        }
    }
}

And the registration in applications/myapp/data/extensions.json:

{
    "core": {
        "Uninstall": {
            "Cleanup": "IPS\\myapp\\extensions\\core\\Uninstall\\Cleanup"
        }
    }
}

My uninstall code never ran and the ACP said "Deleted"

The most likely cause is that the application was flagged app_requires_manual_intervention. Every suite upgrade sets this on all non-IPS applications — system/Dispatcher/Setup.php and applications/core/modules/setup/upgrade/applications.php both run Db::i()->update( 'core_applications', [ 'app_enabled' => 0, 'app_requires_manual_intervention' => 1 ], Db::i()->in( 'app_directory', IPS::$ipsApps, true ) ).

When the admin then deletes that application, applications/core/modules/admin/applications/applications.php::delete() checks the flag and reroutes to static::_deleteLegacyApp( $application['app_directory'] ). That method reimplements most of Application::delete() by hand and never calls preUninstall() or postUninstall() at all — it only calls onOtherUninstall() on the other apps. It also skips the search index entirely (the source literally reads // @todo Search index), skips your FileStorage extensions, skips your Notifications and LoginHandler cleanup, and does not run Store::i()->clearAll().

There is a second, related consequence: Application::applications() skips any row where app_requires_manual_intervention is set and the directory is not in IPS::$ipsApps. So a flagged third-party app is invisible to allExtensions() — it will not receive onOtherUninstall() when some other app is removed either.

There is no fix you can apply from inside the extension. Design for it: anything that absolutely must be cleaned up should also be safe to leave behind, and your install routine should be idempotent so a later reinstall tolerates leftovers.

The ACP reports "Deleted" but nothing was removed

This is the single most dangerous behaviour of this extension point. In the ACP controller, $node->delete() sits inside a try block whose first catch is:

/* Legacy */
catch ( UnexpectedValueException $e )
{
    if( !Member::loggedIn()->hasAcpRestriction( 'core', 'applications', 'app_delete' ) )
    {
        Output::i()->error( 'node_noperm_delete', '2C133/J', 403, '' );
    }

    Db::i()->delete( 'core_applications', array( 'app_directory=?', Request::i()->id ) );
}

That catch exists to handle an application row with no loadable Application.php. But it wraps the entire delete() call, so if your preUninstall(), postUninstall() or onOtherUninstall() throws UnexpectedValueException — and a great deal of IPS code throws it, including Application::constructFromData() — core interprets it as "this is a legacy app", deletes the single core_applications row, and redirects with the deleted language string, which renders as "Deleted". Every table, setting, language word, theme template and file is left in place, and the app has no row so it can never be uninstalled properly again.

The error surfaces nowhere near its cause. There is no log entry, no error code, and no indication that your extension threw. Wrap every method body in try { … } catch ( \Exception $e ) {}, or at minimum guard against UnexpectedValueException:

public function preUninstall( string $application ) : void
{
    try
    {
        $this->doCleanup();
    }
    catch ( \Exception $e )
    {
        /* Never let this escape - core would treat it as a legacy app */
    }
}

"We could not locate the item you are trying to view." with code 2C133/I

Same try block, second catch: catch ( OutOfRangeException $e ) { Output::i()->error( 'node_error', '2C133/I', 404, '' ); }. OutOfRangeException is what ActiveRecord::load(), Application::load() and Settings lookups throw when a record is missing — exactly the sort of thing that happens in postUninstall(), where half your data is already gone.

The visible result is a 404 error page in the ACP with code 2C133/I, and the uninstall stops at whatever step you were on. If you threw from postUninstall(), the tables have already been dropped but core_applications still has the row, the FURL definitions are still registered and the app folder is still on disk — a half-uninstalled application that the ACP will still list. Catch OutOfRangeException anywhere you call ::load() in an Uninstall method.

Uninstalling someone else's app fails with an error from my code

Application::delete() loops the onOtherUninstall() calls with no error handling whatsoever:

foreach( static::allExtensions( 'core', 'Uninstall', FALSE ) as $extension )
{
    $extension->onOtherUninstall( $this->directory );
}

Compare this with the FileStorage loop a few hundred lines later, which is wrapped in try { $extension->delete(); } catch( PHPException $e ){}. The Uninstall loop has no such protection.

An exception thrown from your onOtherUninstall() aborts the uninstall of an application you have nothing to do with, at step 3 of 10 — before core has deleted a single row. The admin sees an error while removing, say, Gallery, and there is nothing on the screen connecting it to your app. Your extension is only identifiable from the stack trace in core_error_logs. This is the classic "one bad app breaks a shared screen" case, and it is why the example above swallows exceptions in onOtherUninstall() unconditionally.

The same loop also means onOtherUninstall() runs on every enabled application on the site on every application uninstall. Keep it cheap and keep it defensive.

"Table 'x.myapp_something' doesn't exist" during postUninstall

By the time postUninstall() is called, core has already looped data/schema.json and run Db::i()->dropTable( $tableName, TRUE ) on every table in it. Your own tables are gone. Core's own Pages extension documents this with a bare comment at the top of the method:

public function postUninstall( string $application ) : void
{
    /* cms_databases has been removed */
    $tables = array();
    …
}

Anything that needs to read your data must happen in preUninstall(). Anything in postUninstall() that touches a table must be guarded with Db::i()->checkForTable() — including tables you think still exist, because a partially-failed earlier uninstall attempt may have already removed them. Note that Databases::preUninstall() guards even the pre-hook with if ( Db::i()->checkForTable( 'cms_databases' ) ).

The reverse is also true and useful: at postUninstall() time the core_applications row still exists and applications/<dir>/ is still on disk, because both are removed afterwards. You can still read your own data/*.json files there.

onOtherUninstall fires with my own app's directory

allExtensions() iterates static::applications(), and at step 3 the application being removed is still enabled and still in core_applicationsparent::delete() does not run until step 10. So your own onOtherUninstall() is invoked with your own directory as $application, in addition to the preUninstall() you already received.

Core's Pages extension relies on this (cms\extensions\core\Uninstall\Widgets has only an onOtherUninstall() and it is what cleans up Pages' own widget areas when Pages itself is removed). But if your onOtherUninstall() writes rows, re-caches, or rebuilds something on behalf of the surviving apps, it will do that work for an app that is about to cease to exist. Test $application against your own directory and return early if you do not want that.

A disabled app never gets a chance to clean up

allExtensions() contains two hard filters before it collects any extension:

if( RECOVERY_MODE and !in_array( $application->directory, IPS::$ipsApps ) )
{
    continue;
}

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

So onOtherUninstall() is skipped for every disabled application, and — if the site is in recovery mode — for every third-party application. Rows that a disabled app holds pointing at the removed app stay there forever, and there is no retry when the app is re-enabled.

Note the asymmetry: preUninstall()/postUninstall() come from $this->extensions( 'core', 'Uninstall' ), an instance method that checks neither appIsEnabled() nor recovery mode. Your own uninstall code therefore runs even when your app is disabled — but your own onOtherUninstall() does not, because that comes from the filtered static call.

My extension is never constructed and no error appears

Application::extensions() reads data/extensions.json and skips silently on two conditions:

foreach ( $json[ $app ][ $extension ] as $name => $classname )
{
    if( !is_string( $classname ) or !class_exists( $classname ) )
    {
        /* Switching between branches confuses extensions */
        continue;
    }
    …
}

and constructExtensionClass() returns null — again silently — if class_exists() fails or if the constructor throws RuntimeException or OutOfRangeException. Nothing is logged in any of those paths.

So a typo in the class name, a namespace that does not match the directory, a missing data/extensions.json entry, or a fatal-free parse problem all present identically: the uninstall completes cleanly and your cleanup simply did not happen. In dev mode the JSON is regenerated by Application::buildExtensionsJson(), which scans applications/<dir>/extensions/ and skips any class that does not exist or that defines a deprecated() method — visit the Developer Center once after adding the file, and verify the entry is actually in the built data/extensions.json you ship.

Fatal TypeError when the uninstall starts

UninstallAbstract declares no constructor, but core always instantiates extensions with one argument:

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

For Uninstall extensions $checkAccess is FALSE in both call sites, so your class is constructed as new Cleanup( NULL ). If you copy a constructor signature from another extension type and write public function __construct( Member $member ), that is a TypeError. constructExtensionClass() only catches RuntimeException and OutOfRangeException, so a TypeError escapes it, escapes the ACP's two catches, and produces a hard error page. Either declare no constructor at all, or accept a nullable first argument: public function __construct( ?Member $member = NULL ).

Columns and rows my installer added are still in core tables

On uninstall core reverts setup/install/queries.json, but the switch only has two cases and no default:

foreach( $schema as $instruction )
{
    switch ( $instruction['method'] )
    {
        case 'addColumn':  /* dropColumn, ignoring error 1091 */  break;
        case 'addIndex':   /* dropIndex,  ignoring error 1091 */  break;
    }
}

Two consequences, both verifiable against IPS's own apps:

  • insert is never reverted. calendar, downloads, forums and gallery all use "method": "insert" in their install queries. Those rows survive the uninstall permanently.
  • Only setup/install/queries.json is read. The per-version setup/upg_<version>/queries.json files are never consulted on delete. A column you added to core_members in an upgrade step, rather than at install, is not dropped.

Drop those yourself in postUninstall() with a Db::i()->checkForColumn() guard, as in the example above, and delete your own inserted rows in preUninstall().

One of my tables survived the uninstall

Table dropping is driven entirely by data/schema.json — core iterates its keys and drops each one, swallowing only MySQL error 1051 ("unknown table"). A table you created directly with an addTable instruction in an upgrade step, or with raw SQL, and never added to schema.json is invisible to that loop and will remain in the database after the app is gone.

Pages has exactly this problem and solves it in postUninstall() by pattern-matching the table names, which is the general fix:

try
{
    $databaseTables = Db::i()->query( "SHOW TABLES LIKE '" . Db::i()->prefix . "cms_custom_database_%'" )->fetch_assoc();
    …
}
catch( Exception $ex ) { }

foreach( $tables as $table )
{
    if ( Db::i()->checkForTable( $table ) )
    {
        Db::i()->dropTable( $table );
    }
}

Note that Pages defensively handles fetch_assoc() returning either a scalar or an array per row (if( is_array( $row ) ) { $tables[] = array_pop( $row ); } else { $tables[] = $row; }) — the shape of a SHOW TABLES LIKE result is not stable enough for core to rely on.

A Pages block still points at my widget after my app is uninstalled

cms\extensions\core\Uninstall\Widgets::onOtherUninstall() deletes cms_blocks rows whose block_plugin_app matches the removed app — but that loop is nested inside the loop over cms_page_widget_areas:

foreach ( Db::i()->select( '*', 'cms_page_widget_areas' ) as $row )
{
    …
    /* Remove blocks using a widget from the uninstalled application */
    foreach( new ActiveRecordIterator( Db::i()->select( '*', 'cms_blocks', array( "block_plugin_app=?", $application ) ), Block::class ) AS $block )
    {
        $block->delete();
    }
}

If cms_page_widget_areas is empty — a site with Pages installed but no widgets placed on any page — the inner loop never executes and your blocks are left behind. If it has many rows, the same delete is re-run once per row. This is core's code, not yours; you cannot fix it from your own extension, but you can clean up after it in your own preUninstall() by deleting from cms_blocks yourself, guarded with Db::i()->checkForTable( 'cms_blocks' ) since Pages may not be installed.

A property I set in preUninstall is missing in onOtherUninstall

Core constructs your extension twice. Step 1 builds the objects used for preUninstall() and postUninstall() and deliberately keeps them — the source comment reads "They are stored in an array so that we only create one object per extension, instead of one each time we loop". Step 3 calls allExtensions(), which constructs a completely separate set of objects for onOtherUninstall().

So state stored on $this in preUninstall() is visible in postUninstall() — that is a supported way to hand data across the destructive middle of the uninstall — but it is not visible in onOtherUninstall(), even though that method is on the same class and runs in between. Use a static property or a datastore key if you need it in all three.

What core cleans up for you, and what it leaves behind

Before writing any cleanup code, check this list — most of it is already done. Core deletes, keyed on your app directory: core_modules, core_permission_index, core_dev, core_item_markers, core_reputation_index, core_upgrade_history, core_admin_logs, core_sys_conf_settings (your settings), core_queue, core_follow, core_follow_count_cache, core_item_statistics_cache, core_view_updates, core_moderator_logs, core_member_history, core_acp_notifications, core_solved_index, core_notifications, core_javascript, core_theme_templates_custom, core_sys_lang_words, core_email_templates, core_tasks and core_tasks_log, plus core_achievements_rules whose action starts with <yourapp>_.

Keyed on classes returned by your ContentRouter extensions it also clears core_approval_queue, core_deletion_log, core_content_promote, core_ratings, core_item_redirect, core_item_member_map, core_rss_import, core_soft_delete_log, core_post_before_registering, core_anonymous_posts, core_polls/core_voters, core_assignments and the core_rc_* report tables. Profile steps, front navigation, club node maps, notification defaults and preferences, login handler rows, theme templates/CSS/resources/editor settings, widgets, widget areas, widget trash and FURL definitions are all handled too.

What it does not do:

  • Delete attachment files. Only core_attachments_map rows for your EditorLocations keys are removed; the comment says "if the attachment is unused, the regular cleanup task will remove the file later".
  • Guarantee your stored files are gone. FileStorage::delete() is called inside try { … } catch( PHPException $e ){} — any failure is swallowed with no log.
  • Remove custom language words saved under a different word_app. Only word_app = <yourapp> is deleted, so node titles you saved with Lang::saveCustom( 'core', … ) survive.
  • Remove the search index for classes not declared by a core/SearchContent extension. Index::i()->removeApplicationContent() iterates $application->extensions( 'core', 'SearchContent' ) and calls removeClassFromSearchIndex() for each supportedClasses() entry. Pages' preUninstall() exists purely to work around this and explains why: "The content router only returns databases linked to pages… so the method to remove all app content from the search index fails".
  • Remove your IPS\Data\Store keys, in the legacy delete path. The normal ACP path calls Cache::i()->clearAll() and Store::i()->clearAll() after delete() returns; _deleteLegacyApp() only unsets modules, applications, widgets and furl_configuration. Pages unsets Store::i()->cms_menu in its own postUninstall(); do the same for yours.
  • Delete your files on a developer install. The applications/<dir>/ removal at the end is wrapped in if ( !CIC2 AND !IN_DEV AND !DEMO_MODE AND file_exists( … ) ), and every rmdir/unlink is error-suppressed with @.

Verified against

Read from Invision Community 5.0.19 source: system/Extensions/UninstallAbstract.php, system/Application/Application.php (delete(), extensions(), allExtensions(), constructExtensionClass(), applications(), appIsEnabled(), buildExtensionsJson()), applications/core/modules/admin/applications/applications.php (delete(), _deleteLegacyApp()), system/Content/Search/Index.php and system/Content/Search/Mysql/Index.php, and the three shipped implementations: cms/extensions/core/Uninstall/Databases.php, cms/extensions/core/Uninstall/Widgets.php and nexus/extensions/core/Uninstall/DisablePOP.php (which is an empty postUninstall() stub and does nothing). No plugin system exists in this version; onOtherUninstall() fires for applications only.


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.