From e5b50a55a47bf435b412676f0ca530d82dd0a3e0 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 24 Jun 2026 20:15:28 +0600 Subject: [PATCH 1/5] fix(mobile): blank notifications page after enabling notifications (#29232) the old notification toggles were removed in a cleanup, so once notifications were enabled the page had nothing left and went blank. show a "notifications enabled" status tile with a shortcut to the system notification settings instead. --- i18n/en.json | 3 +++ mobile/lib/widgets/settings/notification_setting.dart | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/i18n/en.json b/i18n/en.json index 830b5f915b..1587fde604 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1507,6 +1507,9 @@ "notes": "Notes", "nothing_here_yet": "Nothing here yet", "notification_backup_reliability": "Enable notifications to improve background backup reliability", + "notification_enabled_list_tile_content": "Immich uses notifications for background backup. Manage them in your device settings.", + "notification_enabled_list_tile_open_button": "Open settings", + "notification_enabled_list_tile_title": "Notifications enabled", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index cbef5ea109..8b7c652925 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -48,6 +48,14 @@ class NotificationSetting extends HookConsumerWidget { showPermissionsDialog(); } }), + ) + else + SettingsButtonListTile( + icon: Icons.notifications_active_outlined, + title: 'notification_enabled_list_tile_title'.tr(), + subtileText: 'notification_enabled_list_tile_content'.tr(), + buttonText: 'notification_enabled_list_tile_open_button'.tr(), + onButtonTap: () => openAppSettings(), ), ]; From 08b2e2c0b5f3b29c1db09d8bf1ddca1a62877d09 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 11:18:37 -0500 Subject: [PATCH 2/5] fix(docs): Revert v3 bump (#29310) Revert "fix(docsc): v3 bump (#29246)" This reverts commit dc7d57ff9aec6be945697f4374c2b16e8377a9e8. --- docker/example.env | 2 +- docs/docs/install/environment-variables.md | 2 +- docs/docs/install/upgrading.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/example.env b/docker/example.env index 2403d69a85..6641cceaaa 100644 --- a/docker/example.env +++ b/docker/example.env @@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres # TZ=Etc/UTC # The Immich version to use. You can pin this to a specific version like "v2.1.0" -IMMICH_VERSION=v3 +IMMICH_VERSION=v2 # Connection secret for postgres. You should change it to a random password # Please use only the characters `A-Za-z0-9`, without special characters or spaces diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 5f0c888982..0932fa2855 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`. | Variable | Description | Default | Containers | | :----------------- | :------------------------------ | :-----: | :----------------------- | -| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning | +| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning | | `UPLOAD_LOCATION` | Host path for uploads | | server | | `DB_DATA_LOCATION` | Host path for Postgres database | | database | diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md index 8fc9113e6d..38fc056f80 100644 --- a/docs/docs/install/upgrading.md +++ b/docs/docs/install/upgrading.md @@ -29,7 +29,7 @@ docker image prune ## Versioning Policy Immich follows [semantic versioning][semver], which tags releases in the format `..`. We intend for breaking changes to be limited to major version releases. -You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`. +You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`. Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich. Switching back to an earlier version, even within the same minor release tag, is not supported. From 0931a19c5cc4d77b24ab792ffc965e18d3ae3f66 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:29:46 +0200 Subject: [PATCH 3/5] fix: run test suite for plugin changes (#29311) --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68d5c46e50..222ac1d794 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,6 +45,8 @@ jobs: - 'server/**' - 'pnpm-lock.yaml' - 'mise.toml' + - 'packages/plugin-core/**' + - 'packages/plugin-sdk/**' cli: - 'packages/cli/**' - 'packages/sdk/**' From 9751530af83d1ead5b708442faf4435ac25ca05e Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:22:35 +0200 Subject: [PATCH 4/5] feat: plugin wrapper type safety (#29300) --- packages/plugin-core/manifest.json | 11 +- packages/plugin-core/package.json | 2 +- packages/plugin-core/src/index.d.ts | 2 +- packages/plugin-core/src/index.ts | 46 +++--- packages/plugin-sdk/src/sdk.ts | 139 +++++++++++++------ server/src/repositories/plugin.repository.ts | 1 + 6 files changed, 127 insertions(+), 74 deletions(-) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 0f1b88827c..15d705b885 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -222,7 +222,16 @@ "name": "assetLock", "title": "Move to locked folder", "description": "Change visibility to locked", - "types": ["AssetV1"] + "types": ["AssetV1"], + "schema": { + "properties": { + "inverse": { + "title": "Inverse", + "description": "When true will unarchive any archived assets", + "type": "boolean" + } + } + } }, { "name": "assetTimeline", diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json index 26b5124426..baddf4a6eb 100644 --- a/packages/plugin-core/package.json +++ b/packages/plugin-core/package.json @@ -5,7 +5,7 @@ "main": "src/index.ts", "scripts": { "build": "pnpm build:tsc && pnpm build:wasm", - "build:tsc": "tsc --noEmit && node esbuild.js", + "build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js", "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" }, "keywords": [], diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts index 107bcc7aa0..636cb03047 100644 --- a/packages/plugin-core/src/index.d.ts +++ b/packages/plugin-core/src/index.d.ts @@ -22,6 +22,6 @@ declare module 'main' { export function assetArchive(): I32; export function assetLock(): I32; export function assetTimeline(): I32; - export function assetTrash(): I32; + // export function assetTrash(): I32; export function assetAddToAlbums(): I32; } diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index fa956b8cec..12eaab404b 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -1,13 +1,11 @@ -import { wrapper } from '@immich/plugin-sdk'; -import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import { getWrapper } from '@immich/plugin-sdk'; +import { AssetVisibility } from '@immich/sdk'; +import type manifestType from '../dist/manifest'; + +const wrapper = getWrapper(); -type AssetFileFilterConfig = { - pattern: string; - matchType?: 'contains' | 'exact' | 'regex' | 'startsWith'; - caseSensitive?: boolean; -}; export const assetFileFilter = () => { - return wrapper(({ data, config }) => { + return wrapper<'assetFileFilter'>(({ data, config }) => { const { pattern, matchType = 'contains', caseSensitive = false } = config; const { asset } = data; @@ -43,7 +41,7 @@ export const assetFileFilter = () => { }; export const assetMissingTimeZoneFilter = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => { const hasTimeZone = !!data.asset?.exifInfo?.timeZone; const needsTimeZone = config.inverse ? true : false; return { workflow: { continue: hasTimeZone === needsTimeZone } }; @@ -51,13 +49,7 @@ export const assetMissingTimeZoneFilter = () => { }; export const assetLocationFilter = () => { - return wrapper< - WorkflowType.AssetV1, - { - region?: { country?: string; state?: string; city?: string }; - coordinate?: { latitude?: string; longitude?: string; radius?: number }; - } - >(({ config, data }) => { + return wrapper<'assetLocationFilter'>(({ config, data }) => { if ( (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || @@ -96,13 +88,13 @@ export const assetLocationFilter = () => { }; export const assetTypeFilter = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetTypeFilter'>(({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }); }; export const assetFavorite = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetFavorite'>(({ config, data }) => { const target = config.inverse ? false : true; if (target !== data.asset.isFavorite) { return { @@ -115,13 +107,13 @@ export const assetFavorite = () => { }; export const assetVisibility = () => { - return wrapper(({ config }) => ({ - changes: { asset: { visibility: config.visibility } }, + return wrapper<'assetVisibility'>(({ config }) => ({ + changes: { asset: { visibility: config.visibility as AssetVisibility } }, })); }; export const assetArchive = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetArchive'>(({ config, data }) => { if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { return { changes: { asset: { visibility: AssetVisibility.Archive } } }; } @@ -135,7 +127,7 @@ export const assetArchive = () => { }; export const assetLock = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetLock'>(({ config, data }) => { if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { return { changes: { asset: { visibility: AssetVisibility.Locked } } }; } @@ -148,13 +140,13 @@ export const assetLock = () => { }); }; -export const assetTrash = () => { - // TODO use trash/untrash host functions - return wrapper(() => ({})); -}; +// export const assetTrash = () => { +// // TODO use trash/untrash host functions +// return wrapper(() => ({})); +// }; export const assetAddToAlbums = () => { - return wrapper(({ config, data, functions }) => { + return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { const assetId = data.asset.id; if (config.albumIds.length === 0) { diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts index 5b0443513a..e428eafed5 100644 --- a/packages/plugin-sdk/src/sdk.ts +++ b/packages/plugin-sdk/src/sdk.ts @@ -1,53 +1,104 @@ import type { WorkflowType } from '@immich/sdk'; import { hostFunctions } from 'src/host-functions.js'; import type { - ConfigValue, WorkflowEventPayload, WorkflowResponse, WorkflowStepConfig, } from 'src/types.js'; -export const wrapper = < - T extends WorkflowType, - TConfig extends ConfigValue = ConfigValue, ->( - fn: ( - payload: WorkflowEventPayload & { - functions: ReturnType; - }, - ) => WorkflowResponse | undefined, -) => { - const input = Host.inputString(); - - try { - const payload = JSON.parse(input) as WorkflowEventPayload; - const event = { - ...payload, - functions: hostFunctions(payload.workflow.authToken), - }; - - const eventConfigBefore = JSON.stringify(event.config); - - console.debug( - `Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`, - ); - - const response = fn(event) ?? {}; - - // if config changed, notify host - const eventConfigAfter = JSON.stringify(event.config); - if (!response.config && eventConfigBefore !== eventConfigAfter) { - response.config = event.config as WorkflowStepConfig; - } - - console.debug( - `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`, - ); - - const output = JSON.stringify(response); - Host.outputString(output); - } catch (error: Error | any) { - console.error(`Unhandled plugin exception: ${error.message || error}`); - throw error; - } +type Property = { + type: 'string' | 'boolean' | 'number'; + array?: boolean; + enum?: string[]; +} & { + type: 'object'; + properties: { [K: string]: Property }; + required?: string[]; }; + +type RequiredProperties< + Properties extends { [K: string]: unknown }, + Required extends string[] | undefined, + RequiredKeys extends string = Required extends undefined + ? never + : NonNullable[number], +> = { + properties: Pick & + Partial>; +}; + +type GetConfigType = 'enum' extends keyof T + ? NonNullable[number] + : T['type'] extends 'boolean' + ? boolean + : T['type'] extends 'number' + ? number + : T['type'] extends 'string' + ? string + : T['type'] extends 'object' + ? ConfigValue + : never; + +type ConfigValue< + T extends { properties: { [K: string]: Property }; required?: string[] }, + Properties extends { [K: string]: Property } = T['properties'], +> = T extends never + ? never + : RequiredProperties< + { + [K in keyof Properties]: Properties[K]['array'] extends true + ? Array> + : GetConfigType; + }, + 'required' extends keyof T ? T['required'] : undefined + >['properties']; + +export const getWrapper = + >() => + < + K extends T['methods'][number]['name'], + L extends WorkflowType = (T['methods'][number] & { + name: K; + })['types'][number], + TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>, + >( + fn: ( + payload: WorkflowEventPayload & { + functions: ReturnType; + }, + ) => WorkflowResponse | undefined, + ) => { + const input = Host.inputString(); + + try { + const payload = JSON.parse(input) as WorkflowEventPayload; + const event = { + ...payload, + functions: hostFunctions(payload.workflow.authToken), + }; + + const eventConfigBefore = JSON.stringify(event.config); + + console.debug( + `Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`, + ); + + const response = fn(event) ?? {}; + + // if config changed, notify host + const eventConfigAfter = JSON.stringify(event.config); + if (!response.config && eventConfigBefore !== eventConfigAfter) { + response.config = event.config as WorkflowStepConfig; + } + + console.debug( + `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`, + ); + + const output = JSON.stringify(response); + Host.outputString(output); + } catch (error: Error | any) { + console.error(`Unhandled plugin exception: ${error.message || error}`); + throw error; + } + }; diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index 43006c6aa6..154266b965 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -224,6 +224,7 @@ export class PluginRepository { error: (message) => logger.error(message), } as Console, logLevel: asExtismLogLevel(logger.getLogLevel()), + enableWasiOutput: true, }, ), destroy: (plugin) => plugin.close(), From 4099fa6b4a3c9a91489cd0d77cbf2a7118477946 Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 25 Jun 2026 03:48:01 +0200 Subject: [PATCH 5/5] fix(mobile): app doesn't exit full-screen mode (#29301) * fix(mobile): app doesn't exit full-screen mode * chore: rename restoreSystemUI to restoreEdgeToEdge --- .../lib/presentation/pages/drift_memory.page.dart | 5 +++-- .../presentation/pages/drift_slideshow.page.dart | 5 +++-- .../widgets/asset_viewer/asset_viewer.page.dart | 9 ++++----- mobile/lib/utils/system_ui.utils.dart | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 mobile/lib/utils/system_ui.utils.dart diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 6919925d55..f601bf8419 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -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), diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 4fae0709aa..596b6fdf36 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -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 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 with Si } void _onTapUp() async { - await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge); + await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge()); WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index c8d8f63fa9..8b9dc6f887 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -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 { _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + unawaited(restoreEdgeToEdge()); super.dispose(); } @@ -251,10 +252,8 @@ class _AssetViewerState extends ConsumerState { } 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 diff --git a/mobile/lib/utils/system_ui.utils.dart b/mobile/lib/utils/system_ui.utils.dart new file mode 100644 index 0000000000..0d6d65082f --- /dev/null +++ b/mobile/lib/utils/system_ui.utils.dart @@ -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 restoreEdgeToEdge() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); +}