diff --git a/TODO.md b/TODO.md index e4f247a..2d30e9d 100644 --- a/TODO.md +++ b/TODO.md @@ -94,7 +94,7 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - [x] Add canonical `/repos/:did/...` routing. - [ ] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. -- [ ] Add Identity Inspector. +- [x] Add Identity Inspector. - [ ] Add System Monitor with Jetstream first. - [ ] Add Network Map. - [ ] Add Archive Manager. diff --git a/src/lib/atproto/identity-inspector.test.ts b/src/lib/atproto/identity-inspector.test.ts new file mode 100644 index 0000000..7753820 --- /dev/null +++ b/src/lib/atproto/identity-inspector.test.ts @@ -0,0 +1,119 @@ +/** Move to test/inspector.test.ts */ +import { describe, expect, it } from 'vitest'; +import { + findHandleAlias, + findPdsEndpoint, + inspectIdentity, + normalizeHandle, + resolveDidDocumentUrl, + validateHandle +} from './identity-inspector'; +import type { DidDocument } from './types'; + +describe('identity inspector helpers', () => { + it('resolves supported DID document URLs', () => { + expect(resolveDidDocumentUrl('did:plc:xg2vq45muivyy3xwatcehspu')).toBe( + 'https://plc.directory/did%3Aplc%3Axg2vq45muivyy3xwatcehspu' + ); + expect(resolveDidDocumentUrl('did:web:example.com:user')).toBe('https://example.com/user/.well-known/did.json'); + }); + + it('normalizes handle aliases and PDS services', () => { + expect(normalizeHandle('@DesertThunder.Dev')).toBe('desertthunder.dev'); + expect(findHandleAlias(['at://desertthunder.dev'])).toBe('desertthunder.dev'); + expect( + findPdsEndpoint([ + { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' } + ]) + ).toBe('https://pds.example.com'); + }); + + it('classifies handle validation responses', async () => { + const client = { + get: async (name: string, _init?: unknown) => { + if (name === 'com.atproto.identity.resolveHandle') { + return { ok: true, data: { did: 'did:plc:example' } }; + } + + throw new Error('Unexpected XRPC call'); + } + }; + const fetcher = async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/.well-known/atproto-did')) { + return new Response('did:plc:other'); + } + + return Response.json({ Answer: [{ data: '"did=did:plc:example"' }] }); + }; + + const validation = await validateHandle('desertthunder.dev', 'did:plc:example', client, fetcher); + + expect(validation.resolveHandle.status).toBe('valid'); + expect(validation.wellKnown.status).toBe('mismatch'); + expect(validation.dnsTxt.status).toBe('valid'); + }); + + it('builds an inspection model from atcute DID resolution and PLC audit', async () => { + const didDocument: DidDocument = { + id: 'did:plc:example', + alsoKnownAs: ['at://desertthunder.dev'], + verificationMethod: [ + { + id: 'did:plc:example#atproto', + type: 'Multikey', + controller: 'did:plc:example', + publicKeyMultibase: 'zExample' + } + ], + service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }] + }; + const client = { + get: async (name: string, _init?: unknown) => { + if (name === 'com.atproto.identity.resolveDid') { + return { ok: true, data: { didDoc: didDocument } }; + } + + if (name === 'com.atproto.identity.resolveHandle') { + return { ok: true, data: { did: 'did:plc:example' } }; + } + + throw new Error('Unexpected XRPC call'); + } + }; + const fetcher = async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.endsWith('/log/audit')) { + return Response.json([ + { + cid: 'bafyold', + nullified: false, + createdAt: '2024-01-01T00:00:00.000Z', + operation: { rotationKeys: ['did:key:old'] } + }, + { + cid: 'bafynew', + nullified: false, + createdAt: '2024-02-01T00:00:00.000Z', + operation: { rotationKeys: ['did:key:new'] } + } + ]); + } + + if (url.includes('/.well-known/atproto-did')) { + return new Response('did:plc:example'); + } + + return Response.json({ Answer: [] }); + }; + + const inspection = await inspectIdentity('did:plc:example', null, { client, fetcher }); + + expect(inspection.handle).toBe('desertthunder.dev'); + expect(inspection.pds).toBe('https://pds.example.com'); + expect(inspection.rotationKeys).toEqual(['did:key:new']); + expect(inspection.handleValidation?.wellKnown.status).toBe('valid'); + }); +}); diff --git a/src/lib/atproto/identity-inspector.ts b/src/lib/atproto/identity-inspector.ts new file mode 100644 index 0000000..38b9c66 --- /dev/null +++ b/src/lib/atproto/identity-inspector.ts @@ -0,0 +1,270 @@ +/** TODO: this should be identity/inspector.ts */ +import { Client, simpleFetchHandler } from '@atcute/client'; +import type { Did, Handle } from '@atcute/lexicons/syntax'; +import type {} from '@atcute/atproto'; +import type { DidDocument, DidService, DidVerificationMethod } from './types'; + +export type IdentityValidationStatus = 'valid' | 'mismatch' | 'unavailable' | 'unsupported'; + +export type HandleValidation = { + handle: string; + resolveHandle: IdentityCheck; + wellKnown: IdentityCheck; + dnsTxt: IdentityCheck; +}; + +export type IdentityCheck = { status: IdentityValidationStatus; label: string; detail: string; value: string | null }; + +export type PlcAuditEntry = { + cid: string; + createdAt: string; + nullified: boolean; + operation: { + alsoKnownAs?: string[]; + rotationKeys?: string[]; + verificationMethods?: Record; + services?: Record; + }; +}; + +export type IdentityInspection = { + did: string; + handle: string | null; + didDocument: DidDocument; + aliases: string[]; + services: DidService[]; + verificationMethods: DidVerificationMethod[]; + rotationKeys: string[]; + pds: string | null; + didDocumentUrl: string; + plcAuditUrl: string | null; + handleValidation: HandleValidation | null; + rawJson: string; +}; + +type IdentityClient = { get: (name: string, init?: unknown) => Promise }; + +export type ResolveDidDocumentOptions = { client?: IdentityClient; fetcher?: typeof fetch }; + +type InspectIdentityOptions = ResolveDidDocumentOptions; + +const publicApi = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); +const publicIdentityApi = publicApi as unknown as IdentityClient; + +export async function inspectIdentity( + did: string, + fallbackHandle: string | null = null, + options: InspectIdentityOptions = {} +): Promise { + if (!did.startsWith('did:')) { + throw new Error('Identity Inspector requires a DID.'); + } + + const client = options.client ?? publicIdentityApi; + const fetcher = options.fetcher ?? fetch; + const didDocumentUrl = resolveDidDocumentUrl(did); + const didDocument = await resolveDidDocument(did, { client, fetcher }); + const aliases = didDocument.alsoKnownAs ?? []; + const handle = findHandleAlias(aliases) ?? normalizeHandle(fallbackHandle); + const services = didDocument.service ?? []; + const verificationMethods = didDocument.verificationMethod ?? []; + const pds = findPdsEndpoint(services); + const audit = did.startsWith('did:plc:') ? await fetchPlcAudit(did, fetcher).catch(() => []) : []; + const latestOperation = audit.at(-1)?.operation; + const rotationKeys = latestOperation?.rotationKeys ?? []; + + return { + did, + handle, + didDocument, + aliases, + services, + verificationMethods, + rotationKeys, + pds, + didDocumentUrl, + plcAuditUrl: did.startsWith('did:plc:') ? `${didDocumentUrl}/log/audit` : null, + handleValidation: handle ? await validateHandle(handle, did, client, fetcher) : null, + rawJson: JSON.stringify(didDocument, null, 2) + }; +} + +export async function validateHandle( + handle: string, + did: string, + client: IdentityClient = publicIdentityApi, + fetcher: typeof fetch = fetch +): Promise { + const normalizedHandle = normalizeHandle(handle); + + if (!normalizedHandle) { + throw new Error('Handle is empty.'); + } + + const [resolveHandle, wellKnown, dnsTxt] = await Promise.all([ + validateResolveHandle(normalizedHandle, did, client), + validateWellKnown(normalizedHandle, did, fetcher), + validateDnsTxt(normalizedHandle, did, fetcher) + ]); + + return { handle: normalizedHandle, resolveHandle, wellKnown, dnsTxt }; +} + +export function resolveDidDocumentUrl(did: string) { + if (did.startsWith('did:plc:')) { + return `https://plc.directory/${encodeURIComponent(did)}`; + } + + if (did.startsWith('did:web:')) { + const domain = did.slice('did:web:'.length).replaceAll(':', '/'); + return `https://${domain}/.well-known/did.json`; + } + + throw new Error(`Unsupported DID method: ${did}`); +} + +export function findPdsEndpoint(services: DidService[]) { + const pdsService = services.find( + (service) => service.id === '#atproto_pds' || service.type === 'AtprotoPersonalDataServer' + ); + const endpoint = pdsService?.serviceEndpoint; + + return typeof endpoint === 'string' ? endpoint : null; +} + +export function findHandleAlias(aliases: string[]) { + const alias = aliases.find((value) => value.startsWith('at://')); + return alias ? normalizeHandle(alias.slice('at://'.length)) : null; +} + +export function normalizeHandle(handle: string | null | undefined) { + const normalized = handle?.trim().replace(/^@/, '').toLowerCase(); + return normalized || null; +} + +export async function resolveDidDocument(did: string, options: ResolveDidDocumentOptions = {}): Promise { + const client = options.client ?? publicIdentityApi; + const fetcher = options.fetcher ?? fetch; + + try { + const result = unwrapXrpcData( + await client.get('com.atproto.identity.resolveDid', { params: { did: did as Did } }) + ) as { didDoc: unknown }; + return result.didDoc as DidDocument; + } catch { + const response = await fetcher(resolveDidDocumentUrl(did)); + + if (!response.ok) { + throw new Error(`Could not fetch DID document (${response.status}).`); + } + + return (await response.json()) as DidDocument; + } +} + +async function validateResolveHandle(handle: string, did: string, client: IdentityClient): Promise { + try { + const result = unwrapXrpcData( + await client.get('com.atproto.identity.resolveHandle', { params: { handle: handle as Handle } }) + ) as { did: string }; + const status = result.did === did ? 'valid' : 'mismatch'; + + return { + status, + label: 'XRPC resolveHandle', + detail: + status === 'valid' + ? 'Public ATProto resolver matches this DID.' + : 'Public ATProto resolver returned a different DID.', + value: result.did + }; + } catch { + return unavailableCheck('XRPC resolveHandle', 'Could not resolve this handle through public.api.bsky.app.'); + } +} + +async function validateWellKnown(handle: string, did: string, fetcher: typeof fetch): Promise { + try { + const response = await fetcher(`https://${handle}/.well-known/atproto-did`); + + if (!response.ok) { + return unavailableCheck('.well-known', `HTTP ${response.status} from the handle well-known endpoint.`); + } + + const value = (await response.text()).trim(); + const status = value === did ? 'valid' : 'mismatch'; + + return { + status, + label: '.well-known', + detail: + status === 'valid' + ? 'Handle well-known file asserts this DID.' + : 'Endpoint responded, but did not assert this DID.', + value: value.slice(0, 160) + }; + } catch { + return unavailableCheck('.well-known', 'Could not fetch the handle well-known endpoint.'); + } +} + +async function validateDnsTxt(handle: string, did: string, fetcher: typeof fetch): Promise { + try { + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_atproto.${handle}`)}&type=TXT`; + const response = await fetcher(url, { headers: { accept: 'application/dns-json' } }); + + if (!response.ok) { + return unavailableCheck('DNS TXT', `DNS-over-HTTPS returned HTTP ${response.status}.`); + } + + const data = (await response.json()) as { Answer?: Array<{ data?: string }> }; + const values = data.Answer?.map((answer) => answer.data ?? '').map(stripDnsTxtQuotes) ?? []; + const assertion = values.find((value) => value.startsWith('did=')); + + if (!assertion) { + return unavailableCheck('DNS TXT', 'No _atproto TXT DID assertion was found.'); + } + + const value = assertion.slice('did='.length); + const status = value === did ? 'valid' : 'mismatch'; + + return { + status, + label: 'DNS TXT', + detail: status === 'valid' ? 'DNS TXT record asserts this DID.' : 'DNS TXT record asserts a different DID.', + value + }; + } catch { + return unavailableCheck('DNS TXT', 'Could not query DNS-over-HTTPS for this handle.'); + } +} + +async function fetchPlcAudit(did: string, fetcher: typeof fetch): Promise { + const response = await fetcher(`${resolveDidDocumentUrl(did)}/log/audit`); + + if (!response.ok) { + return []; + } + + return (await response.json()) as PlcAuditEntry[]; +} + +function unavailableCheck(label: string, detail: string): IdentityCheck { + return { status: 'unavailable', label, detail, value: null }; +} + +function stripDnsTxtQuotes(value: string) { + return value.replace(/^"|"$/g, '').replaceAll('" "', ''); +} + +function unwrapXrpcData(response: unknown) { + if (typeof response !== 'object' || response === null) { + throw new Error('Invalid XRPC response.'); + } + + if (!('ok' in response) || response.ok !== true || !('data' in response)) { + throw new Error('XRPC request failed.'); + } + + return response.data; +} diff --git a/src/lib/atproto/identity.ts b/src/lib/atproto/identity.ts index 4c32be3..d723f88 100644 --- a/src/lib/atproto/identity.ts +++ b/src/lib/atproto/identity.ts @@ -3,9 +3,8 @@ import type { ActorIdentifier } from '@atcute/lexicons/syntax'; import type { Handle } from '@atcute/lexicons/syntax'; import type {} from '@atcute/atproto'; import type {} from '@atcute/bluesky'; -import type { AccountIdentity, ActorTypeaheadResult, DidDocument } from './types'; - -export type { AccountIdentity, ActorTypeaheadResult } from './types'; +import { findPdsEndpoint, resolveDidDocument } from './identity-inspector'; +import type { AccountIdentity, ActorTypeaheadResult } from './types'; export const defaultIdentity = { handle: 'desertthunder.dev', did: 'did:plc:xg2vq45muivyy3xwatcehspu' }; @@ -76,30 +75,6 @@ async function getPublicProfile(actor: string) { } async function resolvePds(did: string): Promise { - const response = await fetch(resolveDidDocumentUrl(did)); - - if (!response.ok) { - return null; - } - - const document = (await response.json()) as DidDocument; - const pdsService = document.service?.find( - (service) => service.id === '#atproto_pds' || service.type === 'AtprotoPersonalDataServer' - ); - const endpoint = pdsService?.serviceEndpoint; - - return typeof endpoint === 'string' ? endpoint : null; -} - -function resolveDidDocumentUrl(did: string) { - if (did.startsWith('did:plc:')) { - return `https://plc.directory/${encodeURIComponent(did)}`; - } - - if (did.startsWith('did:web:')) { - const domain = did.slice('did:web:'.length).replaceAll(':', '/'); - return `https://${domain}/.well-known/did.json`; - } - - throw new Error(`Unsupported DID method: ${did}`); + const document = await resolveDidDocument(did); + return findPdsEndpoint(document.service ?? []); } diff --git a/src/lib/atproto/routes.test.ts b/src/lib/atproto/routes.test.ts index de201e7..77f00d5 100644 --- a/src/lib/atproto/routes.test.ts +++ b/src/lib/atproto/routes.test.ts @@ -1,5 +1,6 @@ +/** Move to test/routes.test.ts */ import { describe, expect, it } from 'vitest'; -import { collectionPath, recordPath, repoPath } from './routes'; +import { collectionPath, identityPath, recordPath, repoPath } from './routes'; describe('ATProto route helpers', () => { it('builds canonical repo routes', () => { @@ -12,6 +13,10 @@ describe('ATProto route helpers', () => { ); }); + it('builds canonical identity inspector routes', () => { + expect(identityPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/identity'); + }); + it('encodes record keys in canonical record routes', () => { expect(recordPath({ did: 'did:plc:abc123', collection: 'app.bsky.feed.post', rkey: 'post/key' })).toBe( '/repos/did:plc:abc123/collections/app.bsky.feed.post/post%2Fkey' diff --git a/src/lib/atproto/routes.ts b/src/lib/atproto/routes.ts index 1814f22..97fcce0 100644 --- a/src/lib/atproto/routes.ts +++ b/src/lib/atproto/routes.ts @@ -6,6 +6,10 @@ export function repoPath(did: string) { return `/repos/${did}`; } +export function identityPath(did: string) { + return `${repoPath(did)}/identity`; +} + export function collectionPath({ did, collection }: CollectionRouteParams) { return `${repoPath(did)}/collections/${collection}`; } diff --git a/src/lib/atproto/types.ts b/src/lib/atproto/types.ts index 0f8a253..d99eeed 100644 --- a/src/lib/atproto/types.ts +++ b/src/lib/atproto/types.ts @@ -10,7 +10,24 @@ export type AccountIdentity = { export type ActorTypeaheadResult = Pick; export type DidDocument = { - service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record }>; + '@context'?: unknown; + id?: string; + alsoKnownAs?: string[]; + verificationMethod?: DidVerificationMethod[]; + service?: DidService[]; +}; + +export type DidService = { + id?: string; + type?: string; + serviceEndpoint?: string | string[] | Record; +}; + +export type DidVerificationMethod = { + id?: string; + type?: string; + controller?: string; + publicKeyMultibase?: string; }; export type CollectionSummary = { name: string; icon: string; appLabel: string | null; loadedCount: number | null }; diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index 74a1baa..4b38d43 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -3,7 +3,7 @@ import { onMount } from 'svelte'; import { accountSetup } from '$lib/atproto/setup.svelte'; import { repoBrowser } from '$lib/atproto/repo.svelte'; - import { recordPath } from '$lib/atproto/routes'; + import { identityPath, recordPath } from '$lib/atproto/routes'; import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; import { windowManager } from '$lib/window-manager.svelte'; import { SvelteMap } from 'svelte/reactivity'; @@ -57,11 +57,16 @@ } } + function openIdentityInspector() { + const identity = accountSetup.identity; + if (!identity) return; + + void goto(identityPath(identity.did), { keepFocus: true, noScroll: true }); + } + function groupCollections(collections: CollectionSummary[]) { - const groups = new SvelteMap< - string, - { namespace: string; label: string; icon: string; collections: CollectionSummary[] } - >(); + type M = { namespace: string; label: string; icon: string; collections: CollectionSummary[] }; + const groups = new SvelteMap(); for (const collection of collections) { const namespace = namespaceForCollection(collection.name); @@ -153,26 +158,36 @@ {/if}

- +
+ + +
@@ -188,9 +203,11 @@

{repoBrowser.isSearching ? 'Searching cached records…' : 'Loading records…'}

{:else if repoBrowser.records.length === 0}

- {repoBrowser.searchQuery - ? 'No cached records matched that search.' - : 'No public records found for this collection.'} + {#if repoBrowser.searchQuery} + No cached records matched that search. + {:else} + No public records found for this collection. + {/if}

{:else} {#each repoBrowser.records as record (record.uri)} @@ -378,6 +395,7 @@ border-bottom: 1px solid #b29366; } + .summary-controls, .search-box { display: grid; gap: var(--space-1); @@ -410,7 +428,8 @@ font-size: var(--text-1); } - .search-box button { + .search-box button, + .identity-launcher { padding: 0.35rem 0.55rem; color: #2c180d; background: linear-gradient(#fff8e8, #d3b17d); @@ -422,8 +441,22 @@ font-weight: 700; } + .identity-launcher { + display: inline-flex; + align-items: center; + justify-self: end; + gap: var(--space-1); + min-height: 1.9rem; + cursor: default; + } + + .identity-launcher:active { + scale: 0.96; + } + .search-box button:disabled, - .search-box input:disabled { + .search-box input:disabled, + .identity-launcher:disabled { opacity: 0.62; } diff --git a/src/lib/components/IdentityInspector.svelte b/src/lib/components/IdentityInspector.svelte new file mode 100644 index 0000000..a7e135b --- /dev/null +++ b/src/lib/components/IdentityInspector.svelte @@ -0,0 +1,599 @@ + + +
+ + +
+
+ + + {#if inspection?.pds} + + {/if} +
+ + {#if error} +
+ +

{error}

+
+ {:else if isLoading || !inspection} +
+ +

Unlocking public identity material...

+
+ {:else} +
+ +
+

Default public keyring

+

{inspection.handle ? `@${inspection.handle}` : inspection.did}

+ {inspection.did} +
+
+ + {#if selectedSection === 'overview'} +
+

Identity Properties

+
+ {@render PropertyRow('DID', inspection.did, copyValue, copiedValue === inspection.did)} + {@render PropertyRow( + 'Handle', + inspection.handle ?? 'No handle alias', + copyValue, + copiedValue === inspection.handle + )} + {@render PropertyRow('PDS', inspection.pds ?? 'Not advertised', copyValue, copiedValue === inspection.pds)} + {@render PropertyRow( + 'DID document', + inspection.didDocumentUrl, + copyValue, + copiedValue === inspection.didDocumentUrl + )} +
+ + {#if inspection.handleValidation} +
+ {#each [inspection.handleValidation.resolveHandle, inspection.handleValidation.wellKnown, inspection.handleValidation.dnsTxt] as check (check.label)} +
+ +
+ {check.label} +

{check.detail}

+ {#if check.value} + {check.value} + {/if} +
+ {statusLabel(check.status)} +
+ {/each} +
+ {/if} +
+ {:else if selectedSection === 'aliases'} +
+

Aliases

+
+ {#each inspection.aliases as alias (alias)} + {@render PropertyRow('alsoKnownAs', alias, copyValue, copiedValue === alias)} + {:else} +

No aliases are listed in the DID document.

+ {/each} +
+
+ {:else if selectedSection === 'services'} +
+

Services

+
+ {#each inspection.services as service, index (`${service.id}-${index}`)} +
+ +
+ {service.id ?? 'Unnamed service'} +

{service.type ?? 'Unknown service type'}

+ {typeof service.serviceEndpoint === 'string' ? service.serviceEndpoint : JSON.stringify(service.serviceEndpoint)} +
+
+ {:else} +

No services are listed in the DID document.

+ {/each} +
+
+ {:else if selectedSection === 'keys'} +
+

Verification Methods

+
+ {#each inspection.verificationMethods as method, index (`${method.id}-${index}`)} +
+ +
+ {method.id ?? 'Unnamed verification method'} +

{method.type ?? 'Unknown key type'}

+ {method.publicKeyMultibase ?? method.controller ?? 'No public key material'} +
+
+ {:else} +

No verification methods are listed in the DID document.

+ {/each} +
+ +

Rotation Keys

+
+ {#each inspection.rotationKeys as key (key)} + {@render PropertyRow('PLC rotation key', key, copyValue, copiedValue === key)} + {:else} +

No PLC rotation keys were available.

+ {/each} +
+
+ {:else} +
+

Raw DID Document

+
{inspection.rawJson}
+
+ {/if} + {/if} +
+
+ +{#snippet PropertyRow(label: string, value: string, oncopy: (value: string) => unknown, copied = false)} +
+
{label}
+
+ {value} + +
+
+{/snippet} + + diff --git a/src/lib/window-manager.svelte.ts b/src/lib/window-manager.svelte.ts index 97ddcf5..0ba6c11 100644 --- a/src/lib/window-manager.svelte.ts +++ b/src/lib/window-manager.svelte.ts @@ -1,4 +1,6 @@ -export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer'; +/* Split up this file into windows/types.ts & windows/manager.svelte.ts */ + +export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector'; export type ManagedWindow = { id: WindowId; @@ -47,10 +49,19 @@ class WindowManager { isMinimized: false, isMaximized: false, zIndex: 4 + }, + { + id: 'identity-inspector', + title: 'Identity Inspector', + icon: '/icons/humanity/apps/identity-inspector.svg', + isOpen: false, + isMinimized: false, + isMaximized: false, + zIndex: 5 } ]); - private nextZIndex = 4; + private nextZIndex = 5; get openWindows() { return this.windows.filter((window) => window.isOpen); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 725c55c..5e5e2e3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,3 +1,5 @@ +