From 8adeb59e630f5ddd463251a74d208aa6040d9f6f Mon Sep 17 00:00:00 2001 From: macro88 <88macro@proton.me> Date: Mon, 9 Nov 2026 21:18:30 +0800 Subject: [PATCH 1/2] Put publisher and access first in napplet launch review --- docs/published-open.md | 14 +- src/napplets/discovery-open-broker.ts | 3 +- src/napplets/first-open-consent.ts | 4 +- src/napplets/launch-review-media.ts | 29 ++ src/napplets/verified-launch-metadata.ts | 34 +++ src/routing/routing-review.ts | 3 +- src/security/trusted-identity-owner.ts | 13 +- src/shell/OpenPublishedNapplet.tsx | 44 +-- src/shell/PublishedLaunchReview.tsx | 256 ++++++++++++++++++ src/shell/RoutingReview.tsx | 70 ++--- tests/napplets/first-open-consent.test.ts | 5 +- .../napplets/verified-launch-metadata.test.ts | 69 +++++ tests/native/changed_access_driver.py | 12 +- tests/native/driver.py | 38 +++ .../native/identity-data/android-published.py | 2 +- .../ConfigurationTests.swift | 4 +- .../HypergolicUITests/FeedQueryTests.swift | 2 +- .../LinkPublishedTests.swift | 2 +- .../HypergolicUITests/NotificationTests.swift | 2 +- .../HypergolicUITests/RoutingTests.swift | 6 +- .../HypergolicUITests/UploadTests.swift | 2 +- .../HypergolicUITests/WorkspaceTests.swift | 29 +- tests/native/links/android-published.py | 4 +- tests/native/live_published_driver.py | 12 +- tests/native/live_update_driver.py | 26 +- tests/native/presentation/android.py | 2 +- tests/native/presentation/config-android.py | 2 +- .../presentation/config-namespace-android.py | 2 +- .../native/presentation/lifecycle-android.py | 2 +- .../notify-publisher-physical-android.py | 2 +- tests/native/published_update_driver.py | 12 +- tests/native/routing/android.py | 2 +- tests/native/routing/protocol.py | 2 +- tests/native/routing/settings.py | 4 +- tests/native/test_changed_access_driver.py | 8 +- tests/native/test_live_update_driver.py | 7 +- tests/native/test_published_update_driver.py | 1 + tests/native/upload/android-published.py | 2 +- tests/runtime/discovery-controller.test.ts | 1 + tests/runtime/discovery-open.test.ts | 7 +- 40 files changed, 579 insertions(+), 162 deletions(-) create mode 100644 src/napplets/launch-review-media.ts create mode 100644 src/napplets/verified-launch-metadata.ts create mode 100644 src/shell/PublishedLaunchReview.tsx create mode 100644 tests/napplets/verified-launch-metadata.test.ts diff --git a/docs/published-open.md b/docs/published-open.md index 441b70e..cc86c91 100644 --- a/docs/published-open.md +++ b/docs/published-open.md @@ -48,11 +48,15 @@ and [NAP-LINK](https://github.com/napplet/naps/blob/100e5c939ae6b1ea82cc58587c6d event and checks the caller's expected ID before fetching the HTML. The loader refuses same-time conflicts and checks the original bytes against the signed manifest. -3. The shell presents the actual caller, target publisher and `d`, signed event ID, - verified artifact version, and required and optional capabilities, including - unavailable ones. The same review obtains first-open or changed access consent - when needed. Hypergolic saves approved access under the usual identity, publisher - and app scope. A later launch failure may leave that approved access in place. +3. The shell presents the requesting catalog, the target’s signed name and + description, its signing publisher, and the required and optional capabilities. + Unavailable capabilities show which features may be missing. Public profile data + comes from signed metadata by that publisher. The profile and the signed artwork + load separately from the decision. Technical details expose the full publisher + key, publication reference, signed event ID and verified hashes. Settings uses + the same layout for first-open access review. Hypergolic saves approved access + under the usual identity, publisher and app scope. A later launch failure may + leave that approved access in place. 4. Approval permits the exact reviewed artifact. The host reuses a matching mounted view or stages the verified artifact through the ordinary one-use native transfer. A different saved revision of the same publication coordinate yields a conflict diff --git a/src/napplets/discovery-open-broker.ts b/src/napplets/discovery-open-broker.ts index 0b60811..7dcd40d 100644 --- a/src/napplets/discovery-open-broker.ts +++ b/src/napplets/discovery-open-broker.ts @@ -1,3 +1,4 @@ +import { verifiedLaunchMetadata } from './verified-launch-metadata.ts'; import { ReviewBusyError } from '../shell/review-slot.ts'; import { naddrEncode } from 'nostr-tools/nip19'; import { resolveNappletLink, type NappletCoordinate } from './resolve-link.ts'; @@ -173,7 +174,7 @@ async function openExact(ports: Ports, launch: Launch, attempt: Attempt, active: const requiredDomains = Object.freeze([...manifest.requiredDomains].sort()); const optionalDomains = Object.freeze([...manifest.optionalDomains].sort()); const { unsupportedDomains, unsupportedOptionalDomains } = publishedCapabilityCompatibility(requiredDomains, optionalDomains); - const review = reviewLaunch(ports, launch, Object.freeze({ publisher, appId, eventId, version, + const review = reviewLaunch(ports, launch, Object.freeze({ publisher, appId, eventId, version, metadata: verifiedLaunchMetadata(artifact), requiredDomains, optionalDomains, unsupportedDomains, unsupportedOptionalDomains }), attempt, active); let next: NappletDescriptor; if (loaded) { diff --git a/src/napplets/first-open-consent.ts b/src/napplets/first-open-consent.ts index fc04bed..5132850 100644 --- a/src/napplets/first-open-consent.ts +++ b/src/napplets/first-open-consent.ts @@ -4,6 +4,7 @@ import type { AppStorageControl } from '../storage/database.ts'; import type { TrustedApp } from '../storage/ports.ts'; import { reviewedCapabilityCompatibility, type ReviewedSupportProfile } from '../runtime/support-report.ts'; import {verifiedPublicationIdentity} from './verified-publication-identity.ts'; +import { verifiedLaunchMetadata, type VerifiedLaunchMetadata } from './verified-launch-metadata.ts'; import type {SnapshotIdentity} from './published-identity.ts'; export class FirstOpenConsentError extends Error { @@ -16,6 +17,7 @@ export type NappletConsentReview = Readonly<{ publisher: string; appId: string; eventId: string; + metadata: VerifiedLaunchMetadata; snapshot?: SnapshotIdentity; domains: readonly string[]; unsupportedDomains: readonly string[]; @@ -118,7 +120,7 @@ export async function requestFirstOpenConsent( } if (!accepted) { - const request = Object.freeze({ user, publisher, appId, eventId, ...(publication.kind===5129?{snapshot:publication.snapshot}:{}), + const request = Object.freeze({ user, publisher, appId, eventId, metadata: verifiedLaunchMetadata(artifact), ...(publication.kind===5129?{snapshot:publication.snapshot}:{}), domains: requestedDomains, unsupportedDomains, optionalDomains, unsupportedOptionalDomains }); let approved = false; diff --git a/src/napplets/launch-review-media.ts b/src/napplets/launch-review-media.ts new file mode 100644 index 0000000..1884b1b --- /dev/null +++ b/src/napplets/launch-review-media.ts @@ -0,0 +1,29 @@ +import { base64 } from '@scure/base'; +import type { TrustedIdentityDataPort } from '../identity/identity-service.ts'; +import type { ProfileAvatarReader } from '../identity/profile-avatar-image.ts'; +import { classifyResourceBytes } from '../resources/resource-mime.ts'; +import type { PublishedArtifactSource } from './loader.ts'; +import type { PublishedLaunchIcon } from './verified-launch-metadata.ts'; + +export type LaunchReviewMedia = Readonly<{ + readProfile: TrustedIdentityDataPort['readProfile']; + readAvatar: ProfileAvatarReader; + readIcon(icon: PublishedLaunchIcon, signal: AbortSignal): Promise; +}>; + +const MAX_ICON_BYTES = 768 * 1024; + +export function createLaunchReviewMedia(profiles: Pick, readAvatar: ProfileAvatarReader, + blobs: Pick, servers: () => readonly string[]): LaunchReviewMedia { + return Object.freeze({ + readProfile: profiles.readProfile, + readAvatar, + async readIcon(icon: PublishedLaunchIcon, signal: AbortSignal) { + const mirrors = [...new Set([...icon.serverHints, ...servers()])]; + // The published transport bounds the read and verifies the signed Blossom hash. + const bytes = await blobs.readHtml(icon.hash, mirrors, MAX_ICON_BYTES, signal); + if (signal.aborted || classifyResourceBytes(bytes) !== icon.mime) throw new Error('Icon unavailable'); + return `data:${icon.mime};base64,${base64.encode(bytes)}`; + }, + }); +} diff --git a/src/napplets/verified-launch-metadata.ts b/src/napplets/verified-launch-metadata.ts new file mode 100644 index 0000000..20c3ce5 --- /dev/null +++ b/src/napplets/verified-launch-metadata.ts @@ -0,0 +1,34 @@ +import { naddrEncode, neventEncode } from 'nostr-tools/nip19'; +import { assertVerifiedArtifact, type VerifiedNappletArtifact } from './verified-artifact.ts'; +import { verifiedPublicationIdentity } from './verified-publication-identity.ts'; + +const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); +const DISPLAY_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u206f]/gu; + +export type PublishedLaunchIcon = Readonly<{ hash: string; mime: string; serverHints: readonly string[] }>; +export type VerifiedLaunchMetadata = Readonly<{ + title: string; + description: string; + icon?: PublishedLaunchIcon; + reference: string; + version: string; + htmlHash: string; +}>; + +export function verifiedLaunchMetadata(artifact: VerifiedNappletArtifact): VerifiedLaunchMetadata { + assertVerifiedArtifact(artifact); + const { manifest, htmlHash, aggregateHash: version } = artifact; + const { title, appId, kind } = verifiedPublicationIdentity(manifest); + const reference = kind === 5129 ? neventEncode({ id: manifest.eventId, kind, author: manifest.pubkey }) + : naddrEncode({ kind, pubkey: manifest.pubkey, identifier: appId }); + const descriptions = manifest.tags.filter(tag => tag[0] === 'description'); + const [legacyDescription] = descriptions; + const legacyText = descriptions.length === 1 && legacyDescription?.length === 2 ? legacyDescription[1]! : ''; + const description = (manifest.format === 'legacy-path' ? legacyText : manifest.content) + .replace(DISPLAY_CONTROLS, '').trim().slice(0, 8192); + const icons = manifest.tags.filter(tag => tag[0] === 'icon'); + const [icon] = icons; + const artwork = icons.length === 1 && icon?.length === 3 && /^[0-9a-f]{64}$/.test(icon[1]!) && IMAGE_MIME_TYPES.has(icon[2]!) + ? Object.freeze({ hash: icon[1]!, mime: icon[2]!, serverHints: manifest.serverHints }) : undefined; + return Object.freeze({ title, description, ...(artwork && { icon: artwork }), reference, version, htmlHash }); +} diff --git a/src/routing/routing-review.ts b/src/routing/routing-review.ts index d68ae22..9387c68 100644 --- a/src/routing/routing-review.ts +++ b/src/routing/routing-review.ts @@ -1,5 +1,6 @@ import { ReviewBusyError, type ReviewSlot } from '../shell/review-slot.ts'; import type { NappletDescriptor } from '../shell/workspace.ts'; +import type { VerifiedLaunchMetadata } from '../napplets/verified-launch-metadata.ts'; import type { NappletConsentReview } from '../napplets/first-open-consent.ts'; import type { NativeRegistration } from '../runtime/capability-protocol.ts'; import type { IntentRequest } from './routing-contract.ts'; @@ -9,7 +10,7 @@ export type RoutingReviewRequest = Readonly<{ kind: 'intent'; origin: NativeRegi | Readonly<{ kind: 'channel'; origin: NativeRegistration; peer: NativeRegistration }> | Readonly<{ kind: 'permissions'; origin: NativeRegistration; consent: NappletConsentReview }> | Readonly<{ kind: 'published-open'; origin: NativeRegistration; source: NappletDescriptor; changesAccess: boolean; legacyReadOnlyAvailable?: true; - target: Readonly<{ publisher: string; appId: string; eventId: string; version: string; + target: Readonly<{ publisher: string; appId: string; eventId: string; version: string; metadata: VerifiedLaunchMetadata; requiredDomains: readonly string[]; optionalDomains: readonly string[]; unsupportedDomains: readonly string[]; unsupportedOptionalDomains: readonly string[] }> }>; export type RoutingDecision = Readonly<{ allow: boolean; selected?: CatalogHandler; remember?: boolean; legacyReadOnly?: true }>; diff --git a/src/security/trusted-identity-owner.ts b/src/security/trusted-identity-owner.ts index c4ddf50..fe014d7 100644 --- a/src/security/trusted-identity-owner.ts +++ b/src/security/trusted-identity-owner.ts @@ -1,3 +1,4 @@ +import { createLaunchReviewMedia, type LaunchReviewMedia } from '../napplets/launch-review-media'; import { createPublicPreferenceImport } from '../routing/public-preference-import'; import {withCvmRelayDefaults} from '../cvm/cvm-relay-defaults'; import {CvmService} from '../cvm/cvm-service'; @@ -61,6 +62,7 @@ import { assertWorkspaceAvailability, initialTestWorkspace } from '../shell/fixt export interface TrustedIdentityOwner { readonly profileAvatar?: ProfileAvatarStore; + readonly launchReviewMedia?: LaunchReviewMedia; readonly notify?: NotifyBroker; readonly notificationSettings?: NotificationSettingsService; readonly config?: ConfigBroker; @@ -82,7 +84,7 @@ export interface TrustedIdentityOwner { export class WorkspaceStartupError extends Error { constructor() { super('Workspace could not be opened'); this.name = 'WorkspaceStartupError'; } } -async function openOwnerWorkspace(vault: NativeIdentityVault, platform: 'android' | 'ios'): Promise<{ cvm:CvmService; profileAvatar: ProfileAvatarStore; notificationSettings: NotificationSettingsService; config: ConfigBroker; notify: NotifyBroker; routing: Readonly<{broker: RoutingBroker; catalog: RoutingCatalog; reviews: RoutingReviewService; settings: RoutingSettingsService}>; listAccess: IdentityListAccess | null; transition: IdentityTransition; runtime: RuntimeOwner; approvals: ApprovalService; relaySettings: NativeRelaySettings | null; resourceSettings: NativeResourceSettings | null; uploadSettings: NativeUploadSettings | null; uploads: UploadService; reviewSlot: ReviewSlot; fixturePublisher: FixturePublisher }> { +async function openOwnerWorkspace(vault: NativeIdentityVault, platform: 'android' | 'ios'): Promise<{ cvm:CvmService; profileAvatar: ProfileAvatarStore; launchReviewMedia: LaunchReviewMedia; notificationSettings: NotificationSettingsService; config: ConfigBroker; notify: NotifyBroker; routing: Readonly<{broker: RoutingBroker; catalog: RoutingCatalog; reviews: RoutingReviewService; settings: RoutingSettingsService}>; listAccess: IdentityListAccess | null; transition: IdentityTransition; runtime: RuntimeOwner; approvals: ApprovalService; relaySettings: NativeRelaySettings | null; resourceSettings: NativeResourceSettings | null; uploadSettings: NativeUploadSettings | null; uploads: UploadService; reviewSlot: ReviewSlot; fixturePublisher: FixturePublisher }> { const database = await openShellDatabase(SQLite, platform).catch(() => { throw new WorkspaceStartupError(); }); let relaySettings: NativeRelaySettings | null = null; let resourceSettings: NativeResourceSettings | null = null; @@ -141,18 +143,21 @@ async function openOwnerWorkspace(vault: NativeIdentityVault, platform: 'android relays:() => relaySettings?.getSettings().networkRelays ?? [],slot}); const routing = Object.freeze({broker: routingBroker, catalog: routingCatalog, reviews, settings: createRoutingSettings(transition, routingNative, routingCatalog, routingBroker,publicPreferences)}); const identityData = createIdentityDataService(identityCache, queries, Date.now, listAccess ?? undefined); - const profileAvatar = createProfileAvatarStore(identityData, createProfileAvatarReader(requireNativeModule('HypergolicNappletHost'))); + const readAvatar = createProfileAvatarReader(requireNativeModule('HypergolicNappletHost')); + const profileAvatar = createProfileAvatarStore(identityData, readAvatar); + const publishedSource = loadNativePublishedArtifactSource(); + const launchReviewMedia = createLaunchReviewMedia(identityData, readAvatar, publishedSource, () => resourceSettings?.servers() ?? []); releaseAvatar = bindProfileAvatarOwner(transition, profileAvatar); runtime = createRuntimeOwner(database, transition, capability, { service: approvals, destinations: () => relaySettings?.getSettings().networkRelays ?? [], closeReview: id => slot.close(id) }, - { sqlite: SQLite, source: loadNativePublishedArtifactSource(), native: loadNativePublishedTransferPort(), + { sqlite: SQLite, source: publishedSource, native: loadNativePublishedTransferPort(), lookupRelays: () => relaySettings?.getSettings().lookupRelays ?? [], networkRelays: () => relaySettings?.getSettings().networkRelays ?? [] }, queries, loadNativeRelaySubscriptions(() => relaySettings?.getSettings().networkRelays ?? []), loadNativeResourceHost(() => resourceSettings?.servers() ?? [], queries), uploads, identityData, routing, notifyBroker, configBroker, outbox, loadNativeOutboxSubscriptions(outbox), discovery,cvm); const fixturePublisher=createNativeFixturePublisher(vault,transition); - return { cvm, profileAvatar, fixturePublisher, notificationSettings: createNotificationSettings(transition, routingNative, notifyBroker), config: configBroker, notify: notifyBroker, routing, listAccess, transition, approvals, relaySettings, resourceSettings, uploadSettings, uploads, reviewSlot, runtime }; + return { cvm, profileAvatar, launchReviewMedia, fixturePublisher, notificationSettings: createNotificationSettings(transition, routingNative, notifyBroker), config: configBroker, notify: notifyBroker, routing, listAccess, transition, approvals, relaySettings, resourceSettings, uploadSettings, uploads, reviewSlot, runtime }; } catch { cvm?.dispose(); releaseAvatar?.(); releaseLinks?.(); releaseRouting?.(); routingBroker?.stop(); notifyBroker?.stop(); configBroker?.stop(); reviewSlot?.dispose(); try { await notifyPermissions?.close(); } catch { /* Preserve the workspace failure. */ } diff --git a/src/shell/OpenPublishedNapplet.tsx b/src/shell/OpenPublishedNapplet.tsx index bdd76d3..6ed3eb7 100644 --- a/src/shell/OpenPublishedNapplet.tsx +++ b/src/shell/OpenPublishedNapplet.tsx @@ -1,9 +1,6 @@ -import { MEDIA_ACCESS_DETAIL } from '../runtime/support-report'; -import { TrustedBand } from './TrustedBand'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PublishedLaunchReview } from './PublishedLaunchReview'; import { useEffect, useRef, useState } from 'react'; -import { ActivityIndicator, Keyboard, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { ShellModal as Modal } from './ShellModal'; +import { ActivityIndicator, Keyboard, StyleSheet, Text, View } from 'react-native'; import { ShellPressable as Pressable } from './ShellPressable'; import { ShellTextInput as TextInput } from './ShellTextInput'; import type { SettingsActions } from './Shell'; @@ -55,7 +52,9 @@ export function OpenPublishedNapplet({ open }: { open: SettingsActions['openPubl if (!controller.signal.aborted) setBusy(false); }); }; - if (review) return ; + if (review) return decide(true)} onCancel={() => decide(false)} + ids={reviewIds} />; return Open a napplet Paste a napplet address. We’ll check its signed version, then show you who published it and what access it needs. @@ -72,38 +71,9 @@ export function OpenPublishedNapplet({ open }: { open: SettingsActions['openPubl ; } -function OpenSourceReview({review}: {review:NappletConsentReview}) { - const {styles} = useShellStyles(createStyles); - return <> - Publisher{review.publisher} - {review.snapshot ? 'Snapshot (kind 5129)' : 'Napplet'}{review.snapshot?.title ?? review.appId} - Signed event{review.eventId} - ; -} - -function OpenAccessReview({review, decide}: {review: NappletConsentReview; decide(accepted: boolean): void}) { - const { styles } = useShellStyles(createStyles); - const insets = useSafeAreaInsets(); - return decide(false)}> - Allow this napplet? - - {(review.domains.includes('resource') || review.optionalDomains.includes('resource')) && Resource access allows bounded downloads from public HTTPS sites and your configured Blossom servers. Files stay in memory while this napplet is loaded.} - {(review.domains.includes('media') || review.optionalDomains.includes('media')) && {MEDIA_ACCESS_DETAIL}} - {(review.domains.includes('upload') || review.optionalDomains.includes('upload')) && Upload access allows this napplet to read files you select. Every upload still needs your separate approval of the file, identity and server.} - {review.optionalDomains.length ? 'Required access · ' : 'Requested access: '}{review.domains.join(', ') || 'none'} - {review.optionalDomains.length > 0 && Optional access · {review.optionalDomains.join(', ')}} - {(review.domains.includes('keys') || review.optionalDomains.includes('keys')) && - Keys lets this napplet use hardware shortcuts while it has focus.} - {review.unsupportedDomains.length > 0 && Required capabilities unavailable · {review.unsupportedDomains.join(', ')}. The napplet can open with limited features.} - {review.unsupportedOptionalDomains.length > 0 && Optional capabilities unavailable · {review.unsupportedOptionalDomains.join(', ')}} - decide(true)} style={styles.action}>Allow and open - decide(false)} style={styles.secondary}>Cancel - ; -} +const reviewIds = { review: 'settings-napplet-review', approve: 'settings-napplet-approve', cancel: 'settings-napplet-reject' }; const createStyles = (colors: ShellPalette, fonts: ShellFonts) => StyleSheet.create({ - backdrop: { flex: 1, justifyContent: 'flex-end', backgroundColor: colors.shade }, - sheet: { maxHeight: '92%', backgroundColor: colors.surface }, disabled: { opacity: 0.45 }, addressLabel: { fontFamily: fonts.mono, fontSize: 11, letterSpacing: 1.2, textTransform: 'uppercase', color: colors.muted }, group: { gap: 12 }, title: { fontFamily: fonts.display, color: colors.text, fontSize: 28, }, @@ -112,8 +82,6 @@ const createStyles = (colors: ShellPalette, fonts: ShellFonts) => StyleSheet.cre input: { fontFamily: fonts.body, color: colors.text, borderWidth: 1, borderColor: colors.border, borderRadius: 0, minHeight: 56, padding: 16, fontSize: 16 }, action: { padding: 18, minHeight: 56, borderRadius: 0, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, actionText: { textTransform: 'uppercase', letterSpacing: 1.8, fontFamily: fonts.strong, color: colors.onPrimary, fontSize: 14, }, - review: { gap: 16, padding: 24 }, - claim: { fontFamily: fonts.mono, color: colors.text, fontSize: 12 }, secondary: { padding: 18, minHeight: 56, borderRadius: 0, borderColor: colors.border, borderWidth: 1, alignItems: 'center' }, secondaryText: { textTransform: 'uppercase', letterSpacing: 1.8, fontFamily: fonts.strong, color: colors.text, fontSize: 14 }, }); diff --git a/src/shell/PublishedLaunchReview.tsx b/src/shell/PublishedLaunchReview.tsx new file mode 100644 index 0000000..ee8e73c --- /dev/null +++ b/src/shell/PublishedLaunchReview.tsx @@ -0,0 +1,256 @@ +import { useContext, useEffect, useRef, useState, type ReactNode } from 'react'; +import { Image } from 'expo-image'; +import { AccessibilityInfo, AppState, ScrollView, StyleSheet, Text, View, type TextInstance } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { boundedPublishedTitle, snapshotEventId } from '../napplets/published-identity'; +import type { VerifiedLaunchMetadata } from '../napplets/verified-launch-metadata'; +import { MEDIA_ACCESS_DETAIL } from '../runtime/support-report'; +import { formatNpub } from '../security/public-identity'; +import { OwnerContext } from './IdentityOwnerContext'; +import { ShellModal } from './ShellModal'; +import { ShellPressable } from './ShellPressable'; +import { TrustedBand } from './TrustedBand'; +import { useShellStyles, type ShellFonts, type ShellPalette } from './theme'; + +type ReviewTarget = Readonly<{ + metadata: VerifiedLaunchMetadata; + publisher: string; + eventId: string; + appId: string; + requiredDomains: readonly string[]; + optionalDomains: readonly string[]; + unsupportedDomains: readonly string[]; + unsupportedOptionalDomains: readonly string[]; +}>; + +type LaunchSource = Readonly<{ label: string; publisher?: string }>; + +const capabilityCopy = new Map([ + ['shell', ['Shell connection', 'Connect to Hypergolic and check which capabilities are available.']], + ['identity', ['Public identity', 'Read your public account information. Some public lists need separate approval.']], + ['storage', ['App storage', 'Save data for this napplet under your selected identity.']], + ['theme', ['Appearance', 'Follow Hypergolic’s light or dark theme.']], + ['relay', ['Nostr relays', 'Read events and ask to publish through your configured relays.']], + ['resource', ['Resources', 'Download bounded public resources through Hypergolic. Files stay in memory while this napplet is open.']], + ['upload', ['File uploads', 'Ask to upload files you choose to Blossom. Each upload needs your approval.']], + ['link', ['Web links', 'Ask to open public web links using your device.']], + ['intent', ['Actions in other napplets', 'Ask an installed napplet to perform an action. Each dispatch needs your approval.']], + ['inc', ['Messages between napplets', 'Exchange messages with approved peers while Hypergolic is in the foreground.']], + ['notify', ['Notifications', 'Show notifications inside Hypergolic.']], + ['config', ['Settings', 'Use and request changes to this napplet’s settings in Hypergolic.']], + ['outbox', ['Outbox routing', 'Read Nostr events or ask to publish using the relays associated with the accounts involved.']], + ['keys', ['Keyboard shortcuts', 'Use hardware shortcuts while this napplet has focus.']], + ['media', ['Media playback', MEDIA_ACCESS_DETAIL]], + ['cvm', ['External tools', 'Ask to send encrypted requests to free tools. Hypergolic asks you to review the provider and what is sent.']], + ['common', ['Nostr utilities', 'Use common Nostr tools. Profile lookup, public lists and social actions may need additional access.']], +]); + +function CapabilityGroup({ kind, domains, unavailable }: { kind: 'required' | 'optional'; domains: readonly string[]; unavailable: readonly string[] }) { + const { styles } = useShellStyles(createStyles); + if (domains.length === 0) return null; + const missing = new Set(unavailable); + return + {kind === 'required' ? 'Required capabilities' : 'Optional capabilities'} + {domains.map(domain => { + const [name, description] = capabilityCopy.get(domain) ?? [domain, 'Declared by the publisher.']; + return + {name} + {description} + {missing.has(domain) && Unavailable in Hypergolic} + ; + })} + ; +} + +function RequestedAccess({ target, changesAccess }: { target: ReviewTarget; changesAccess: boolean }) { + const { styles } = useShellStyles(createStyles); + const { requiredDomains, optionalDomains, unsupportedDomains, unsupportedOptionalDomains } = target; + return + Requested access + {requiredDomains.length === 0 && optionalDomains.length === 0 && No capabilities requested} + + {unsupportedDomains.length > 0 && + This napplet can open with limited features. Features that need unavailable capabilities will be missing. + } + + {unsupportedOptionalDomains.length > 0 && Features that use unavailable optional capabilities will be missing.} + {changesAccess && requiredDomains.length + optionalDomains.length > 0 && + Hypergolic saves the available access for this publisher and napplet under your selected identity. + } + ; +} + +function useLaunchArtwork({ publisher, metadata: { icon } }: ReviewTarget) { + const media = useContext(OwnerContext)?.launchReviewMedia; + const [name, setName] = useState(''); + const [avatar, setAvatar] = useState(); + const [artwork, setArtwork] = useState(); + useEffect(() => { + if (!media || AppState.currentState !== 'active') return; + const controller = new AbortController(); + const { signal } = controller; + const live = () => { if (signal.aborted) throw new Error('Review closed'); }; + const subscription = AppState.addEventListener('change', next => { if (next !== 'active') controller.abort(); }); + void media.readProfile(publisher, signal, live).then(result => { + live(); + setName(boundedPublishedTitle(result?.displayName ?? '') || boundedPublishedTitle(result?.name ?? '')); + if (result?.picture) return media.readAvatar(result.picture, signal, live); + }).then(image => { + if (image) { live(); setAvatar(image.uri); } + }).catch(() => undefined); + if (icon) void media.readIcon(icon, signal).then(uri => { live(); setArtwork(uri); }).catch(() => undefined); + return () => { controller.abort(); subscription.remove(); }; + }, [icon, media, publisher]); + return { name, avatar, artwork }; +} + +function ReviewImage({ uri, avatar = false }: { uri: string | undefined; avatar?: boolean }) { + const { styles } = useShellStyles(createStyles); + const [failed, setFailed] = useState(false); + return + {uri && !failed + ? setFailed(true)} /> + : } + ; +} + +function LaunchIdentity({ target, source }: { target: ReviewTarget; source: LaunchSource | undefined }) { + const { styles } = useShellStyles(createStyles); + const { name, avatar, artwork } = useLaunchArtwork(target); + const { title, description } = target.metadata; + const [fullDescription, setFullDescription] = useState(false); + const hasLongDescription = description.length > 180; + const npub = formatNpub(target.publisher); + const preview = hasLongDescription && !fullDescription ? description.slice(0, 177).trimEnd() + '…' : description; + return + + + + {title} + {source && {source.label}} + + + + Publisher + + + + {name !== '' && {name}} + {npub.slice(0, 13)}…{npub.slice(-7)} + + + + {description !== '' && + {preview} + {hasLongDescription && setFullDescription(value => !value)} style={styles.descriptionToggle}> + {fullDescription ? 'Show less' : 'Read full description'} + } + } + ; +} + +function TechnicalDetails({ target, source }: { target: ReviewTarget; source: LaunchSource | undefined }) { + const { styles } = useShellStyles(createStyles); + const [expanded, setExpanded] = useState(false); + const { reference, version, htmlHash } = target.metadata; + const fields = [ + ['Publisher public key', target.publisher], + ['Signed event', target.eventId], + ['Publisher npub', formatNpub(target.publisher)], + ['Publication reference', reference], + ['Verified artifact version', version], + ['Verified HTML hash', htmlHash], + ]; + if (!snapshotEventId(target.appId)) fields.splice(1, 0, ['Publication identifier', target.appId]); + if (source?.publisher) fields.push(['Requesting catalog key', formatNpub(source.publisher)]); + return + setExpanded(value => !value)} style={styles.disclosure}> + Technical details + {expanded ? '−' : '+'} + + {expanded && + {fields.map(([label, value]) => + {label} + {value} + )} + } + ; +} + +export function PublishedLaunchReview({ target, source, changesAccess, onAccept, onCancel, ids, title = 'Open this napplet?', approveLabel, children }: { + target: ReviewTarget; + source?: LaunchSource; + changesAccess: boolean; + onAccept(): void; + onCancel(): void; + ids: Readonly<{ review: string; approve: string; cancel: string }>; + title?: string; + approveLabel?: string; + children?: ReactNode; +}) { + const { styles } = useShellStyles(createStyles); + const insets = useSafeAreaInsets(); + const heading = useRef(null); + return { + if (heading.current) AccessibilityInfo.sendAccessibilityEvent(heading.current, 'focus'); + }}> + + + + + {title} + + + {children} + + + + + Cancel + + + {approveLabel ?? (changesAccess ? 'Allow and open' : 'Open')} + + + + + ; +} + +const createStyles = (colors: ShellPalette, fonts: ShellFonts) => StyleSheet.create({ + backdrop: { flex: 1, justifyContent: 'flex-end', alignItems: 'center', backgroundColor: colors.shade }, + sheet: { maxHeight: '100%', width: '100%', maxWidth: 680, backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border }, + scroll: { flexGrow: 0, flexShrink: 1 }, + content: { padding: 20, gap: 16 }, + heading: { fontFamily: fonts.display, fontSize: 26, color: colors.text }, + identityRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + identityCopy: { flex: 1, gap: 3 }, + artwork: { width: 52, height: 52, backgroundColor: colors.raised, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }, + image: { width: '100%', height: '100%' }, + avatar: { width: 40, height: 40, borderRadius: 20 }, + mark: { width: 16, height: 16, backgroundColor: colors.accent, transform: [{ rotate: '45deg' }] }, + avatarMark: { width: 12, height: 12, borderRadius: 6 }, + appName: { fontFamily: fonts.strong, fontSize: 23, color: colors.text }, + name: { fontFamily: fonts.strong, fontSize: 16, color: colors.text }, + publisherCard: { gap: 6, paddingVertical: 10, paddingHorizontal: 12, backgroundColor: colors.raised }, + section: { gap: 10 }, + group: { gap: 4 }, + sectionTitle: { fontFamily: fonts.strong, fontSize: 16, color: colors.text }, + label: { fontFamily: fonts.mono, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', color: colors.muted }, + body: { fontFamily: fonts.body, fontSize: 16, color: colors.text }, + detail: { fontFamily: fonts.body, fontSize: 14, color: colors.muted }, + mono: { fontFamily: fonts.mono, fontSize: 12, color: colors.text }, + capability: { gap: 3, paddingVertical: 9, borderBottomWidth: 1, borderColor: colors.border }, + unavailable: { fontFamily: fonts.strong, fontSize: 14, color: colors.accent }, + descriptionToggle: { minHeight: 44, justifyContent: 'center', alignSelf: 'flex-start' }, + technical: { gap: 6, borderTopWidth: 1, borderColor: colors.border }, + disclosure: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', minHeight: 48, gap: 8 }, + disclosureText: { flexShrink: 1, fontFamily: fonts.strong, fontSize: 15, color: colors.accent }, + actions: { flexDirection: 'row', flexWrap: 'wrap', borderTopWidth: 1, borderColor: colors.border, gap: 8, paddingHorizontal: 20, paddingTop: 10 }, + button: { flexGrow: 1, flexBasis: '44%', minWidth: 112, minHeight: 48, paddingVertical: 10, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border }, + buttonText: { fontFamily: fonts.strong, fontSize: 16, textAlign: 'center', color: colors.text }, + primary: { backgroundColor: colors.primary, borderColor: colors.primary }, + primaryText: { color: colors.onPrimary }, +}); diff --git a/src/shell/RoutingReview.tsx b/src/shell/RoutingReview.tsx index da51d0b..17423d0 100644 --- a/src/shell/RoutingReview.tsx +++ b/src/shell/RoutingReview.tsx @@ -1,3 +1,4 @@ +import { PublishedLaunchReview } from './PublishedLaunchReview'; import { MEDIA_ACCESS_DETAIL } from '../runtime/support-report'; import type { ReviewSlot } from './review-slot'; import { TrustedBand } from './TrustedBand'; @@ -35,9 +36,10 @@ function SnapshotDetails({eventId}: {eventId:string}) { const {styles} = useShellStyles(createStyles); return <>Snapshot (kind 5129){eventId}; } -function reviewTitle(request: RoutingReviewRequest): string { +type PublishedOpenRequest = Extract; +type StandardReviewRequest = Exclude; +function reviewTitle(request: StandardReviewRequest): string { switch (request.kind) { - case 'published-open': return 'Open this published napplet?'; case 'intent': return request.selected ? 'Open with this napplet?' : 'Choose a handler'; case 'topic': return 'Allow this topic?'; case 'permissions': return 'Allow handler capabilities?'; @@ -85,33 +87,15 @@ function PermissionReviewDetails({request}: {request: ExtractHypergolic saves available access for this identity, publisher and app. Uploads, intent dispatch and new communication permissions still need their own approval. ; } -function PublishedOpenDetails({request}: {request: Extract}) { - const { styles } = useShellStyles(createStyles); - const { target } = request; - return <> - Target napplet and publisher - Signed revision{target.eventId} - Verified artifact version{target.version} - Required capabilities{target.requiredDomains.join(', ') || 'None'} - Optional capabilities{target.optionalDomains.join(', ') || 'None'} - {(target.requiredDomains.includes('keys') || target.optionalDomains.includes('keys')) && - Keys lets this napplet use hardware shortcuts while it has focus.} - {(target.requiredDomains.includes('media') || target.optionalDomains.includes('media')) && {MEDIA_ACCESS_DETAIL}} - {target.unsupportedDomains.length > 0 && Unavailable required capabilities: {target.unsupportedDomains.join(', ')}. Features using these capabilities will be unavailable.} - {target.unsupportedOptionalDomains.length > 0 && Unavailable optional capabilities: {target.unsupportedOptionalDomains.join(', ')}.} - {request.changesAccess && Hypergolic will save the available access for this publisher and napplet under your selected identity.} - ; -} -function ReviewDetails({request, selected, onSelect, remember, onRemember}: {request: RoutingReviewRequest; selected: CatalogHandler | null; onSelect(value: CatalogHandler): void; remember: boolean; onRemember(): void}) { +function ReviewDetails({request, selected, onSelect, remember, onRemember}: {request: StandardReviewRequest; selected: CatalogHandler | null; onSelect(value: CatalogHandler): void; remember: boolean; onRemember(): void}) { switch (request.kind) { - case 'published-open': return ; case 'intent': return ; case 'topic': return ; case 'channel': return ; case 'permissions': return ; } } -const reviewActions = {'published-open': 'Open verified version', intent: 'Approve dispatch', channel: 'Open channel once', permissions: 'Allow capabilities', topic: 'Allow this topic'} as const; +const reviewActions = { intent: 'Approve dispatch', channel: 'Open channel once', permissions: 'Allow capabilities', topic: 'Allow this topic'} as const; function RoutingReviewSheet({service, item}: {service: RoutingReviewService; item: RoutingReviewItem}) { const { styles } = useShellStyles(createStyles); const request = item.request, insets = useSafeAreaInsets(); @@ -119,9 +103,10 @@ function RoutingReviewSheet({service, item}: {service: RoutingReviewService; ite const landscape = width > height; const [selected, setSelected] = useState(request.kind === 'intent' ? request.selected : null); const [remember, setRemember] = useState(false); - const [legacyReadOnly, setLegacyReadOnly] = useState(false); const cancel = () => service.decide(item.id, {allow: false}); - const accept = () => service.decide(item.id, {allow: true, ...(selected ? {selected} : {}), remember, ...(legacyReadOnly ? {legacyReadOnly: true as const} : {})}); + const accept = () => service.decide(item.id, {allow: true, ...(selected ? {selected} : {}), remember}); + if (request.kind === 'published-open') return service.decide(item.id, {allow: true, ...(legacyReadOnly ? {legacyReadOnly: true as const} : {})})} />; const disabled = request.kind === 'intent' && selected === null; return @@ -129,21 +114,36 @@ function RoutingReviewSheet({service, item}: {service: RoutingReviewService; ite {reviewTitle(request)} Requesting napplet - {request.kind === 'published-open' && request.source.source === 'bundled' - ? {request.source.title} · built into Hypergolic - : } + setRemember(value => !value)}/> - {request.kind === 'published-open' && request.legacyReadOnlyAvailable && - setLegacyReadOnly(value => !value)} style={styles.button}> - {legacyReadOnly ? '✓ ' : '□ '}Use legacy read-only mode - - Hypergolic supplies compatibility libraries and reads public content through your configured relays. Public HTTPS images use bounded native downloads. Results may be limited. Signing, publishing and uploads are unavailable in this mode. - } - {legacyReadOnly ? 'Open read-only' : request.kind === 'published-open' && request.changesAccess ? 'Allow and open' : reviewActions[request.kind]} + {reviewActions[request.kind]} Cancel ; } +const publishedReviewIds = { review: 'routing-review', approve: 'routing-approve', cancel: 'routing-cancel' }; + +function RoutingPublishedLaunchReview({ request, onAccept, onCancel }: { + request: PublishedOpenRequest; onAccept(legacyReadOnly: boolean): void; onCancel(): void; +}) { + const { styles } = useShellStyles(createStyles); + const [legacyReadOnly, setLegacyReadOnly] = useState(false); + const { source } = request; + const origin = source.source === 'bundled' + ? { label: `Requested by ${source.title} · built into Hypergolic` } + : { label: `Requested by ${source.title}`, publisher: request.origin.publisher }; + return onAccept(legacyReadOnly)} ids={publishedReviewIds} + {...(legacyReadOnly && { approveLabel: 'Open read-only' })}> + {request.legacyReadOnlyAvailable && + setLegacyReadOnly(value => !value)} style={styles.button}> + {legacyReadOnly ? '✓ ' : '□ '}Use legacy read-only mode + + Hypergolic supplies compatibility libraries and reads public content through your configured relays. Public HTTPS images use bounded native downloads. Results may be limited. Signing, publishing and uploads are unavailable in this mode. + } + ; +} + const createStyles = (colors: ShellPalette, fonts: ShellFonts) => StyleSheet.create({backdrop: {flex: 1, backgroundColor: colors.shade, justifyContent: 'flex-end'}, sheet: {maxHeight: '90%', backgroundColor: colors.surface, borderTopWidth: 1, borderColor: colors.border, paddingTop: 0}, content: {gap: 12, padding: 24}, group: {gap: 6}, title: { fontFamily: fonts.display,fontSize: 28, color: colors.text}, label: { textTransform: 'uppercase', letterSpacing: 1.2, fontFamily: fonts.mono,fontSize: 12, color: colors.muted}, text: { fontFamily: fonts.body,fontSize: 16, lineHeight: 24, color: colors.text}, mono: { fontFamily: fonts.mono,fontSize: 13, lineHeight: 20, color: colors.text}, detail: { fontFamily: fonts.body,fontSize: 14, lineHeight: 21, color: colors.muted}, choice: {padding: 16, gap: 6, borderWidth: 1, borderColor: colors.border}, chosen: {borderColor: colors.accent}, actions: {paddingHorizontal: 24, gap: 8}, actionsLandscape: {flexDirection: 'row'}, actionLandscape: {flex: 1}, button: {minHeight: 48, padding: 12, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center'}, action: { textTransform: 'uppercase', letterSpacing: 1.8, fontFamily: fonts.strong,fontSize: 14, color: colors.accent}}); diff --git a/tests/napplets/first-open-consent.test.ts b/tests/napplets/first-open-consent.test.ts index ab76dc1..ce92587 100644 --- a/tests/napplets/first-open-consent.test.ts +++ b/tests/napplets/first-open-consent.test.ts @@ -38,7 +38,10 @@ test('only explicit trusted review persists the exact publisher, signed coordina const candidate = await artifact(); let seen: unknown; const admission = await requestFirstOpenConsent(candidate, owner, storage, new AbortController().signal, owner.assertActive, async request => { seen = request; return true; }); - assert.deepEqual(seen, { user, publisher, appId: 'hello', eventId: candidate.manifest.eventId, domains: ['theme'], unsupportedDomains: [], optionalDomains: [], unsupportedOptionalDomains: [] }); + assert.deepEqual(seen, { user, publisher, appId: 'hello', eventId: candidate.manifest.eventId, + metadata: { title: 'hello', description: '', reference: naddrEncode({ kind: 35129, pubkey: publisher, identifier: 'hello' }), + version: candidate.aggregateHash, htmlHash: candidate.htmlHash }, + domains: ['theme'], unsupportedDomains: [], optionalDomains: [], unsupportedOptionalDomains: [] }); assert.equal(Object.isFrozen(seen), true); assert.deepEqual((await storage.readAccessGrant())?.domains, ['theme']); assertVerifiedNappletAdmission(admission, candidate); diff --git a/tests/napplets/verified-launch-metadata.test.ts b/tests/napplets/verified-launch-metadata.test.ts new file mode 100644 index 0000000..48dad8b --- /dev/null +++ b/tests/napplets/verified-launch-metadata.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; +import { naddrEncode, neventEncode } from 'nostr-tools/nip19'; +import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'; +import { createLaunchReviewMedia } from '../../src/napplets/launch-review-media.ts'; +import { resolvePublishedReference } from '../../src/napplets/resolve-link.ts'; +import { verifyArtifact, verifyManifest } from '../../src/napplets/verified-artifact.ts'; +import { verifiedLaunchMetadata } from '../../src/napplets/verified-launch-metadata.ts'; + +const key = new Uint8Array(32); +key[31] = 9; +const publisher = getPublicKey(key); +const html = new TextEncoder().encode('Fixtureok'); +const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aNM0AAAAASUVORK5CYII=', 'base64'); +const hash = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex'); +const server = 'https://blossom.example.org/'; + +async function release(kind: 5129 | 35129, tags: string[][], content: string) { + const signed = finalizeEvent({ kind, created_at: 1_700_000_000, content, + tags: [...(kind === 35129 ? [['d', 'signed-review']] : []), ...tags] }, key); + const reference = kind === 5129 ? neventEncode({ id: signed.id, kind, author: publisher }) + : naddrEncode({ kind, pubkey: publisher, identifier: 'signed-review' }); + const manifest = await verifyManifest(resolvePublishedReference(reference), JSON.parse(JSON.stringify(signed))); + return { artifact: await verifyArtifact(manifest, html), reference }; +} + +test('launch metadata comes from the exact signed modern event and accepts only one supported icon descriptor', async () => { + const tags = [['x', hash(html)], ['title', 'Field guide'], ['server', server], ['icon', hash(png), 'image/png']]; + const { artifact, reference } = await release(35129, tags, 'Identify local birds.\u202e'); + const metadata = verifiedLaunchMetadata(artifact); + assert.deepEqual(metadata, { + title: 'Field guide', description: 'Identify local birds.', reference, + version: artifact.aggregateHash, htmlHash: artifact.htmlHash, + icon: { hash: hash(png), mime: 'image/png', serverHints: [server] }, + }); + assert(Object.isFrozen(metadata.icon)); + assert.throws(() => verifiedLaunchMetadata({ ...artifact })); + const ambiguous = await release(35129, [...tags, ['icon', hash(png), 'image/png']], 'Duplicate artwork'); + assert.equal(verifiedLaunchMetadata(ambiguous.artifact).icon, undefined); + const unsupported = await release(35129, [['x', hash(html)], ['icon', hash(png), 'image/svg+xml']], 'Unsupported artwork'); + assert.equal(verifiedLaunchMetadata(unsupported.artifact).icon, undefined); +}); + +test('legacy descriptions and snapshot references preserve the signed publication identity', async () => { + const legacy = await release(35129, [['path', '/index.html', hash(html)], ['description', 'Legacy description']], 'Not the legacy description'); + assert.equal(verifiedLaunchMetadata(legacy.artifact).description, 'Legacy description'); + const snapshot = await release(5129, [['x', hash(html)]], 'Snapshot description'); + assert.equal(verifiedLaunchMetadata(snapshot.artifact).reference, snapshot.reference); + assert.equal(verifiedLaunchMetadata(snapshot.artifact).title, 'Published snapshot'); +}); + +test('launch artwork reuses bounded verified Blossom bytes and rejects mismatched raster formats or cancellation', async () => { + const fallback = 'https://mirror.example.org/'; + const requests: unknown[] = []; + const media = createLaunchReviewMedia({ async readProfile() { return null; } }, + async () => assert.fail('Icon read must not fetch a profile avatar'), + { async readHtml(hash, mirrors, maxBytes, signal) { + requests.push({ hash, mirrors, maxBytes, signal }); + return png; + } }, () => [server, fallback]); + const controller = new AbortController(); + const icon = { hash: hash(png), serverHints: [server], mime: 'image/png' }; + assert.equal(await media.readIcon(icon, controller.signal), 'data:image/png;base64,' + png.toString('base64')); + assert.deepEqual(requests, [{ hash: hash(png), mirrors: [server, fallback], maxBytes: 768 * 1024, signal: controller.signal }]); + await assert.rejects(media.readIcon({ ...icon, mime: 'image/jpeg' }, controller.signal)); + controller.abort(); + await assert.rejects(media.readIcon(icon, controller.signal)); +}); diff --git a/tests/native/changed_access_driver.py b/tests/native/changed_access_driver.py index 90f267b..1e8b325 100644 --- a/tests/native/changed_access_driver.py +++ b/tests/native/changed_access_driver.py @@ -67,14 +67,8 @@ def verify_fixture_screen(nodes): def verify_consent_review(nodes): - if not any(resource_id_is(n, 'settings-napplet-review') for n in nodes): - raise CheckFailed('First-open consent review is not visible') - for value, field in ((FIXTURE['publisher'], 'publisher'), (FIXTURE['app'], 'napplet'), - (FIXTURE['oldEvent'], 'event'), ('Requested access: theme', 'requested access')): - if not any(label(n) == value for n in nodes): - raise CheckFailed('Consent review did not show the expected ' + field) - return exactly_one(nodes, lambda n: resource_id_is(n, 'settings-napplet-approve'), - 'explicit first-open approval') + return base.verify_published_review_claims(nodes, FIXTURE['publisher'], FIXTURE['app'], + FIXTURE['oldEvent'], ('theme',)) def update_session_id(nodes): @@ -225,7 +219,7 @@ def run_scenario(self): self.tap_id('settings-open-napplet', 'verify and open public napplet') _, nodes = self.wait(lambda observed: any(resource_id_is(n, 'settings-napplet-review') for n in observed), 'first-open consent review') - approve = verify_consent_review(nodes) + approve = verify_consent_review(base.reveal_published_review(self, nodes, FIXTURE['oldEvent'])) self.check('first-open-consent-exact', {'publisher': FIXTURE['publisher'], 'app': FIXTURE['app'], 'event': FIXTURE['oldEvent'], 'access': 'theme', 'explicitApprovalRequired': True}) self.capture('02-consent-review') diff --git a/tests/native/driver.py b/tests/native/driver.py index a331a4d..d156425 100644 --- a/tests/native/driver.py +++ b/tests/native/driver.py @@ -63,6 +63,44 @@ def node_bounds(node): raise CheckFailed('Control is not currently visible') return x1, y1, x2, y2 +def published_review_id(node, identifier): + return node.get('resource-id', '').rsplit('/', 1)[-1] == identifier + + +def reveal_published_review(driver, initial, event): + toggle = 'published-review-technical-toggle' + if any(label(node) == event for node in initial): + raise CheckFailed('Technical details appeared before expansion') + observed = list(initial) + + def capture(node, matches): + observed.append(node) + return matches(node) + + nodes = driver.scroll_until(lambda node: capture(node, lambda value: published_review_id(value, toggle)), + 'technical details') + driver.tap(exactly_one(nodes, lambda node: published_review_id(node, toggle), 'technical details control')) + details = driver.scroll_until(lambda node: capture(node, lambda value: label(value) == event), + 'exact signed event') + # The footer remains visible. Keep the latest coordinates for each observed field. + return list({(node.get('resource-id'), label(node)): node for node in observed + nodes + details}.values()) + + +def verify_published_review_claims(nodes, publisher, identifier, event, required): + if not any(published_review_id(node, 'settings-napplet-review') for node in nodes): + raise CheckFailed('First-open consent review is not visible') + for value, field in ((publisher, 'publisher'), (identifier, 'publication identifier'), (event, 'signed event')): + if not any(label(node) == value for node in nodes): + raise CheckFailed('Consent review did not show exact ' + field) + prefix = 'published-review-required-' + domains = {node.get('resource-id', '').rsplit('/', 1)[-1].removeprefix(prefix) for node in nodes + if node.get('resource-id', '').rsplit('/', 1)[-1].startswith(prefix)} + if domains != set(required): + raise CheckFailed('Consent review did not show exact required capabilities') + return exactly_one(nodes, lambda node: published_review_id(node, 'settings-napplet-approve'), + 'explicit first-open approval') + + def exactly_one(nodes, predicate, description): found = [n for n in nodes if predicate(n)] if len(found) != 1: diff --git a/tests/native/identity-data/android-published.py b/tests/native/identity-data/android-published.py index 35b3ddc..6996ee6 100644 --- a/tests/native/identity-data/android-published.py +++ b/tests/native/identity-data/android-published.py @@ -63,7 +63,7 @@ def review(allow):self.find('Allow public list access?');self.find('Publisher'); def success(label,kind,wanted):self.guest(label);self.guest_find('Public '+kind+' read complete.');self.result(wanted) def error():self.guest_find('Public mutes read failed: identity request denied');self.delivered() def open_signed(role,reject=False): - address=self.find('signed-'+role+'-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.settings_tap('settings-napplet-address');self.adb('shell','input','text',address);self.adb('shell','input','keyevent','4');self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?');self.settings_find('Requested access: identity, '+('storage' if role=='state' else 'relay'));self.capture(role+'-domain-consent') + address=self.find('signed-'+role+'-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.settings_tap('settings-napplet-address');self.adb('shell','input','text',address);self.adb('shell','input','keyevent','4');self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?');self.settings_find('published-review-required-identity');self.settings_find('published-review-required-'+('storage' if role=='state' else 'relay'));self.capture(role+'-domain-consent') if reject: self.settings_tap('settings-napplet-reject');self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?') self.settings_tap('settings-napplet-approve');self.find('Identity connected' if role=='state' else 'Ready to query.');self.capture(role+'-admitted');self.check('signed-'+role+'-normal-admission') diff --git a/tests/native/ios/XCTest/HypergolicUITests/ConfigurationTests.swift b/tests/native/ios/XCTest/HypergolicUITests/ConfigurationTests.swift index ab694c4..6ffb6fd 100644 --- a/tests/native/ios/XCTest/HypergolicUITests/ConfigurationTests.swift +++ b/tests/native/ios/XCTest/HypergolicUITests/ConfigurationTests.swift @@ -27,7 +27,7 @@ final class ConfigurationTests: XCTestCase { let input=app.textFields["settings-napplet-address"];settings(input);input.tap();if let old=input.value as? String,!old.isEmpty {input.typeText(String(repeating:XCUIKeyboardKey.delete.rawValue,count:old.count))} for offset in stride(from:0,to:link.count,by:12) {let end=min(offset+12,link.count),startIndex=link.index(link.startIndex,offsetBy:offset),endIndex=link.index(link.startIndex,offsetBy:end);input.typeText(String(link[startIndex..Any { @@ -133,7 +133,7 @@ final class RoutingTests: XCTestCase { } input.typeText("\n") let consent=app.staticTexts["Allow this napplet?"].firstMatch;XCTAssertTrue(consent.waitForExistence(timeout:30),app.debugDescription);settings(consent) - wait("Requested access: inc, intent");capture(role+"-domain-consent");settingsButton("settings-napplet-approve");wait("SDK ready for routing.") + XCTAssertTrue(app.staticTexts["published-review-required-"+("inc")].firstMatch.waitForExistence(timeout:30),app.debugDescription);XCTAssertTrue(app.staticTexts["published-review-required-"+("intent")].firstMatch.waitForExistence(timeout:30),app.debugDescription);capture(role+"-domain-consent");settingsButton("settings-napplet-approve");wait("SDK ready for routing.") XCTAssertEqual(app.staticTexts["focused-napplet-name"].label,"signed-routing-"+(role=="collision-peer" ? "peer":role)+"-lab") } func readJSON(_ contains:String,_ prefix:String)->Any { @@ -202,7 +202,7 @@ final class RoutingTests: XCTestCase { } input.typeText("\n") let consent=app.staticTexts["Allow this napplet?"].firstMatch;XCTAssertTrue(consent.waitForExistence(timeout:30),app.debugDescription);settings(consent) - wait("Requested access: "+domains);capture(role+"-domain-consent");settingsButton("settings-napplet-approve");wait("SDK ready for routing.") + for domain in domains.components(separatedBy:", ") {XCTAssertTrue(app.staticTexts["published-review-required-"+(domain)].firstMatch.waitForExistence(timeout:30),app.debugDescription)};capture(role+"-domain-consent");settingsButton("settings-napplet-approve");wait("SDK ready for routing.") XCTAssertEqual(app.staticTexts["focused-napplet-name"].label,"signed-routing-"+role+"-lab") } func readJSON(_ contains:String,_ prefix:String)->Any { diff --git a/tests/native/ios/XCTest/HypergolicUITests/UploadTests.swift b/tests/native/ios/XCTest/HypergolicUITests/UploadTests.swift index e76a061..8f65531 100644 --- a/tests/native/ios/XCTest/HypergolicUITests/UploadTests.swift +++ b/tests/native/ios/XCTest/HypergolicUITests/UploadTests.swift @@ -608,7 +608,7 @@ final class UploadTests: XCTestCase { let input = app.textFields["settings-napplet-address"];settings(input);input.tap();input.typeText(link + "\n") let consent = app.staticTexts["Allow this napplet?"].firstMatch XCTAssertTrue(consent.waitForExistence(timeout: 30),app.debugDescription);settings(consent);capture("first-open-consent") - XCTAssertTrue(app.staticTexts["Requested access: upload"].exists) + XCTAssertTrue(app.staticTexts["published-review-required-upload"].exists) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label == %@", "Upload access allows this napplet to read files you select. Every upload still needs your separate approval of the file, identity and server.")).firstMatch.exists) let reject = app.buttons["settings-napplet-reject"];settings(reject);reject.tap();emit("CONSENT_REJECT") let verify = app.buttons["settings-open-napplet"];settings(verify);verify.tap() diff --git a/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift b/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift index 538f057..6cb603c 100644 --- a/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift +++ b/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift @@ -490,9 +490,9 @@ final class WorkspaceTests: XCTestCase { throw Failure.invalid("Published open reported verification error before review: " + String(openError.label.prefix(200)) + " / " + (source.exists ? String(source.label.prefix(200)) : "source stage unavailable")) } - let claims = Set(app.staticTexts.allElementsBoundByIndex.filter { $0.exists }.map(\.label)) + let claims = try revealFirstOpenClaims() record("live-first-open-exact-review", claims.contains(publisher) && claims.contains("file-browser") - && claims.contains(event) && claims.contains("Requested access: theme"), + && claims.contains(event) && app.staticTexts["published-review-required-theme"].firstMatch.exists, ["publisher": publisher, "identifier": "file-browser", "eventId": event, "requestedAccess": "theme"]) capture("live-published-review") try identityPress("settings-napplet-approve") @@ -542,9 +542,9 @@ final class WorkspaceTests: XCTestCase { self.app.descendants(matching: .any).matching(identifier: "settings-napplet-review").firstMatch.exists && self.app.buttons.matching(identifier: "settings-napplet-approve").firstMatch.isHittable } - let claims = Set(app.staticTexts.allElementsBoundByIndex.filter { $0.exists }.map(\.label)) + let claims = try revealFirstOpenClaims() record("live-first-open-exact-review", claims.contains(fixture.publisher) && claims.contains(fixture.identifier) - && claims.contains(fixture.v1) && claims.contains("Requested access: theme"), + && claims.contains(fixture.v1) && app.staticTexts["published-review-required-theme"].firstMatch.exists, ["publisher": fixture.publisher, "identifier": fixture.identifier, "eventId": fixture.v1, "requestedAccess": "theme", "explicitApprovalRequired": true]) capture("live-update-v1-review") @@ -665,13 +665,13 @@ final class WorkspaceTests: XCTestCase { && self.app.buttons.matching(identifier: "settings-napplet-approve").firstMatch.isHittable } let review = app.descendants(matching: .any).matching(identifier: "settings-napplet-review").firstMatch - let claims = Set(app.staticTexts.allElementsBoundByIndex.filter { $0.exists }.map(\.label)) + let claims = try revealFirstOpenClaims() let publisher = "638cd26c28fb52d057f424d11a14e24526ac4b5e08cbad5d6c36f8f57cacce2f" let appId = "hypergolic-update-qa" let oldEvent = "dc078ef5973560de09933f39f19dddc28c51b2b30c7ec505157442db665a48b1" let newEvent = "df50ad4aa2bcbc0514f75a63ca72c0dfb12da1b1e9e199f551bd6d2d98a71640" let exactReview = claims.contains(publisher) && claims.contains(appId) && claims.contains(oldEvent) - && claims.contains("Requested access: theme") + && app.staticTexts["published-review-required-theme"].firstMatch.exists && app.buttons.matching(identifier: "settings-napplet-approve").firstMatch.exists record("first-open-exact-review", exactReview, ["publisher": publisher, "identifier": appId, "eventId": oldEvent, "reviewVisible": review.exists, "signing": "unavailable", "requestedAccess": "theme"]) @@ -858,9 +858,9 @@ final class WorkspaceTests: XCTestCase { && self.app.buttons.matching(identifier: "settings-napplet-approve").firstMatch.isHittable } let firstReview = app.descendants(matching: .any).matching(identifier: "settings-napplet-review").firstMatch - let initialClaims = Set(app.staticTexts.allElementsBoundByIndex.filter { $0.exists }.map(\.label)) + let initialClaims = try revealFirstOpenClaims() let exactFirstOpen = initialClaims.contains(publisher) && initialClaims.contains(appId) - && initialClaims.contains(v1Event) && initialClaims.contains("Requested access: theme") + && initialClaims.contains(v1Event) && app.staticTexts["published-review-required-theme"].firstMatch.exists record("first-open-exact-review", exactFirstOpen, ["publisher": publisher, "appId": appId, "eventId": v1Event, "domains": ["theme"], "reviewVisible": firstReview.exists]) capture("changed-access-first-open-review") @@ -1071,6 +1071,19 @@ final class WorkspaceTests: XCTestCase { try stateNativeReveal(section) try identityPress(section.identifier) } + private func revealFirstOpenClaims() throws -> Set { + let scroll = app.scrollViews["published-review-content"].firstMatch + let technical = app.buttons["published-review-technical-toggle"].firstMatch + guard scroll.exists else { throw Failure.invalid("Missing launch review scroll surface") } + for _ in 0..<12 { + if technical.isHittable { + technical.tap() + return Set(scroll.staticTexts.allElementsBoundByIndex.filter { $0.exists }.map(\.label)) + } + scroll.swipeUp(velocity: .slow) + } + throw Failure.invalid("Could not reveal launch review technical details") + } private func stateNativeReveal(_ element: XCUIElement) throws { let scroll = app.scrollViews.firstMatch guard scroll.exists else { throw Failure.invalid("Missing native scroll surface") } diff --git a/tests/native/links/android-published.py b/tests/native/links/android-published.py index c5ada9a..8ccc277 100644 --- a/tests/native/links/android-published.py +++ b/tests/native/links/android-published.py @@ -52,7 +52,9 @@ def review(): self.guest('Open external link');self.find('Open external link?');text='\n'.join(n.get('text','') for n in self.nodes());assert 'Napplet: signed-resource-link-lab' in text and 'Publisher: '+publisher in text and 'https://example.com/?hypergolic=link-tracer' in text and 'Unsigned embedded' not in text and 'Read external page' not in text;self.capture('signed-review-'+str(len(self.report['checks']))) def cancel():self.tap('CANCEL');self.find('Link opening denied.');self.receipt('denied');self.absent() def open_signed(role,reject=False): - address=self.find('signed-'+role+'-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.enter_address(address);self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?');self.settings_find('Requested access: '+('link, resource' if role=='link' else 'resource'));self.capture(role+'-domain-consent') + address=self.find('signed-'+role+'-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.enter_address(address);self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?'); + if role=='link':self.settings_find('published-review-required-link') + self.settings_find('published-review-required-resource');self.capture(role+'-domain-consent') if reject: self.settings_tap('settings-napplet-reject');assert not any(n.get('class')=='android.webkit.WebView' for n in self.nodes());self.absent();self.check('domain-consent-decline-no-host');self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?') self.settings_tap('settings-napplet-approve');admitted() diff --git a/tests/native/live_published_driver.py b/tests/native/live_published_driver.py index d719990..870ba0c 100644 --- a/tests/native/live_published_driver.py +++ b/tests/native/live_published_driver.py @@ -61,15 +61,7 @@ def scroll_until(self, predicate, description): raise CheckFailed(description + ' was not reached by observed native scrolling') def verify_review(self, nodes): - if not any(has_id(n, 'settings-napplet-review') for n in nodes): - raise CheckFailed('First-open consent review is not visible') - expected = ((PUBLISHER, 'publisher'), (APP_ID, 'napplet'), (EVENT, 'signed event'), - ('Requested access: theme', 'requested access')) - for value, field in expected: - if not any(label(n) == value for n in nodes): - raise CheckFailed('Consent review did not show exact expected ' + field) - return exactly_one(nodes, lambda n: has_id(n, 'settings-napplet-approve'), - 'explicit Allow and open control') + return base.verify_published_review_claims(nodes, PUBLISHER, APP_ID, EVENT, ('theme',)) def run_scenario(self): if not re.fullmatch(r'[A-Za-z0-9_.]+', self.args.package): @@ -124,7 +116,7 @@ def run_scenario(self): diagnostic = next((label(n) for n in nodes if has_id(n, 'live-fixture-open-diagnostic')), 'source stage unavailable') raise CheckFailed('Published Settings open failed before review: ' + diagnostic[:240]) - approve = self.verify_review(nodes) + approve = self.verify_review(base.reveal_published_review(self, nodes, EVENT)) self.check('first-open-review-exact', {'publisher': PUBLISHER, 'event': EVENT, 'app': APP_ID, 'requestedAccess': 'theme', 'explicitApprovalRequired': True}) self.capture('03-consent-review') diff --git a/tests/native/live_update_driver.py b/tests/native/live_update_driver.py index 79ebc59..f21230e 100644 --- a/tests/native/live_update_driver.py +++ b/tests/native/live_update_driver.py @@ -80,23 +80,20 @@ def source_snapshot(self): return {'files': hashes, 'sha256': source_digest(hashes)} def check_review(self, nodes, update): - expected = ((self.fixture['publisher'], 'publisher'), (self.fixture['identifier'], 'napplet'), - (self.fixture['v1'], 'current event'), (self.fixture['v2'], 'new event')) if update else ( - (self.fixture['publisher'], 'publisher'), (self.fixture['identifier'], 'napplet'), - (self.fixture['v1'], 'signed event')) - for value, field in expected: + if not update: + return base.verify_published_review_claims(nodes, self.fixture['publisher'], self.fixture['identifier'], + self.fixture['v1'], ('theme',)) + for value, field in ((self.fixture['publisher'], 'publisher'), (self.fixture['identifier'], 'napplet'), + (self.fixture['v1'], 'current event'), (self.fixture['v2'], 'new event')): if not any(label(node) == value for node in nodes): raise CheckFailed('Live review did not show exact ' + field) if not any(label(node) == 'Requested access: theme' for node in nodes): raise CheckFailed('Live review did not show exact theme access') - if update: - identifiers = [embedded.resource_id_suffix(node) for node in nodes if re.fullmatch( - r'published-update-review-' + embedded.UUID.pattern, embedded.resource_id_suffix(node))] - if len(identifiers) != 1: - raise CheckFailed('Expected one session-bound live update review') - return identifiers[0].removeprefix('published-update-review-') - return exactly_one(nodes, lambda node: embedded.resource_id_is(node, 'settings-napplet-approve'), - 'explicit first-open approval') + identifiers = [embedded.resource_id_suffix(node) for node in nodes if re.fullmatch( + r'published-update-review-' + embedded.UUID.pattern, embedded.resource_id_suffix(node))] + if len(identifiers) != 1: + raise CheckFailed('Expected one session-bound live update review') + return identifiers[0].removeprefix('published-update-review-') def publish_v2(self): command = ['node', str(base.ROOT / 'tests/native/live-update-publication/cli.mjs'), @@ -148,7 +145,8 @@ def run_scenario(self): self.tap_id('settings-open-napplet', 'verify and open live QA napplet') _, nodes = self.wait(lambda observed: any(embedded.resource_id_is(node, 'settings-napplet-review') for node in observed), 'live v1 first-open review', timeout=150) - approve = self.check_review(nodes, update=False) + claims = base.reveal_published_review(self, nodes, self.fixture['v1']) + approve = self.check_review(claims, update=False) self.check('live-first-open-review', {'publisher': self.fixture['publisher'], 'event': self.fixture['v1'], 'access': 'theme'}) self.capture('02-first-open-review') diff --git a/tests/native/presentation/android.py b/tests/native/presentation/android.py index ab2fff9..dbb6eb7 100644 --- a/tests/native/presentation/android.py +++ b/tests/native/presentation/android.py @@ -47,7 +47,7 @@ def run(self): build=json.loads(self.args.build.read_text());assert build['status']=='built' and build['sourceAfter']==sources() actual=self.adb('exec-out','cat',self.adb('shell','pm','path',PACKAGE).removeprefix('package:'),binary=True);assert sha(actual)==sha(self.args.apk.read_bytes())==build['productSha256'];self.report['installedApkSha256']=sha(actual) self.adb('shell','pm','clear',PACKAGE);self.adb('shell','am','start','-W','-n',PACKAGE+'/org.nostrocket.hypergolic.dev.MainActivity') - address=self.find('signed-caller-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.enter_address(address);self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?');self.settings_find('Requested access: notify');self.capture('normal-notify-domain-consent');self.settings_tap('settings-napplet-approve');self.find('SDK ready for routing.');self.check('signed-notification-domain-admission') + address=self.find('signed-caller-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.enter_address(address);self.settings_tap('settings-open-napplet');self.settings_find('Allow this napplet?');self.settings_find('published-review-required-notify');self.capture('normal-notify-domain-consent');self.settings_tap('settings-napplet-approve');self.find('SDK ready for routing.');self.check('signed-notification-domain-admission') self.guest('Request notification permission');self.find('Allow notifications?');self.find('signed-notify-caller-lab');self.capture('trusted-notification-permission');self.tap('notify-deny');self.find('Notification permission denied.');assert self.data('notify-permission-result')=={'granted':False};self.check('native-denied-original-sdk-permission') self.guest('Request notification permission');self.find('Allow notifications?');self.tap('notify-allow');self.find('Notification permission granted.');assert self.data('notify-permission-result')=={'granted':True};self.check('native-approved-original-sdk-permission') previous_id=None diff --git a/tests/native/presentation/config-android.py b/tests/native/presentation/config-android.py index 8152073..7149a80 100644 --- a/tests/native/presentation/config-android.py +++ b/tests/native/presentation/config-android.py @@ -15,7 +15,7 @@ def launch(self): def open_caller(self,first): address=self.find('signed-config-caller-address').get('content-desc');assert address.startswith('naddr1');self.tap('shell-settings');self.enter_address(address);self.settings_tap('settings-open-napplet') if first: - self.settings_find('Allow this napplet?');self.settings_find('Requested access: config');self.capture('normal-config-domain-consent');self.settings_tap('settings-napplet-approve') + self.settings_find('Allow this napplet?');self.settings_find('published-review-required-config');self.capture('normal-config-domain-consent');self.settings_tap('settings-napplet-approve') self.find('SDK ready for routing.');assert self.find('focused-napplet-name').get('text')=='signed-config-caller-lab' def register(self): self.guest('Register configuration');self.find('Configuration schema accepted.') diff --git a/tests/native/presentation/config-namespace-android.py b/tests/native/presentation/config-namespace-android.py index 0bda8d7..110fc31 100644 --- a/tests/native/presentation/config-namespace-android.py +++ b/tests/native/presentation/config-namespace-android.py @@ -30,7 +30,7 @@ def open_role(self,role,domain,first=True): while time.monotonic() ({id: `${digit.repeat(8) const update = {publisher, appId: 'discovery', previousEventId: '1'.repeat(64), nextEventId: '2'.repeat(64), previousVersion: '1'.repeat(64), nextVersion: '2'.repeat(64), domains: ['relay'], optionalDomains: ['upload']}; const access = {user: 'c'.repeat(64), publisher, appId: 'discovery', eventId: '2'.repeat(64), + metadata: {title: 'Discover', description: '', reference: address, version: '2'.repeat(64), htmlHash: '3'.repeat(64)}, domains: ['relay'], unsupportedDomains: [], optionalDomains: ['upload'], unsupportedOptionalDomains: []}; function setup(configured: string | null = null) { diff --git a/tests/runtime/discovery-open.test.ts b/tests/runtime/discovery-open.test.ts index 30035a4..c927793 100644 --- a/tests/runtime/discovery-open.test.ts +++ b/tests/runtime/discovery-open.test.ts @@ -26,7 +26,7 @@ const address = naddrEncode({ kind: 35129, pubkey: publisher, identifier: appId const html = new TextEncoder().encode('Test'); const hash = createHash('sha256').update(html).digest('hex'); const signed = finalizeEvent({ kind: 35129, created_at: 1_700_000_001, content: 'Open test', - tags: [['d', appId], ['x', hash], ['server', 'https://blossom.example.org/'], ['R', 'relay'], ['O', 'pow'], ['O', 'keys']] }, secret); + tags: [['d', appId], ['title', 'Open test title'], ['x', hash], ['server', 'https://blossom.example.org/'], ['R', 'relay'], ['O', 'pow'], ['O', 'keys']] }, secret); const wire = { id: signed.id, pubkey: signed.pubkey, created_at: signed.created_at, kind: signed.kind, tags: signed.tags.map(tag => [...tag]), content: signed.content, sig: signed.sig }; let artifact: VerifiedNappletArtifact; @@ -162,6 +162,11 @@ test('the actual bundled source opens a verified exact cold target after review assert.deepEqual(review.request.source, source); assert.equal(review.request.target.publisher, publisher); assert.equal(review.request.target.eventId, artifact.manifest.eventId); + assert.deepEqual(review.request.target.metadata, { + title: 'Open test title', description: 'Open test', reference: address, + version: artifact.aggregateHash, htmlHash: artifact.htmlHash, + }); + assert.equal(Object.isFrozen(review.request.target.metadata), true); assert.deepEqual(review.request.target.requiredDomains, ['relay']); assert.deepEqual(review.request.target.optionalDomains, ['keys', 'pow']); assert.deepEqual(review.request.target.unsupportedOptionalDomains, ['pow']); From 84adb8af02c4b4936e8d97d26abdfeae8e27692c Mon Sep 17 00:00:00 2001 From: macro88 <88macro@proton.me> Date: Mon, 9 Nov 2026 21:25:04 +0800 Subject: [PATCH 2/2] Polish launch review disclosure and description --- src/shell/PublishedLaunchReview.tsx | 9 +++++---- .../ios/XCTest/HypergolicUITests/WorkspaceTests.swift | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/shell/PublishedLaunchReview.tsx b/src/shell/PublishedLaunchReview.tsx index ee8e73c..89c1745 100644 --- a/src/shell/PublishedLaunchReview.tsx +++ b/src/shell/PublishedLaunchReview.tsx @@ -119,9 +119,10 @@ function LaunchIdentity({ target, source }: { target: ReviewTarget; source: Laun const { name, avatar, artwork } = useLaunchArtwork(target); const { title, description } = target.metadata; const [fullDescription, setFullDescription] = useState(false); - const hasLongDescription = description.length > 180; + const summary = description.replace(/\s+/gu, ' '); + const hasLongDescription = summary.length > 180; const npub = formatNpub(target.publisher); - const preview = hasLongDescription && !fullDescription ? description.slice(0, 177).trimEnd() + '…' : description; + const preview = hasLongDescription ? summary.slice(0, 177).replace(/\s+\S*$/u, '') + '…' : summary; return @@ -141,7 +142,7 @@ function LaunchIdentity({ target, source }: { target: ReviewTarget; source: Laun {description !== '' && - {preview} + {fullDescription ? description : preview} {hasLongDescription && setFullDescription(value => !value)} style={styles.descriptionToggle}> {fullDescription ? 'Show less' : 'Read full description'} @@ -165,7 +166,7 @@ function TechnicalDetails({ target, source }: { target: ReviewTarget; source: La if (!snapshotEventId(target.appId)) fields.splice(1, 0, ['Publication identifier', target.appId]); if (source?.publisher) fields.push(['Requesting catalog key', formatNpub(source.publisher)]); return - setExpanded(value => !value)} style={styles.disclosure}> Technical details {expanded ? '−' : '+'} diff --git a/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift b/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift index 6cb603c..513288f 100644 --- a/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift +++ b/tests/native/ios/XCTest/HypergolicUITests/WorkspaceTests.swift @@ -1072,7 +1072,7 @@ final class WorkspaceTests: XCTestCase { try identityPress(section.identifier) } private func revealFirstOpenClaims() throws -> Set { - let scroll = app.scrollViews["published-review-content"].firstMatch + let scroll = app.otherElements["published-review-content"].scrollViews.firstMatch let technical = app.buttons["published-review-technical-toggle"].firstMatch guard scroll.exists else { throw Failure.invalid("Missing launch review scroll surface") } for _ in 0..<12 {