mirror of
https://github.com/immich-app/immich.git
synced 2026-06-24 23:48:13 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1037fcc07e | |||
| db9dc73006 | |||
| c80303d4d5 | |||
| 4099fa6b4a | |||
| 11f61f23ba | |||
| 226fab849c | |||
| d39bd2e6cc | |||
| e4cf79263b |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -24,4 +24,5 @@ declare module 'main' {
|
||||
export function assetTimeline(): I32;
|
||||
// export function assetTrash(): I32;
|
||||
export function assetAddToAlbums(): I32;
|
||||
export function assetDataWebhook(): I32;
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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), '[]')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user