diff --git a/bun.lock b/bun.lock
index fce5e4f50..583c1d993 100644
--- a/bun.lock
+++ b/bun.lock
@@ -43,7 +43,7 @@
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@placeos/cloud-uploads": "^1.1.1",
- "@placeos/ts-client": "^6.3.1",
+ "@placeos/ts-client": "^6.5.0",
"@playwright/test": "^1.36.0",
"@schematics/angular": "22.0.0",
"@sentry/browser": "^10.25.0",
@@ -876,7 +876,7 @@
"@placeos/cloud-uploads": ["@placeos/cloud-uploads@1.1.1", "", { "peerDependencies": { "rxjs": "^7.8.2", "ts-md5": "^2.0.1" } }, "sha512-krF2RiuX4u0MkHBhm+DkrxWa91pMHmiWs3M4tuFpFDEsnrpbvPVvVPDs1EwqyLib5D6d/vxS6an+TcMTGfA+tg=="],
- "@placeos/ts-client": ["@placeos/ts-client@6.3.1", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-rT86XaIx3JI8K6003vaZXN44mP/dGNhs5VeylEbfYQlUKC0gV/Ku12oAWFq9a5Iis+YHxb2Uh9beWS0bDVo0eg=="],
+ "@placeos/ts-client": ["@placeos/ts-client@6.5.0", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-M6rvhk9So3vCFxlvqcvP7SpajFdrkikxICCFK323cQopx0iuApiIB1v4UNrQHl1L5lUvQzm0O09zFqpGViv+Cg=="],
"@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="],
diff --git a/package.json b/package.json
index 978371060..d9842c8a5 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@placeos/cloud-uploads": "^1.1.1",
- "@placeos/ts-client": "^6.3.1",
+ "@placeos/ts-client": "^6.5.0",
"@playwright/test": "^1.36.0",
"@schematics/angular": "22.0.0",
"@sentry/browser": "^10.25.0",
diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json
index 556b0241b..7b3f9c22b 100644
--- a/public/assets/locale/en-AU.json
+++ b/public/assets/locale/en-AU.json
@@ -1164,6 +1164,12 @@
"TAB_SIGNAGE_PLUGINS": "Signage Plugins",
"SIGNAGE_PLUGINS_HEADER": "Signage Plugins",
"SIGNAGE_PLUGINS_ADD": "Add Plugin",
+ "SIGNAGE_PLUGINS_IMPORT": "Import Plugin from Repository",
+ "SIGNAGE_PLUGINS_IMPORT_REPOSITORY": "Frontend repository",
+ "SIGNAGE_PLUGINS_IMPORT_SELECT_REPOSITORY": "Select a frontend repository to find its plugins.",
+ "SIGNAGE_PLUGINS_IMPORT_SEARCHING": "Finding plugins...",
+ "SIGNAGE_PLUGINS_IMPORT_EMPTY": "No plugins found in this repository.",
+ "SIGNAGE_PLUGINS_IMPORT_ERROR": "Error finding plugins. Error: {{ error }}",
"SIGNAGE_PLUGINS_EDIT": "Edit Signage Plugin",
"SIGNAGE_PLUGINS_NEW": "New Signage Plugin",
"SIGNAGE_PLUGINS_REMOVE": "Remove Plugin",
diff --git a/src/app/admin/signage-plugins/signage-plugin-embed.component.ts b/src/app/admin/signage-plugins/signage-plugin-embed.component.ts
index 5f7f1f8d5..fbbb8839b 100644
--- a/src/app/admin/signage-plugins/signage-plugin-embed.component.ts
+++ b/src/app/admin/signage-plugins/signage-plugin-embed.component.ts
@@ -16,7 +16,7 @@ import { SignagePlugin } from '@placeos/ts-client';
import { AsyncHandler } from '../../common/async-handler.class';
import { SafePipe } from '../../ui/pipes/safe.pipe';
-const API_VERSION = 'signage-plugin/v1';
+export const SIGNAGE_PLUGIN_API_VERSION = 'signage-plugin/v1';
/** Resolve a plugin URI the same way as the iframe element. */
export function resolveSignagePluginUrl(
@@ -49,6 +49,8 @@ export type PluginLoadedPayload = {
plugin: {
name: string;
version: string;
+ /** Not sent by plugins built before plugin types were added */
+ type?: SignagePlugin['plugin_type'];
};
capabilities: {
requires_play_signal: boolean;
@@ -136,7 +138,7 @@ export class SignagePluginEmbedComponent
payload: PluginConfigPayload | null = null,
) {
this._plugin_el()?.nativeElement?.contentWindow.postMessage(
- { api: API_VERSION, type, payload },
+ { api: SIGNAGE_PLUGIN_API_VERSION, type, payload },
this.plugin_origin(),
);
}
@@ -155,7 +157,11 @@ export class SignagePluginEmbedComponent
return;
const msg = event.data;
- if (!msg || msg.api !== API_VERSION || typeof msg.type !== 'string')
+ if (
+ !msg ||
+ msg.api !== SIGNAGE_PLUGIN_API_VERSION ||
+ typeof msg.type !== 'string'
+ )
return;
this.status.set(msg.type);
diff --git a/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts b/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts
new file mode 100644
index 000000000..53c4e65d7
--- /dev/null
+++ b/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts
@@ -0,0 +1,213 @@
+import { Component, computed, inject, OnInit, signal } from '@angular/core';
+import { MatRippleModule } from '@angular/material/core';
+import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatProgressBarModule } from '@angular/material/progress-bar';
+import { MatSelectModule } from '@angular/material/select';
+import {
+ listRepositoryFiles,
+ PlaceRepository,
+ PlaceRepositoryType,
+ queryRepositories,
+ SignagePlugin,
+} from '@placeos/ts-client';
+
+import { AsyncHandler } from '../../common/async-handler.class';
+import { i18n } from '../../common/locale.service';
+import { notifyError } from '../../common/notifications';
+import { IconComponent } from '../../ui/icon.component';
+import { TranslatePipe } from '../../ui/translate.pipe';
+import { PluginLoadedPayload } from './signage-plugin-embed.component';
+import {
+ forEachWithLimit,
+ probeSignagePlugin,
+} from './signage-plugins.utilities';
+
+/** Number of plugin files loaded at the same time */
+const PROBE_LIMIT = 6;
+
+interface PluginProbe {
+ uri: string;
+ status: 'loading' | 'valid' | 'invalid';
+ details?: PluginLoadedPayload;
+}
+
+/**
+ * Wizard to find the plugins in an interface repository.
+ * Closes with the selected plugin (name, URI and type) as `SignagePlugin`,
+ * or `undefined` when cancelled.
+ */
+@Component({
+ selector: 'signage-plugin-import-modal',
+ template: `
+
+
+ {{ 'ADMIN.SIGNAGE_PLUGINS_IMPORT' | translate }}
+
+
+
+
+
+
+ @for (repo of repositories(); track repo.id) {
+
+ {{ repo.name }}
+
+ }
+
+
+ = 100"
+ />
+
+ @for (probe of plugins(); track probe.uri) {
+
+ } @empty {
+
+ {{ empty_message() | translate }}
+
+ }
+
+
+ `,
+ styles: [``],
+ imports: [
+ IconComponent,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatProgressBarModule,
+ MatRippleModule,
+ MatSelectModule,
+ TranslatePipe,
+ ],
+})
+export class SignagePluginImportModalComponent
+ extends AsyncHandler
+ implements OnInit
+{
+ private _dialog_ref =
+ inject>(
+ MatDialogRef,
+ );
+
+ public readonly repositories = signal([]);
+ public readonly repository_id = signal('');
+ public readonly loading_files = signal(false);
+ public readonly probes = signal([]);
+ public readonly plugins = computed(() =>
+ this.probes().filter((probe) => probe.status === 'valid'),
+ );
+ public readonly progress = computed(() => {
+ const probes = this.probes();
+ if (!probes.length) return 100;
+ const done = probes.filter((_) => _.status !== 'loading').length;
+ return (done / probes.length) * 100;
+ });
+ public readonly empty_message = computed(() => {
+ if (!this.repository_id())
+ return 'ADMIN.SIGNAGE_PLUGINS_IMPORT_SELECT_REPOSITORY';
+ if (this.loading_files() || this.progress() < 100)
+ return 'ADMIN.SIGNAGE_PLUGINS_IMPORT_SEARCHING';
+ return 'ADMIN.SIGNAGE_PLUGINS_IMPORT_EMPTY';
+ });
+
+ public async ngOnInit() {
+ try {
+ const { data } = await queryRepositories({ limit: 1000 });
+ this.repositories.set(
+ data.filter(
+ (repo) => repo.repo_type === PlaceRepositoryType.Interface,
+ ),
+ );
+ } catch (err) {
+ this._notifyError(err);
+ }
+ }
+
+ /** Find the HTML files in the repository and check which are plugins */
+ public async selectRepository(id: string) {
+ const controller = new AbortController();
+ // Replacing the subscription aborts the probes of the previous repository
+ this.subscription('probes', () => controller.abort());
+ this.repository_id.set(id);
+ this.probes.set([]);
+ this.loading_files.set(true);
+ let files: string[] = [];
+ try {
+ files = await listRepositoryFiles(id, { pattern: '*.html' });
+ } catch (err) {
+ if (!controller.signal.aborted) this._notifyError(err);
+ }
+ if (controller.signal.aborted) return;
+ this.loading_files.set(false);
+ this.probes.set(files.map((uri) => ({ uri, status: 'loading' })));
+ await forEachWithLimit(files, PROBE_LIMIT, async (uri) => {
+ const details = await probeSignagePlugin(uri, {
+ signal: controller.signal,
+ });
+ if (controller.signal.aborted) return;
+ this.probes.update((list) =>
+ list.map((probe) =>
+ probe.uri === uri
+ ? {
+ uri,
+ details,
+ status: details ? 'valid' : 'invalid',
+ }
+ : probe,
+ ),
+ );
+ });
+ }
+
+ public selectPlugin(probe: PluginProbe) {
+ this._dialog_ref.close(
+ new SignagePlugin({
+ name: probe.details.plugin.name,
+ uri: probe.uri,
+ plugin_type: probe.details.plugin.type,
+ }),
+ );
+ }
+
+ private _notifyError(err: { response?: unknown; message?: string }) {
+ notifyError(
+ i18n('ADMIN.SIGNAGE_PLUGINS_IMPORT_ERROR', {
+ error: JSON.stringify(err.response || err.message || err),
+ }),
+ );
+ }
+}
diff --git a/src/app/admin/signage-plugins/signage-plugins.component.ts b/src/app/admin/signage-plugins/signage-plugins.component.ts
index 14e144c42..159607ec4 100644
--- a/src/app/admin/signage-plugins/signage-plugins.component.ts
+++ b/src/app/admin/signage-plugins/signage-plugins.component.ts
@@ -18,6 +18,7 @@ import { openConfirmModal } from '../../overlays/confirm-modal.component';
import { IconComponent } from '../../ui/icon.component';
import { SimpleTableComponent } from '../../ui/simple-table.component';
import { TranslatePipe } from '../../ui/translate.pipe';
+import { SignagePluginImportModalComponent } from './signage-plugin-import-modal.component';
import { SignagePluginModalComponent } from './signage-plugin-modal.component';
import { SignagePluginTestModalComponent } from './signage-plugin-test-modal.component';
@@ -30,6 +31,21 @@ import { SignagePluginTestModalComponent } from './signage-plugin-test-modal.com
{{ 'ADMIN.SIGNAGE_PLUGINS_HEADER' | translate }}
+