diff --git a/src/lib/components/ContainerIcon.svelte b/src/lib/components/ContainerIcon.svelte index 2658699..67cc0c3 100644 --- a/src/lib/components/ContainerIcon.svelte +++ b/src/lib/components/ContainerIcon.svelte @@ -4,7 +4,7 @@ import { appSettings } from '$lib/stores/settings'; import { selfhstMatcher, ensureSelfhstMatcher } from '$lib/stores/selfhst-refs'; import { selfhstIcons, requestSelfhst } from '$lib/stores/selfhst-icons'; - import { isSelfhstIcon, selfhstRef, isCustomIcon, getStackIconComponent } from '$lib/utils/icons'; + import { isSelfhstIcon, selfhstRef, isCustomIcon, getStackIconComponent, isDockhandImage } from '$lib/utils/icons'; interface Props { image: string; @@ -62,6 +62,10 @@ // An explicit override wins over auto-matching (and the app-logo toggle). const hasOverride = $derived(!!override); + + // Dockhand's own container always shows the app mark (unless the user overrode it), + // regardless of the app-logo toggle - it is our own branding, not a selfh.st match. + const isSelf = $derived(!hasOverride && isDockhandImage(image)); const overrideSelfhst = $derived(selfhstRef(override)); const overrideCustom = $derived(isCustomIcon(override)); const customIconName = $derived(overrideKey ?? name); @@ -100,7 +104,9 @@ const resolvedSelfhst = $derived(activeSelfhst ? $selfhstIcons[activeSelfhst] : undefined); -{#if activeSelfhst && resolvedSelfhst} +{#if isSelf} + Dockhand +{:else if activeSelfhst && resolvedSelfhst} {:else if activeSelfhst && resolvedSelfhst === undefined} diff --git a/src/lib/utils/icons.ts b/src/lib/utils/icons.ts index 3754d89..a06f822 100644 --- a/src/lib/utils/icons.ts +++ b/src/lib/utils/icons.ts @@ -56,6 +56,25 @@ export function selfhstRef(icon: string | null | undefined): string | null { return isSelfhstIcon(icon) ? icon!.slice('selfhst:'.length) : null; } +/** + * Whether an image is Dockhand's own image, so its container shows the app mark. + * Matches the `dockhand` repo under the `fnsys`/`finsys` org from any registry, + * ignoring the tag/digest (e.g. `fnsys/dockhand:v1.0.47`, + * `docker.io/finsys/dockhand`, `registry.bor6.pl/dockhand@sha256:...`). + */ +export function isDockhandImage(image: string | null | undefined): boolean { + if (!image) return false; + const path = image.split('@')[0].split('/'); // drop digest, split path + // The tag lives only in the LAST segment (after the last '/'); a registry + // host may carry a ':port' earlier, so strip the tag from the last segment only. + const name = path[path.length - 1].split(':')[0]; + if (name !== 'dockhand') return false; + const org = path.length >= 2 ? path[path.length - 2] : ''; + // The official org, or a private-registry host (`registry.example/dockhand`). + // A bare or unqualified `/dockhand` is NOT matched - too broad. + return org === 'fnsys' || org === 'finsys' || org.includes('.') || org.includes(':'); +} + export { iconMap }; // --- Stack icon set (SEPARATE from env; app/service-oriented) -------------------- diff --git a/tests/is-dockhand-image.test.ts b/tests/is-dockhand-image.test.ts new file mode 100644 index 0000000..c001803 --- /dev/null +++ b/tests/is-dockhand-image.test.ts @@ -0,0 +1,51 @@ +/** + * isDockhandImage - whether a container image is Dockhand's own, so its + * container renders the app mark instead of an auto-matched / generic icon. + */ + +import { describe, test, expect } from 'bun:test'; +import { isDockhandImage } from '../src/lib/utils/icons'; + +describe('isDockhandImage - matches Dockhand images', () => { + test('official org, with tag', () => { + expect(isDockhandImage('fnsys/dockhand:v1.0.47')).toBe(true); + }); + test('official org, no tag', () => { + expect(isDockhandImage('fnsys/dockhand')).toBe(true); + }); + test('finsys spelling variant', () => { + expect(isDockhandImage('finsys/dockhand:latest')).toBe(true); + }); + test('docker.io fully qualified', () => { + expect(isDockhandImage('docker.io/fnsys/dockhand:v1.0.47')).toBe(true); + }); + test('private registry host / dockhand', () => { + expect(isDockhandImage('registry.bor6.pl/dockhand:0a9de97e')).toBe(true); + }); + test('private registry with port', () => { + expect(isDockhandImage('registry.bor6.pl:5000/dockhand')).toBe(true); + }); + test('digest pinned', () => { + expect(isDockhandImage('fnsys/dockhand@sha256:abc123')).toBe(true); + }); +}); + +describe('isDockhandImage - does NOT match', () => { + test('unrelated image', () => { + expect(isDockhandImage('postgres:15.2')).toBe(false); + }); + test('a different app named dockhand under someone else\'s Docker Hub org', () => { + // A bare `/dockhand` on Docker Hub (org has no dot/port) is too ambiguous + // to claim as ours - only the official org or a registry host qualifies. + expect(isDockhandImage('someuser/dockhand')).toBe(false); + }); + test('substring in a longer name is not a match', () => { + expect(isDockhandImage('fnsys/dockhand-agent')).toBe(false); + expect(isDockhandImage('fnsys/not-dockhand')).toBe(false); + }); + test('empty / null / undefined', () => { + expect(isDockhandImage('')).toBe(false); + expect(isDockhandImage(null)).toBe(false); + expect(isDockhandImage(undefined)).toBe(false); + }); +});