144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
import {
|
|
contextualizeToolboxLink,
|
|
fetchToolboxAppManifest,
|
|
fetchToolboxCatalog,
|
|
requireSameOrigin,
|
|
resolveToolboxApp,
|
|
resolveWebUrl,
|
|
type ToolboxCatalogEntry,
|
|
type ToolboxCatalogExternalEntry,
|
|
type ToolboxCatalogManifestEntry,
|
|
} from '@add-ideas/toolbox-contract';
|
|
import type { LoadedCatalogue, ResolvedApp, UnavailableApp } from './types';
|
|
|
|
function isInternal(
|
|
entry: ToolboxCatalogEntry
|
|
): entry is ToolboxCatalogManifestEntry {
|
|
return 'manifest' in entry;
|
|
}
|
|
|
|
function fallbackId(entry: ToolboxCatalogExternalEntry, entryUrl: URL): string {
|
|
const slug = entry.name
|
|
.toLocaleLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
let hash = 2166136261;
|
|
for (const character of `${entry.name}\0${entryUrl.href}`) {
|
|
hash ^= character.codePointAt(0) ?? 0;
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return `external:${slug || 'tool'}:${(hash >>> 0).toString(36)}`;
|
|
}
|
|
|
|
export async function loadCatalogue(
|
|
catalogueHref: string,
|
|
signal?: AbortSignal
|
|
): Promise<LoadedCatalogue> {
|
|
const pageUrl = resolveWebUrl(document.baseURI);
|
|
const catalogueUrl = requireSameOrigin(
|
|
resolveWebUrl(catalogueHref, pageUrl),
|
|
pageUrl,
|
|
'Toolbox catalogue'
|
|
);
|
|
const catalogue = await fetchToolboxCatalog(catalogueUrl, {
|
|
signal,
|
|
expectedOrigin: pageUrl,
|
|
});
|
|
const enabled = catalogue.apps.filter((entry) => entry.enabled);
|
|
const results = await Promise.all(
|
|
enabled.map(
|
|
async (
|
|
entry
|
|
): Promise<{ app?: ResolvedApp; unavailable?: UnavailableApp }> => {
|
|
if (!isInternal(entry)) {
|
|
const entryUrl = resolveWebUrl(entry.entry, catalogueUrl);
|
|
return {
|
|
app: {
|
|
id: fallbackId(entry, entryUrl),
|
|
name: entry.name,
|
|
description: 'External tool',
|
|
entryUrl: entryUrl.href,
|
|
categories: [],
|
|
tags: [],
|
|
launchModes: [entry.launch],
|
|
isExternal: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
const manifestUrl = requireSameOrigin(
|
|
resolveWebUrl(entry.manifest, catalogueUrl),
|
|
pageUrl,
|
|
'Application manifest'
|
|
);
|
|
try {
|
|
const manifest = await fetchToolboxAppManifest(manifestUrl, {
|
|
signal,
|
|
expectedOrigin: pageUrl,
|
|
});
|
|
const resolved = resolveToolboxApp(manifest, manifestUrl, pageUrl);
|
|
return {
|
|
app: {
|
|
id: manifest.id,
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
description: manifest.description,
|
|
entryUrl: resolved.entryUrl.href,
|
|
iconUrl: resolved.iconUrl.href,
|
|
categories: manifest.categories,
|
|
tags: manifest.tags,
|
|
launchModes: manifest.integration.launchModes,
|
|
isExternal: false,
|
|
manifestUrl: resolved.manifestUrl.href,
|
|
sourceUrl: manifest.source?.repository,
|
|
requirements: manifest.requirements,
|
|
privacy: manifest.privacy,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (signal?.aborted) throw error;
|
|
return {
|
|
unavailable: {
|
|
id: `unavailable:${manifestUrl.href}`,
|
|
name: manifestUrl.pathname.split('/').at(-2) ?? 'Unknown app',
|
|
manifestUrl: manifestUrl.href,
|
|
reason:
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Unknown manifest error.',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
)
|
|
);
|
|
|
|
const apps = results.flatMap((result) => (result.app ? [result.app] : []));
|
|
const ids = new Set<string>();
|
|
for (const app of apps) {
|
|
if (ids.has(app.id)) throw new Error(`Duplicate app id: ${app.id}.`);
|
|
ids.add(app.id);
|
|
}
|
|
|
|
return {
|
|
catalogue,
|
|
catalogueUrl,
|
|
homeUrl: requireSameOrigin(
|
|
resolveWebUrl(catalogue.home, catalogueUrl),
|
|
pageUrl,
|
|
'Toolbox home'
|
|
).href,
|
|
apps,
|
|
unavailable: results.flatMap((result) =>
|
|
result.unavailable ? [result.unavailable] : []
|
|
),
|
|
};
|
|
}
|
|
|
|
export function contextualLaunchUrl(
|
|
entryHref: string,
|
|
catalogueHref: string
|
|
): string {
|
|
return contextualizeToolboxLink(entryHref, catalogueHref);
|
|
}
|