Compare commits

...

8 Commits

Author SHA1 Message Date
Ben Beckford 1037fcc07e chore: update workflow method wrapper type 2026-06-24 21:40:27 -07:00
Ben Beckford db9dc73006 Merge branch 'main' into feat/workflow-webhooks 2026-06-24 21:37:17 -07:00
Ben Beckford c80303d4d5 feat(server): allow plugins to specify allowed hostnames 2026-06-24 21:17:45 -07:00
Yaros 4099fa6b4a fix(mobile): app doesn't exit full-screen mode (#29301)
* fix(mobile): app doesn't exit full-screen mode

* chore: rename restoreSystemUI to restoreEdgeToEdge
2026-06-24 20:48:01 -05:00
Ben Beckford 11f61f23ba Merge branch 'main' into feat/workflow-webhooks 2026-06-23 13:00:07 -07:00
Ben Beckford 226fab849c chore: use extism http in workflow webhook method 2026-06-23 12:58:59 -07:00
Ben Beckford d39bd2e6cc feat: support PUT in webhook action 2026-06-23 11:14:58 -07:00
Ben Beckford e4cf79263b feat: webhook workflow action 2026-06-22 00:01:04 -07:00
15 changed files with 111 additions and 13 deletions
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@@ -49,7 +50,7 @@ class DriftMemoryPage extends HookConsumerWidget {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
};
});
@@ -328,7 +329,7 @@ class DriftMemoryPage extends HookConsumerWidget {
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -76,7 +77,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -255,7 +256,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge());
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
@@ -128,7 +129,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -251,10 +252,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
final immersive = !controls || (CurrentPlatform.isIOS && details);
unawaited(immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge());
}
@override
+14
View File
@@ -0,0 +1,14 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// Restore the system bars and return to edge-to-edge layout.
///
/// On Android 15+/API 36 edge-to-edge is enforced, so calling
/// setEnabledSystemUIMode(edgeToEdge) does NOT re-show bars that an immersive
/// mode (immersive / immersiveSticky) previously hid. Explicitly request all
/// overlays first, then return to edge-to-edge layout.
Future<void> restoreEdgeToEdge() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
+35
View File
@@ -5,6 +5,7 @@
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasmPath": "dist/plugin.wasm",
"allowedHosts": ["*"],
"templates": [
{
"name": "screenshots-smart-album",
@@ -300,6 +301,40 @@
"required": ["albumIds"]
}
},
{
"name": "assetDataWebhook",
"title": "Trigger Webhook",
"description": "POST/PUT event data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
},
"headerValue": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
},
"method": {
"type": "string",
"title": "Method",
"description": "The HTTP method to use in the request",
"enum": ["POST", "PUT"]
}
},
"required": ["url"]
}
},
{
"name": "noop1",
"title": "DEV: Nested properties",
+1
View File
@@ -24,4 +24,5 @@ declare module 'main' {
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
export function assetDataWebhook(): I32;
}
+18
View File
@@ -173,3 +173,21 @@ export const assetAddToAlbums = () => {
return {};
});
};
export const assetDataWebhook = () => {
return wrapper<'assetDataWebhook'>(({ config, data }) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (config.headerName && config.headerValue) {
headers.set(config.headerName, config.headerValue);
}
fetch(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify(data.asset),
headers,
});
return {};
});
};
+1 -1
View File
@@ -4,7 +4,7 @@
"declaration": true,
"emitDeclarationOnly": true,
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
"lib": ["es2020", "DOM"], // Specify a list of library files to be included in the compilation
"module": "nodenext", // Specify module code generation
"moduleResolution": "nodenext",
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
@@ -33,6 +33,11 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
type HttpRequestOptions = {
method?: string;
headers?: Record<string, string>;
body?: string;
};
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
+1
View File
@@ -58,6 +58,7 @@ const PluginManifestSchema = z
wasmPath: z.string().min(1).describe('WASM file path'),
author: z.string().min(1).describe('Plugin author'),
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
allowedHosts: z.array(z.string()).optional().default([]).describe('Hostnames the plugin can access'),
templates: z
.array(PluginManifestTemplateSchema)
.optional()
+5
View File
@@ -6,6 +6,7 @@ select
"plugin"."name",
"plugin"."version",
"plugin"."wasmBytes",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -36,6 +37,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -72,6 +74,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -108,6 +111,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -144,6 +148,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
+6 -1
View File
@@ -20,6 +20,7 @@ export type PluginHostFunction = (callContext: CallContext, input: bigint) => Pr
export type PluginLoadOptions = {
runInWorker?: boolean;
functions?: Record<string, PluginHostFunction>;
allowedHosts?: string[];
};
export type PluginMethodSearchResponse = {
@@ -60,6 +61,7 @@ export class PluginRepository {
'plugin.name',
'plugin.version',
'plugin.wasmBytes',
'plugin.allowedHosts',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -82,6 +84,7 @@ export class PluginRepository {
'plugin.createdAt',
'plugin.updatedAt',
'plugin.templates',
'plugin.allowedHosts',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -159,6 +162,7 @@ export class PluginRepository {
wasmBytes: eb.ref('excluded.wasmBytes'),
templates: eb.ref('excluded.templates'),
sha256hash: eb.ref('excluded.sha256hash'),
allowedHosts: eb.ref('excluded.allowedHosts'),
})),
)
.returning(['id', 'name'])
@@ -202,7 +206,7 @@ export class PluginRepository {
});
}
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions }: PluginLoadOptions) {
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions, allowedHosts }: PluginLoadOptions) {
const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength);
const logger = LoggingRepository.create(`Plugin:${label}`);
const pool = createPool<ExtismPlugin>(
@@ -216,6 +220,7 @@ export class PluginRepository {
functions: {
'extism:host/user': functions ?? {},
},
allowedHosts,
logger: {
trace: (message) => logger.verbose(message),
info: (message) => logger.log(message),
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" DROP COLUMN "allowedHosts";`.execute(db);
}
+3
View File
@@ -43,6 +43,9 @@ export class PluginTable {
@Column({ type: 'bytea' })
sha256hash!: Buffer;
@Column({ type: 'character varying', default: [], array: true })
allowedHosts!: Generated<string[]>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@@ -90,7 +90,7 @@ export class WorkflowExecutionService extends BaseService {
};
const plugins = await this.pluginRepository.getForLoad();
for (const { id, name, version, wasmBytes, methods } of plugins) {
for (const { id, name, version, wasmBytes, methods, allowedHosts } of plugins) {
const method = methods.some(({ hostFunctions }) => !hostFunctions);
if (method) {
const label = `${name}@${version}`;
@@ -108,7 +108,7 @@ export class WorkflowExecutionService extends BaseService {
const label = `${name}@${version}/worker`;
const key = this.getPluginKey({ id, hostFunctions: true });
try {
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions });
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions, allowedHosts });
this.logger.log(`Loaded plugin with host functions: ${label}`);
} catch (error) {
this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error);
@@ -214,6 +214,7 @@ export class WorkflowExecutionService extends BaseService {
author: manifest.author,
version: manifest.version,
templates: manifest.templates,
allowedHosts: manifest.allowedHosts,
wasmBytes,
sha256hash,
},