From 6b29501e8977b46fd4fdc2a382e554e2694ee538 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 10 Sep 2026 11:13:14 +0800 Subject: [PATCH] fix(mobile): show GitHub profiles and align Harmony chat entry --- src/apps/mobile/harmonyos/README.md | 7 ++ .../entry/src/main/ets/i18n/EnUsMessages.ets | 2 + .../entry/src/main/ets/i18n/ZhCnMessages.ets | 2 + .../actions/AppRootPresentationActions.ets | 2 +- .../pages/components/AccountProfilePanel.ets | 14 ++-- .../components/AppRootOverlaySurfaces.ets | 7 ++ .../main/ets/pages/components/AppSidebar.ets | 61 ++++++++++++++-- .../pages/components/DefaultAccountAvatar.ets | 10 +++ .../components/RemoteControlSettingsSheet.ets | 4 +- .../ets/pages/components/SettingsSheet.ets | 2 + .../policy/SidebarConnectionActionPolicy.ets | 12 ++++ .../runtime/AppRootRuntimeComposition.ets | 9 ++- .../main/ets/pages/state/RemotePageState.ets | 5 ++ .../pages/viewmodel/SettingsController.ets | 40 +++++++++++ .../ets/services/CloudAccountSessionStore.ets | 10 +++ .../ets/services/GitHubAccountProfile.ets | 65 +++++++++++++++++ .../src/test/AppRootLifecycleUnit.test.ets | 62 ++++++++++++++++ .../ConversationPresentationUnit.test.ets | 10 +++ .../test/TransportAndGeneralChatUnit.test.ets | 72 ++++++++++++++++++- src/mobile-web/index.html | 2 +- .../src/components/AccountAvatar.tsx | 10 +++ .../src/components/CompactSettingsSheet.tsx | 10 ++- .../src/hooks/useGitHubAccountProfile.ts | 13 ++++ src/mobile-web/src/i18n/messages.ts | 6 ++ src/mobile-web/src/pages/SessionListPage.tsx | 11 ++- .../src/services/GitHubAccountProfile.ts | 66 +++++++++++++++++ .../src/styles/components/harmony-native.scss | 18 +++++ src/mobile-web/tests/account-login.test.mjs | 51 ++++++++++++- 28 files changed, 558 insertions(+), 25 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/GitHubAccountProfile.ets create mode 100644 src/mobile-web/src/components/AccountAvatar.tsx create mode 100644 src/mobile-web/src/hooks/useGitHubAccountProfile.ts create mode 100644 src/mobile-web/src/services/GitHubAccountProfile.ts diff --git a/src/apps/mobile/harmonyos/README.md b/src/apps/mobile/harmonyos/README.md index e6bc4c61b3..b1621fbaee 100644 --- a/src/apps/mobile/harmonyos/README.md +++ b/src/apps/mobile/harmonyos/README.md @@ -9,6 +9,13 @@ Device QR codes identify a target; the authenticated account directory authorize access. Old room records and local conversation data remain on disk during an upgrade, but do not automatically reconnect or start a local runtime. +GitHub usernames and avatars are presentation metadata loaded from GitHub's public +user-by-ID API without forwarding account credentials. The phone caches them for +24 hours in its encrypted account store, scoped to the authenticated GitHub ID. +Existing signed-in installs are enriched on startup. Offline or rate-limited +profile requests retain the session and cached display; device authorization +continues to use the immutable ID issued by Relay. + ## Project Layout - `AppScope/`: application metadata and shared resources. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index e764e69f68..afbb27b892 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -276,6 +276,8 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.settings.deviceUnavailable', 'The target desktop is unavailable. Refresh the device list and retry.'], ['remote.settings.deviceName', 'Device'], ['remote.settings.deviceManagementBody', 'This device stores account credentials encrypted with HUKS. Signing out clears them immediately.'], + ['remote.settings.githubId', 'GitHub ID: {0}'], + ['remote.settings.githubAccount', 'GitHub account'], ['remote.settings.userId', 'User ID'], ['remote.settings.deviceId', 'This device ID'], ['remote.settings.connectedDesktop', 'Current desktop'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index b52d959f4a..85dba9a322 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -276,6 +276,8 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.deviceUnavailable', '目标桌面暂时不可用,请刷新设备列表后重试。'], ['remote.settings.deviceName', '设备'], ['remote.settings.deviceManagementBody', '此设备的账号凭据已使用 HUKS 加密保存。退出账号后会立即清理。'], + ['remote.settings.githubId', 'GitHub ID: {0}'], + ['remote.settings.githubAccount', 'GitHub 账号'], ['remote.settings.userId', '用户 ID'], ['remote.settings.deviceId', '本机设备 ID'], ['remote.settings.connectedDesktop', '当前桌面端'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index fec9ce2fa6..1f2a04a49c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -58,7 +58,7 @@ export interface RemoteHomePresentationActions { export interface SidebarPresentationActions { readonly close: () => void; - readonly newChat: () => void; + readonly newChat: (agentType?: string) => void; readonly enterCode: () => void; /** Opens the QR scanner directly from sidebar device actions. */ readonly scanDesktop: () => void; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets index 378291df15..5331525db3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets @@ -10,6 +10,7 @@ import { SheetCloseHeader } from './SheetCloseHeader'; @ComponentV2 export struct AccountProfilePanel { @Param accountUsername: string = ''; + @Param accountAvatarUrl: string = ''; @Param accountUserId: string = ''; @Param fallbackUserId: string = ''; @Param deviceId: string = ''; @@ -73,7 +74,7 @@ export struct AccountProfilePanel { private CompactIdentityCard() { this.EmbeddedSectionTitle(RemoteI18n.t('settings.account.section')) Row({ space: 14 }) { - DefaultAccountAvatar({ avatarSize: 46 }) + DefaultAccountAvatar({ avatarSize: 46, avatarUrl: this.accountAvatarUrl }) Column({ space: 4 }) { Text(this.isAccountAuthenticated() ? RemoteI18n.t('settings.account.current') : RemoteI18n.t('settings.account.currentIdentity')) @@ -83,7 +84,7 @@ export struct AccountProfilePanel { .width('100%') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.shortProfileIdentifier()) + Text(this.isAccountAuthenticated() ? this.profileName() : this.shortProfileIdentifier()) .fontSize(MobileDesignTypography.bodySmall.size) .fontColor(MUTED) .width('100%') @@ -141,7 +142,7 @@ export struct AccountProfilePanel { @Builder private ProfileHero() { Column({ space: 10 }) { - DefaultAccountAvatar({ avatarSize: 70 }) + DefaultAccountAvatar({ avatarSize: 70, avatarUrl: this.accountAvatarUrl }) Text(this.profileName()) .fontSize(MobileDesignTypography.headlineLarge.size) .fontWeight(FontWeight.Bold) @@ -150,7 +151,7 @@ export struct AccountProfilePanel { .textAlign(TextAlign.Center) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.profileIdentifier()) + Text(this.isAccountAuthenticated() ? RemoteI18n.f('remote.settings.githubId', this.accountUserId) : this.profileIdentifier()) .fontSize(MobileDesignTypography.bodyMedium.size) .fontColor(MUTED) .width('88%') @@ -445,7 +446,8 @@ export struct AccountProfilePanel { } private profileName(): string { - return this.accountUsername || RemoteI18n.t('remote.settings.openbitfunUser'); + return this.accountUsername && this.accountUsername !== this.accountUserId ? `@${this.accountUsername}` : + RemoteI18n.t('remote.settings.githubAccount'); } private profileIdentifier(): string { @@ -472,7 +474,7 @@ export struct AccountProfilePanel { if (!this.isAccountAuthenticated()) { return RemoteI18n.t('remote.settings.accountNotSignedInBody'); } - return RemoteI18n.f('remote.settings.accountSignedInBody', this.accountUsername || this.accountUserId); + return RemoteI18n.f('remote.settings.accountSignedInBody', this.profileName()); } private isAccountAuthenticated(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index a5ad0c82f8..28a80e16e3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -35,6 +35,11 @@ export struct AppSidebarSurface { build() { AppSidebar({ connectionState: this.remotePageState.connectionState, + isBusy: this.remotePageState.isBusy, + workspaceKind: this.remotePageState.workspaceKind, + workspacePath: this.remotePageState.workspacePath, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + supportsHarnessProfiles: this.remotePageState.supportsHostCapability(REMOTE_CAPABILITY_HARNESS_PROFILES_V1), accountUserId: this.remotePageState.accountUserId, controlTargetType: this.remotePageState.controlTargetType, showViewSettingsButton: false, @@ -123,6 +128,7 @@ export struct AppSettingsSurface { desktopId: this.remotePageState.desktopId, userId: this.remotePageState.userId, accountUsername: this.remotePageState.accountUsername, + accountAvatarUrl: this.remotePageState.accountAvatarUrl, accountUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, controlTargetType: this.remotePageState.controlTargetType, @@ -148,6 +154,7 @@ export struct AppSettingsSurface { SettingsSheet({ sheetState: this.shellState.settingsSheet, accountUsername: this.remotePageState.accountUsername, + accountAvatarUrl: this.remotePageState.accountAvatarUrl, authenticatedUserId: this.remotePageState.accountUserId, fallbackUserId: this.remotePageState.userId, desktopName: this.remotePageState.desktopName, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index a0387ef7ec..1855b6e799 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -1,3 +1,4 @@ +import { HarnessProfileMenu } from './HarnessProfileMenu'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, PAGE_BG, PAGE_BG_FADE, SHADOW_FAINT, SHADOW_SUBTLE, SOFT, SUBTLE, TRANSPARENT } from './Theme'; @@ -9,13 +10,19 @@ import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionAction @ComponentV2 export struct AppSidebar { @Param connectionState: string = 'idle'; + @Param isBusy: boolean = false; + @Param workspaceKind: string = 'normal'; + @Param workspacePath: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param supportsHarnessProfiles: boolean = false; + @Local showCreateMenu: boolean = false; @Param accountUserId: string = ''; @Param controlTargetType: string = 'none'; @Param showCollapseButton: boolean = false; @Param showViewSettingsButton: boolean = false; @Param showWorkspaceSection: boolean = false; @Event onClose: () => void = () => {}; - @Event onNewChat: () => void = () => {}; + @Event onNewChat: (agentType: string) => void = (_agentType: string) => {}; @Event onEnterCode: () => void = () => {}; @Event onScanDesktop: () => void = () => {}; @Event onCollapse: () => void = () => {}; @@ -162,7 +169,7 @@ export struct AppSidebar { .fontSize(MobileDesignTypography.bodyMedium.size) .fontColor(INK) .placeholderColor(SUBTLE) - .padding({ left: 14, right: 14 }) + .padding(0) .backgroundColor(SOFT) .borderRadius(8) .margin({ top: 12 }) @@ -195,7 +202,7 @@ export struct AppSidebar { .height(50) .alignItems(VerticalAlign.Center) .onClick(() => { - this.onNewChat(); + this.onEnterCode(); }) if (this.showCollapseButton) { @@ -215,8 +222,8 @@ export struct AppSidebar { Row() { Button() { Row({ space: 8 }) { - SidebarGlyph({ kind: 'remote' }) - Text(RemoteI18n.t('sidebar.devices')) + SidebarGlyph({ kind: 'edit' }) + Text(RemoteI18n.t('sidebar.newChat')) .fontSize(MobileDesignTypography.titleMedium.size) .fontWeight(FontWeight.Medium) .fontColor(INK) @@ -229,10 +236,23 @@ export struct AppSidebar { .border({ width: 0.5, color: LINE }) .borderRadius(22) .shadow({ radius: 24, color: SHADOW_SUBTLE, offsetY: 7 }) + .enabled(SidebarConnectionActionPolicy.canCreateSession(this.connectionState, this.isBusy)) + .opacity(SidebarConnectionActionPolicy.canCreateSession(this.connectionState, this.isBusy) ? 1 : 0.45) + .accessibilityText(RemoteI18n.t('sidebar.newChat')) .stateEffect(true) - .onClick(() => { - this.onNewChat(); + .bindPopup(this.showCreateMenu, { + builder: () => { this.CreateModeMenu() }, + placement: Placement.Top, + popupColor: TRANSPARENT, + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) this.showCreateMenu = false; + } }) + .onClick(() => { this.requestChat(); }) Blank() @@ -301,6 +321,33 @@ export struct AppSidebar { .zIndex(2) } + @Monitor('controlTargetDeviceId', 'workspacePath', 'workspaceKind', 'connectionState', 'isBusy', 'supportsHarnessProfiles') + private closeStaleCreateMenu(): void { + this.showCreateMenu = false; + } + + private requestChat(): void { + if (!SidebarConnectionActionPolicy.canCreateSession(this.connectionState, this.isBusy)) return; + const action = SidebarConnectionActionPolicy.chatAction(this.workspaceKind, this.supportsHarnessProfiles); + if (action === 'choose-harness') { + this.showCreateMenu = !this.showCreateMenu; + return; + } + this.onNewChat(action === 'assistant' ? 'claw' : 'code'); + } + + @Builder + private CreateModeMenu() { + HarnessProfileMenu({ + onSelect: (agentType: string) => { + this.showCreateMenu = false; + if (SidebarConnectionActionPolicy.canCreateSession(this.connectionState, this.isBusy)) { + this.onNewChat(agentType); + } + } + }) + } + private shouldShowPrimaryNavigation(): boolean { return SidebarConnectionActionPolicy.shouldShowPrimaryNavigation( this.accountUserId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets index a4f939b8ca..ccacabea2c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets @@ -3,12 +3,22 @@ import { MUTED, SOFT } from './Theme'; @ComponentV2 export struct DefaultAccountAvatar { @Param avatarSize: number = 34; + @Param avatarUrl: string = ''; + @Local failedUrl: string = ''; build() { Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.person')) .fontSize(this.avatarSize * 0.52) .fontColor([MUTED]) + if (this.avatarUrl.length > 0 && this.failedUrl !== this.avatarUrl) { + Image(this.avatarUrl) + .width(this.avatarSize) + .height(this.avatarSize) + .objectFit(ImageFit.Cover) + .borderRadius(this.avatarSize / 2) + .onError(() => { this.failedUrl = this.avatarUrl; }) + } } .width(this.avatarSize) .height(this.avatarSize) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 53eb5c79d4..485dd50662 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -20,6 +20,7 @@ export struct RemoteControlSettingsSheet { @Param desktopId: string = ''; @Param userId: string = ''; @Param accountUsername: string = ''; + @Param accountAvatarUrl: string = ''; @Param accountUserId: string = ''; @Param deviceId: string = ''; @Param controlTargetType: string = 'none'; @@ -110,7 +111,7 @@ export struct RemoteControlSettingsSheet { @Builder private ProfileEntry() { Row({ space: 12 }) { - DefaultAccountAvatar({ avatarSize: 34 }) + DefaultAccountAvatar({ avatarSize: 34, avatarUrl: this.accountAvatarUrl }) Text(this.isAccountAuthenticated() ? RemoteI18n.t('remote.settings.profile') : RemoteI18n.t('sidebar.signInOpenBitFunAccount')) .fontSize(MobileDesignTypography.headlineSmall.size) @@ -449,6 +450,7 @@ export struct RemoteControlSettingsSheet { Scroll() { AccountProfilePanel({ accountUsername: this.accountUsername, + accountAvatarUrl: this.accountAvatarUrl, accountUserId: this.accountUserId, fallbackUserId: this.userId, deviceId: this.deviceId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 52b43d857b..ad649cb1bb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -12,6 +12,7 @@ import { CloudAccountDevice } from '../../services/CloudAccountClient'; export struct SettingsSheet { @Param sheetState: SettingsSheetState = new SettingsSheetState(); @Param accountUsername: string = ''; + @Param accountAvatarUrl: string = ''; @Param authenticatedUserId: string = ''; @Param fallbackUserId: string = ''; @Param desktopName: string = ''; @@ -58,6 +59,7 @@ export struct SettingsSheet { Column({ space: 0 }) { AccountProfilePanel({ accountUsername: this.accountUsername, + accountAvatarUrl: this.accountAvatarUrl, accountUserId: this.authenticatedUserId, fallbackUserId: this.fallbackUserId, deviceId: this.deviceId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets index b893ca6f68..e8cd5301e1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SidebarConnectionActionPolicy.ets @@ -1,5 +1,17 @@ /** Presentation rules for account-independent sidebar entry and navigation. */ +export type SidebarChatAction = 'assistant' | 'choose-harness' | 'code'; + export class SidebarConnectionActionPolicy { + /** Match mobile-web: assistant chat, supported harness chooser, or legacy code chat. */ + static chatAction(workspaceKind: string, supportsHarnessProfiles: boolean): SidebarChatAction { + if (workspaceKind === 'assistant') return 'assistant'; + return supportsHarnessProfiles ? 'choose-harness' : 'code'; + } + + static canCreateSession(connectionState: string, isBusy: boolean): boolean { + return connectionState === 'connected' && !isBusy; + } + static shouldShowScan(connectionState: string): boolean { return connectionState !== 'connected'; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index affaeaf11b..53dfe0f83c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -1,3 +1,4 @@ +import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy'; import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { ChatMessage, @@ -18,6 +19,7 @@ import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresen import { ImagePickerService } from '../../services/ImagePickerService'; import { MobileIdentityStore } from '../../services/MobileIdentityStore'; import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; +import { GitHubAccountProfileClient } from '../../services/GitHubAccountProfile'; import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteChatCache } from '../../services/RemoteChatCache'; @@ -610,6 +612,7 @@ export abstract class AppRootRuntimeComposition { { client: new CloudAccountClient(), sessionStore: new CloudAccountSessionStore(), + profileLoader: new GitHubAccountProfileClient(), sessionManager: this.sessionManager, remoteState: this.remotePageState, hooks: { @@ -785,7 +788,11 @@ export abstract class AppRootRuntimeComposition { }, onSidebar: { close: (): void => this.closeAppSidebar(), - newChat: (): void => { this.closeAppSidebar(); this.enterRemoteSurface(); }, + newChat: async (agentType: string = 'code'): Promise => { + if (!SidebarConnectionActionPolicy.canCreateSession(this.remotePageState.connectionState, this.remotePageState.isBusy)) return; + this.closeAppSidebar(); + await this.conversationController.createRemoteSession(agentType); + }, enterCode: (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, scanDesktop: (): void => { this.closeAppSidebar(); this.openConnectSheet(CONNECT_INTENT_SCAN); }, addDesktop: (): void => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index f07b1c116e..a1ad4da951 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -24,6 +24,7 @@ export class RemotePageState { @Trace userId: string = ''; @Trace requiresAccountAuth: boolean = false; @Trace accountUsername: string = ''; + @Trace accountAvatarUrl: string = ''; @Trace accountUserId: string = ''; @Trace authenticatedUserId: string = ''; @Trace hostCapabilities: string[] = []; @@ -103,6 +104,10 @@ export class RemotePageState { this.accountUsername = accountUsername; } + setAccountAvatarUrl(avatarUrl: string): void { + this.accountAvatarUrl = avatarUrl; + } + setAccountUsername(accountUsername: string): void { this.accountUsername = accountUsername; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index 6bbc2a74f0..d227da31b3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -1,3 +1,4 @@ +import { GitHubAccountProfile, GitHubProfileLoader } from '../../services/GitHubAccountProfile'; import { normalizeAccountRelayUrl } from '../../services/AccountDeviceLink'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { stampRemoteSessionsDevice } from '../../services/RemoteSessionIdentity'; @@ -49,6 +50,7 @@ export interface CloudAccountSettingsHooks { export interface CloudAccountSettingsDependencies { readonly client: CloudAccountClient; readonly sessionStore: CloudAccountSessionStore; + readonly profileLoader?: GitHubProfileLoader; readonly sessionManager: RemoteSessionManager; readonly remoteState: RemotePageState; readonly hooks: CloudAccountSettingsHooks; @@ -88,6 +90,7 @@ export class SettingsController { const cloud = this.requireCloud(); await cloud.sessionStore.init(context); await this.restoreCloudAccountSession(); + void this.refreshCloudAccountProfile(); } hasCloudAccountSession(): boolean { @@ -177,6 +180,7 @@ export class SettingsController { } catch (err) { RemoteLogger.warn(`cloud account session persistence failed: ${String(err)}`); } + void this.refreshCloudAccountProfile(); RemoteLogger.info('cloud account session active, refreshing account devices'); RemoteLogger.info(`cloud account login success user=${session.userId}`); return session.userId; @@ -188,6 +192,7 @@ export class SettingsController { this.cloudRelayUrl = relayUrl.trim(); this.selectedRelayUrl = this.cloudRelayUrl; this.cloudAccountUsername = username.trim(); + remoteState.setAccountAvatarUrl(''); remoteState.setAccountUserId(session.userId); remoteState.setAccountUsername(this.cloudAccountUsername); } @@ -582,6 +587,40 @@ export class SettingsController { } } + async refreshCloudAccountProfile(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + const loader = cloud.profileLoader; + if (!session || !loader) return; + const version = this.accountLoginVersion; + const isCurrent = (): boolean => this.cloudSession === session && this.accountLoginVersion === version; + const apply = (profile: GitHubAccountProfile): void => { + if (!isCurrent() || profile.userId !== session.userId) return; + this.cloudAccountUsername = profile.login; + cloud.remoteState.setAccountUsername(profile.login); + cloud.remoteState.setAccountAvatarUrl(profile.avatarUrl); + }; + try { + const cached = await cloud.sessionStore.loadProfile(session.userId); + if (!isCurrent()) return; + if (cached) { + apply(cached); + const age = Date.now() - cached.fetchedAt; + if (age >= 0 && age < 24 * 60 * 60 * 1000) return; + } + } catch (_err) { RemoteLogger.warn('GitHub profile cache unavailable'); } + if (!isCurrent()) return; + try { + const profile = await loader.load(session.userId); + if (!isCurrent() || profile.userId !== session.userId) return; + apply(profile); + await cloud.sessionStore.saveProfile(profile); + } catch (_err) { + // Optional public metadata must never expire or block the authenticated session. + RemoteLogger.warn('GitHub profile refresh unavailable; keeping account session'); + } + } + private async restoreCloudAccountSession(): Promise { const cloud = this.requireCloud(); try { @@ -665,6 +704,7 @@ export class SettingsController { cloud.hooks.onAccountDevices([]); cloud.remoteState.setAccountUserId(''); cloud.remoteState.setAccountUsername(''); + cloud.remoteState.setAccountAvatarUrl(''); } private prepareAccountDeviceConnection(deviceId: string): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets index b84233d4ec..e8b6325476 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets @@ -1,3 +1,4 @@ +import { GitHubAccountProfile, readCachedGitHubProfile } from './GitHubAccountProfile'; import { huks } from '@kit.UniversalKeystoreKit'; import { preferences } from '@kit.ArkData'; import { Encoding } from './Encoding'; @@ -52,6 +53,15 @@ export class CloudAccountSessionStore { return secret; } + async loadProfile(userId: string): Promise { + const text = await this.readSealed(`github_profile_${userId}_v1_cipher`, `github_profile_${userId}_v1_iv`); + return readCachedGitHubProfile(userId, text || ''); + } + + async saveProfile(profile: GitHubAccountProfile): Promise { + await this.writeSealed(`github_profile_${profile.userId}_v1_cipher`, `github_profile_${profile.userId}_v1_iv`, JSON.stringify(profile)); + } + async save(session: PersistedCloudAccountSession): Promise { await this.writeSealed(CIPHER_KEY, IV_KEY, JSON.stringify(session)); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/GitHubAccountProfile.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/GitHubAccountProfile.ets new file mode 100644 index 0000000000..9a76f50b64 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/GitHubAccountProfile.ets @@ -0,0 +1,65 @@ +import { http } from '@kit.NetworkKit'; + +/** Public presentation data only. The relay's immutable user ID still owns authorization. */ +export interface GitHubAccountProfile { + userId: string; + login: string; + avatarUrl: string; + fetchedAt: number; +} + +export interface GitHubPublicUser { + id: number; + login: string; + avatar_url?: string; +} + +export interface GitHubProfileLoader { + load(userId: string): Promise; +} + +export function normalizeGitHubProfile(userId: string, user: GitHubPublicUser): GitHubAccountProfile { + if (!/^[1-9][0-9]*$/.test(userId) || !Number.isSafeInteger(user.id) || String(user.id) !== userId || + typeof user.login !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$/.test(user.login)) { + throw new Error('Invalid GitHub profile identity.'); + } + const avatar = typeof user.avatar_url === 'string' ? user.avatar_url : ''; + return { + userId, login: user.login, + avatarUrl: avatar.startsWith('https://avatars.githubusercontent.com/') ? avatar : '', + fetchedAt: Date.now() + }; +} + +export function readCachedGitHubProfile(userId: string, text: string): GitHubAccountProfile | undefined { + if (!text) return undefined; + try { + const profile = JSON.parse(text) as GitHubAccountProfile; + if (profile.userId !== userId || typeof profile.fetchedAt !== 'number' || !Number.isFinite(profile.fetchedAt)) return undefined; + const normalized = normalizeGitHubProfile(userId, { + id: Number(profile.userId), login: profile.login, avatar_url: profile.avatarUrl + }); + normalized.fetchedAt = profile.fetchedAt; + return normalized; + } catch (_err) { return undefined; } +} + +/** Works for already-signed-in controllers without sending any OpenBitFun token to GitHub. */ +export class GitHubAccountProfileClient implements GitHubProfileLoader { + async load(userId: string): Promise { + if (!/^[1-9][0-9]*$/.test(userId)) throw new Error('Invalid GitHub account ID.'); + const request = http.createHttp(); + try { + const response = await request.request(`https://api.github.com/user/${userId}`, { + method: http.RequestMethod.GET, + header: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'OpenBitFun-Mobile' }, + expectDataType: http.HttpDataType.STRING, + connectTimeout: 5000, readTimeout: 5000 + }); + if (response.responseCode !== 200 || typeof response.result !== 'string') { + throw new Error(`GitHub profile unavailable (HTTP ${response.responseCode}).`); + } + return normalizeGitHubProfile(userId, JSON.parse(response.result) as GitHubPublicUser); + } finally { request.destroy(); } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 94de6a1266..2d7b5296fa 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -1,3 +1,5 @@ +import { CloudAccountClient, CloudAccountSession } from '../main/ets/services/CloudAccountClient'; +import { CommandStatusResponse, RemoteCommand } from '../main/ets/model/RemoteModels'; import { describe, expect, it } from '@ohos/hypium'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; @@ -31,6 +33,25 @@ class FakeAppRootHost implements AppRootHostPort { } } +class SidebarSessionClient extends CloudAccountClient { + createCommands: RemoteCommand[] = []; + createTargets: string[] = []; + async deviceRpc( + _relayUrl: string, _session: CloudAccountSession, targetDeviceId: string, + command: RemoteCommand, _timeoutMs: number = 130000 + ): Promise { + if (command.cmd === 'get_workspace_info') return JSON.parse( + '{"has_workspace":true,"workspace_path":"/workspace","workspace_name":"Workspace"}') as T; + if (command.cmd === 'create_session') { + this.createCommands.push(command); + this.createTargets.push(targetDeviceId); + return JSON.parse('{"session_id":"new-session","title":"New session"}') as T; + } + if (command.cmd === 'get_session_messages') return JSON.parse('{"messages":[],"has_more":false}') as T; + return JSON.parse('{"sessions":[],"messages":[],"has_more":false,"is_running":false}') as T; + } +} + class TestAppRootRuntime extends AppRootRuntime { constructor(host: AppRootHostPort = new FakeAppRootHost()) { super(host); @@ -97,6 +118,47 @@ export default function appRootLifecycleUnitTest() { expect(runtime.appShellState.showConnectSheet).assertFalse(); }); + it('creates a new remote session from the sidebar in compact and wide layouts', 0, async () => { + for (const wide of [false, true]) { + for (const agentType of ['code', 'minimal', 'agentic', 'Ultra', 'claw']) { + const runtime = new TestAppRootRuntime(); + const client = new SidebarSessionClient(); + await runtime.sessionManager.connectAccountDevice(client, 'https://relay.example.com', + { token: 'test-token', userId: '1', masterKey: new Uint8Array(32) }, 'desktop-a'); + runtime.appShellState.wideLayout = wide; + runtime.remotePageState.connectionState = 'connected'; + runtime.remotePageState.setControlTarget('account_device', 'desktop-a', 'Desktop A'); + runtime.remotePageState.setWorkspace('Workspace', '/workspace', '', '', 'normal'); + runtime.remotePageState.setActiveSession({ sessionId: 'old-session', title: 'Old', workspacePath: '/workspace', agentType: 'code' }); + runtime.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, 'old-session'); + runtime.appShellState.setSidebarVisible(true); + try { + await runtime.presentationActions.onSidebar.newChat(agentType); + expect(client.createCommands.length).assertEqual(1); + expect(client.createTargets[0]).assertEqual('desktop-a'); + expect(client.createCommands[0].agent_type).assertEqual(agentType); + expect(client.createCommands[0].workspace_path).assertEqual('/workspace'); + expect(runtime.remotePageState.activeSession.sessionId).assertEqual('new-session'); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.RemoteChat); + expect(runtime.appShellState.showSidebar).assertFalse(); + } finally { runtime.onPageHide(); runtime.aboutToDisappear(); } + } + } + }); + + it('keeps sidebar navigation intact when session creation is unavailable', 0, async () => { + const runtime = new TestAppRootRuntime(); + runtime.appShellState.setSidebarVisible(true); + await runtime.presentationActions.onSidebar.newChat(); + expect(runtime.appShellState.showSidebar).assertTrue(); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.RemoteHome); + runtime.remotePageState.connectionState = 'connected'; + runtime.remotePageState.setBusy(true); + await runtime.presentationActions.onSidebar.newChat(); + expect(runtime.appShellState.showSidebar).assertTrue(); + runtime.aboutToDisappear(); + }); + it('opens the scanner directly when adding a desktop', 0, () => { const runtime = new TestAppRootRuntime(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 64277afbc5..736ce5aa4f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -607,6 +607,7 @@ export default function conversationPresentationUnitTest() { // local/remote pair the switcher needed is gone. expect(RemoteI18n.t('sidebar.conversations')).assertEqual('最近对话'); expect(RemoteI18n.t('sidebar.devices')).assertEqual('设备'); + expect(RemoteI18n.t('sidebar.newChat')).assertEqual('聊天'); expect(RemoteI18n.t('sidebar.connectDesktop')).assertEqual('连接电脑'); expect(RemoteI18n.t('sidebar.scanToConnect')).assertEqual('扫码连接电脑'); expect(RemoteI18n.t('sidebar.signInOpenBitFunAccount')).assertEqual('使用 GitHub 登录'); @@ -624,6 +625,15 @@ export default function conversationPresentationUnitTest() { }); it('hides the redundant scan action after a QR connection succeeds', 0, () => { + expect(SidebarConnectionActionPolicy.chatAction('normal', true)).assertEqual('choose-harness'); + expect(SidebarConnectionActionPolicy.chatAction('remote', true)).assertEqual('choose-harness'); + expect(SidebarConnectionActionPolicy.chatAction('normal', false)).assertEqual('code'); + expect(SidebarConnectionActionPolicy.chatAction('assistant', true)).assertEqual('assistant'); + expect(SidebarConnectionActionPolicy.chatAction('assistant', false)).assertEqual('assistant'); + expect(SidebarConnectionActionPolicy.canCreateSession('connected', false)).assertTrue(); + expect(SidebarConnectionActionPolicy.canCreateSession('connected', true)).assertFalse(); + expect(SidebarConnectionActionPolicy.canCreateSession('reconnecting', false)).assertFalse(); + expect(SidebarConnectionActionPolicy.canCreateSession('idle', false)).assertFalse(); expect(SidebarConnectionActionPolicy.shouldShowScan('disconnected')).assertTrue(); expect(SidebarConnectionActionPolicy.shouldShowScan('connected')).assertFalse(); }); diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 244b10e8b2..242f3ca622 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1,3 +1,4 @@ +import { GitHubAccountProfile, GitHubProfileLoader, normalizeGitHubProfile, readCachedGitHubProfile } from '../main/ets/services/GitHubAccountProfile'; import { accountDeviceLink } from '../main/ets/services/AccountDeviceLink'; import { describe, it, expect } from '@ohos/hypium'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; @@ -398,6 +399,9 @@ class GatedAccountSessionManager extends ScriptedAccountSessionManager { /** HUKS and preferences are not reachable from a local unit run. */ class InMemoryCloudAccountSessionStore extends CloudAccountSessionStore { readonly saved: PersistedCloudAccountSession[] = []; + profile?: GitHubAccountProfile; + async loadProfile(_userId: string): Promise { return this.profile; } + async saveProfile(profile: GitHubAccountProfile): Promise { this.profile = profile; } clears: number = 0; failSaves: boolean = false; failLoads: boolean = false; @@ -470,6 +474,18 @@ class ProvisioningFailureAccountClient extends CloudAccountClient { } } +class TestGitHubProfileLoader implements GitHubProfileLoader { + calls: number = 0; + fail: boolean = false; + beforeResponse: () => Promise = async (): Promise => {}; + async load(userId: string): Promise { + this.calls++; + await this.beforeResponse(); + if (this.fail) throw new Error('offline'); + return normalizeGitHubProfile(userId, { id: Number(userId), login: 'octocat', avatar_url: 'https://avatars.githubusercontent.com/u/1' }); + } +} + class AccountDeviceSwitchHarness { readonly remoteState: RemotePageState = new RemotePageState(); readonly sessionManager: ScriptedAccountSessionManager; @@ -489,7 +505,8 @@ class AccountDeviceSwitchHarness { constructor( client: CloudAccountClient = new CloudAccountClient(), sessionManager: ScriptedAccountSessionManager = new ScriptedAccountSessionManager(), - applyExistingSession: boolean = true + applyExistingSession: boolean = true, + profileLoader?: GitHubProfileLoader ) { this.sessionManager = sessionManager; const hooks: CloudAccountSettingsHooks = { @@ -528,6 +545,7 @@ class AccountDeviceSwitchHarness { const cloud: CloudAccountSettingsDependencies = { client, sessionStore: this.sessionStore, + profileLoader, sessionManager: this.sessionManager, remoteState: this.remoteState, hooks @@ -1099,6 +1117,58 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('GitHub account presentation', () => { + it('rejects unrelated profile identities and keeps legacy records readable', 0, () => { + const profile = normalizeGitHubProfile('1', { id: 1, login: 'octocat', avatar_url: 'https://avatars.githubusercontent.com/u/1' }); + expect(readCachedGitHubProfile('1', JSON.stringify(profile))?.login).assertEqual('octocat'); + expect(readCachedGitHubProfile('2', JSON.stringify(profile)) === undefined).assertTrue(); + expect(readCachedGitHubProfile('1', '') === undefined).assertTrue(); + expect(readCachedGitHubProfile('1', '{broken') === undefined).assertTrue(); + expect(readCachedGitHubProfile('1', JSON.stringify({ userId: '1', token: 'legacy', username: '1' })) === undefined).assertTrue(); + expect(normalizeGitHubProfile('1', { id: 1, login: 'octocat', avatar_url: 'https://avatars.githubusercontent.com.evil.test/u/1' }).avatarUrl).assertEqual(''); + }); + + it('enriches an existing numeric account without changing its authorization or target', 0, async () => { + const loader = new TestGitHubProfileLoader(); + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), new ScriptedAccountSessionManager(), false, loader); + harness.controller.applyCloudAccountSession({ token: 't', userId: '1', masterKey: new Uint8Array(32) }, 'https://relay.example.com', '1'); + harness.remoteState.setControlTarget('account_device', 'desk-a', 'Desktop A'); + await harness.controller.refreshCloudAccountProfile(); + expect(harness.remoteState.accountUserId).assertEqual('1'); + expect(harness.remoteState.accountUsername).assertEqual('octocat'); + expect(harness.remoteState.accountAvatarUrl).assertEqual('https://avatars.githubusercontent.com/u/1'); + expect(harness.remoteState.controlTargetDeviceId).assertEqual('desk-a'); + await harness.controller.refreshCloudAccountProfile(); + expect(loader.calls).assertEqual(1); + expect(harness.sessionStore.clears).assertEqual(0); + }); + + it('keeps cached presentation and the session when public profile refresh fails', 0, async () => { + const loader = new TestGitHubProfileLoader(); + loader.fail = true; + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), new ScriptedAccountSessionManager(), false, loader); + harness.controller.applyCloudAccountSession({ token: 't', userId: '1', masterKey: new Uint8Array(32) }, 'https://relay.example.com', '1'); + const cached = normalizeGitHubProfile('1', { id: 1, login: 'cached-name' }); + cached.fetchedAt = 0; + harness.sessionStore.profile = cached; + await harness.controller.refreshCloudAccountProfile(); + expect(harness.remoteState.accountUsername).assertEqual('cached-name'); + expect(harness.controller.hasCloudAccountSession()).assertTrue(); + expect(harness.sessionStore.clears).assertEqual(0); + }); + + it('discards a profile response after logout', 0, async () => { + const loader = new TestGitHubProfileLoader(); + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), new ScriptedAccountSessionManager(), false, loader); + harness.controller.applyCloudAccountSession({ token: 't', userId: '1', masterKey: new Uint8Array(32) }, 'https://relay.example.com', '1'); + loader.beforeResponse = async (): Promise => { await harness.controller.logoutCloudAccount(); }; + await harness.controller.refreshCloudAccountProfile(); + expect(harness.remoteState.accountUsername).assertEqual(''); + expect(harness.remoteState.accountAvatarUrl).assertEqual(''); + expect(harness.sessionStore.profile === undefined).assertTrue(); + }); + }); + describe('SettingsController', () => { it('keeps an offline account target resumable for the heartbeat', 0, async () => { diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html index 1caede7d8f..097ad946f2 100644 --- a/src/mobile-web/index.html +++ b/src/mobile-web/index.html @@ -5,7 +5,7 @@ - + diff --git a/src/mobile-web/src/components/AccountAvatar.tsx b/src/mobile-web/src/components/AccountAvatar.tsx new file mode 100644 index 0000000000..69a6dacc73 --- /dev/null +++ b/src/mobile-web/src/components/AccountAvatar.tsx @@ -0,0 +1,10 @@ +import React, { useState } from 'react'; + +export default function AccountAvatar({ url }: { url?: string }) { + const [failedUrl, setFailedUrl] = useState(); + return ; +} diff --git a/src/mobile-web/src/components/CompactSettingsSheet.tsx b/src/mobile-web/src/components/CompactSettingsSheet.tsx index 5d4e01885e..7599ee51b8 100644 --- a/src/mobile-web/src/components/CompactSettingsSheet.tsx +++ b/src/mobile-web/src/components/CompactSettingsSheet.tsx @@ -1,3 +1,4 @@ +import AccountAvatar from './AccountAvatar'; import React from 'react'; import { MobileBadge, @@ -18,6 +19,8 @@ interface SettingsDevice { interface CompactSettingsSheetProps { accountLabel: string | null; + accountUserId?: string | null; + accountAvatarUrl?: string; devices: SettingsDevice[]; isDark: boolean; onClose: () => void; @@ -39,6 +42,8 @@ function ThemeToggleIcon({ isDark }: { isDark: boolean }) { export default function CompactSettingsSheet({ accountLabel, + accountUserId, + accountAvatarUrl, devices, isDark, onClose, @@ -63,13 +68,12 @@ export default function CompactSettingsSheet({

{t('settings.accountSection')}

{accountLabel ? t('settings.currentAccount') : t('settings.notSignedIn')} {accountLabel || t('settings.connectedByQr')} + {accountUserId && {t('settings.githubId', { id: accountUserId })}} {accountLabel && {t('settings.signedIn')}} diff --git a/src/mobile-web/src/hooks/useGitHubAccountProfile.ts b/src/mobile-web/src/hooks/useGitHubAccountProfile.ts new file mode 100644 index 0000000000..719d459fbd --- /dev/null +++ b/src/mobile-web/src/hooks/useGitHubAccountProfile.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; +import { loadGitHubAccountProfile, type GitHubAccountProfile } from '../services/GitHubAccountProfile'; + +export function useGitHubAccountProfile(userId: string | null) { + const [profile, setProfile] = useState(null); + useEffect(() => { + const controller = new AbortController(); + setProfile(null); + if (userId) void loadGitHubAccountProfile(userId, setProfile, controller.signal); + return () => controller.abort(); + }, [userId]); + return profile?.userId === userId ? profile : null; +} diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 69a70d2ab1..2993b3ccd2 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -155,6 +155,8 @@ export const messages: Record = { }, settings: { accountSection: 'Account', + githubAccount: 'GitHub account', + githubId: 'GitHub ID: {id}', currentAccount: 'Current account', signedIn: 'Verified', notSignedIn: 'Not signed in', @@ -424,6 +426,8 @@ export const messages: Record = { }, settings: { accountSection: '账号', + githubAccount: 'GitHub 账号', + githubId: 'GitHub ID:{id}', currentAccount: '当前账号', signedIn: '已认证', notSignedIn: '未登录', @@ -693,6 +697,8 @@ export const messages: Record = { }, settings: { accountSection: '帳號', + githubAccount: 'GitHub 帳號', + githubId: 'GitHub ID:{id}', currentAccount: '目前帳號', signedIn: '已認證', notSignedIn: '未登入', diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 47512ff573..dc6cb6768f 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -1,3 +1,5 @@ +import { useGitHubAccountProfile } from '../hooks/useGitHubAccountProfile'; +import AccountAvatar from '../components/AccountAvatar'; import React, { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from 'react'; import { MobileButton, @@ -323,12 +325,14 @@ const SessionListPage: React.FC = ({ setCurrentAssistant, setPairedDisplayMode, authenticatedUserId, - authenticatedUserLabel, connectionHealth, controlTarget, setControlTarget, resetForDeviceSwitch, } = useMobileStore(); + const githubProfile = useGitHubAccountProfile(authenticatedUserId); + const authenticatedUserLabel = authenticatedUserId + ? githubProfile ? `@${githubProfile.login}` : t('settings.githubAccount') : null; const { isDark, toggleTheme } = useTheme(); const logoMark = isDark ? logoMarkLight : logoMarkDark; const [creating, setCreating] = useState(false); @@ -1819,6 +1823,8 @@ const SessionListPage: React.FC = ({ setCompactSettingsOpen(false)} @@ -1867,7 +1873,8 @@ const SessionListPage: React.FC = ({ {authenticatedUserLabel && ( { switch (connectionHealth) { case 'connected': return t('sessions.connectionConnected'); case 'checking': return t('sessions.connectionChecking'); case 'unreachable': return t('sessions.connectionUnreachable'); default: return t('sessions.connectionUnpaired'); } })()} /> - {authenticatedUserLabel} + + {authenticatedUserLabel} {controlTarget && controlTarget.deviceName && ( {controlTarget.deviceName} diff --git a/src/mobile-web/src/services/GitHubAccountProfile.ts b/src/mobile-web/src/services/GitHubAccountProfile.ts new file mode 100644 index 0000000000..2444ef7217 --- /dev/null +++ b/src/mobile-web/src/services/GitHubAccountProfile.ts @@ -0,0 +1,66 @@ +/** Public display metadata; never used as account authorization. */ +export interface GitHubAccountProfile { + userId: string; + login: string; + avatarUrl: string; + fetchedAt: number; +} +type ProfileStorage = Pick; + +export function normalizeGitHubProfile(userId: string, value: unknown): GitHubAccountProfile { + const user = value as Record | null; + if (!/^[1-9][0-9]*$/.test(userId) || !user || !Number.isSafeInteger(user.id) + || String(user.id) !== userId || typeof user.login !== 'string' + || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$/.test(user.login)) { + throw new Error('Invalid GitHub profile identity'); + } + return { + userId, login: user.login, + avatarUrl: typeof user.avatar_url === 'string' && user.avatar_url.startsWith('https://avatars.githubusercontent.com/') + ? user.avatar_url : '', + fetchedAt: Date.now(), + }; +} + +function browserStorage(): ProfileStorage | null { + try { return window.localStorage; } catch { return null; } +} + +export async function loadGitHubAccountProfile( + userId: string, + apply: (profile: GitHubAccountProfile) => void, + signal: AbortSignal, + storage: ProfileStorage | null = browserStorage(), + request: typeof fetch = fetch, +): Promise { + if (!/^[1-9][0-9]*$/.test(userId) || signal.aborted) return; + const key = `openbitfun.mobile.github_profile.v1.${userId}`; + try { + const cached = JSON.parse(storage?.getItem(key) || 'null'); + if (cached?.userId === userId && Number.isFinite(cached.fetchedAt)) { + const profile = normalizeGitHubProfile(userId, { + id: Number(userId), login: cached.login, avatar_url: cached.avatarUrl, + }); + profile.fetchedAt = cached.fetchedAt; + apply(profile); + const age = Date.now() - profile.fetchedAt; + if (age >= 0 && age < 24 * 60 * 60 * 1000) return; + } + } catch { /* Corrupt or inaccessible cache stays in place. */ } + const timeout = new AbortController(); + const abort = () => timeout.abort(); + signal.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(abort, 5000); + try { + const response = await request(`https://api.github.com/user/${userId}`, { + headers: { Accept: 'application/vnd.github+json' }, + credentials: 'omit', signal: timeout.signal, + }); + if (!response.ok) return; + const profile = normalizeGitHubProfile(userId, await response.json()); + if (signal.aborted || timeout.signal.aborted) return; + apply(profile); + try { storage?.setItem(key, JSON.stringify(profile)); } catch { /* Optional metadata. */ } + } catch { /* Offline/rate-limited metadata must not affect the account session. */ } + finally { clearTimeout(timer); signal.removeEventListener('abort', abort); } +} diff --git a/src/mobile-web/src/styles/components/harmony-native.scss b/src/mobile-web/src/styles/components/harmony-native.scss index 291774c7ca..4ebe7414db 100644 --- a/src/mobile-web/src/styles/components/harmony-native.scss +++ b/src/mobile-web/src/styles/components/harmony-native.scss @@ -1429,3 +1429,21 @@ } &[data-checked='true'] { justify-content: flex-end; } } + +.mobile-account-avatar { + display: inline-flex; + width: 24px; + height: 24px; + flex-shrink: 0; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + + img { width: 100%; height: 100%; object-fit: cover; } +} + +.harmony-sidebar__account-avatar .mobile-account-avatar { + width: 100%; + height: 100%; +} diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index de06dced4d..c465086181 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -21,7 +21,10 @@ const offline = { device_id: 'desktop-a', device_name: 'Offline desktop', online const online = { device_id: 'desktop-b', device_name: 'Online desktop', online: true }; const controller = { device_id: 'browser', device_name: 'Browser', online: true }; -const navigationModule = await loadSource('../src/services/MobileNavigationStore.ts'); +const harnessModule = await loadSource('../../shared/agent-harness/contract.generated.ts'); +const navigationModule = await loadSource('../src/services/MobileNavigationStore.ts', { + '../../../shared/agent-harness/contract.generated': harnessModule.url, +}); const { loadMobileNavigation, saveMobileNavigation, clearMobileNavigation } = await import(navigationModule.url); test('same-tab reload restores the selected device and session only within the authenticated QR scope', () => { @@ -37,7 +40,9 @@ test('same-tab reload restores the selected device and session only within the a }; const navigation = { deviceId: 'desktop-b', session: { id: 'session-b', name: 'Task B', agentType: 'agentic' } }; saveMobileNavigation(scope, navigation, storage); - assert.deepEqual(loadMobileNavigation(scope, storage), navigation); + assert.deepEqual(loadMobileNavigation(scope, storage), { + ...navigation, session: { ...navigation.session, agentType: 'Standard' }, + }); for (const replacement of [ { accountId: 'account-b' }, { controllerDeviceId: 'browser-b' }, { relayUrl: 'https://another-relay.example.com' }, { routeKey: '/relay/r/new/#/pair?did=desktop-c' }, @@ -198,3 +203,45 @@ test('official and local invitations share strict device-only targeting', async assert.equal(accountDeviceIdFromHash('#/pair?did=desktop'), 'desktop'); assert.equal(accountDeviceIdFromHash('#/chat?did=desktop'), null); }); + +const githubModule = await loadSource('../src/services/GitHubAccountProfile.ts'); +const { loadGitHubAccountProfile, normalizeGitHubProfile } = await import(githubModule.url); +const githubUser = { id: 123, login: 'example', avatar_url: 'https://avatars.githubusercontent.com/u/123' }; + +test('GitHub public profile validates stable identity and never forwards account credentials', async () => { + assert.throws(() => normalizeGitHubProfile('124', githubUser)); + assert.equal(normalizeGitHubProfile('123', { ...githubUser, avatar_url: 'https://other.example/avatar' }).avatarUrl, ''); + const profiles = []; + await loadGitHubAccountProfile('123', p => profiles.push(p), new AbortController().signal, null, async (url, options) => { + assert.equal(url, 'https://api.github.com/user/123'); + assert.equal(options.credentials, 'omit'); + assert.deepEqual(options.headers, { Accept: 'application/vnd.github+json' }); + return { ok: true, json: async () => githubUser }; + }); + assert.equal(profiles[0].login, 'example'); +}); + +test('GitHub cached profiles survive refresh failure and fresh cache avoids a request', async () => { + for (const age of [0, 2 * 86400000]) { + const profile = { ...normalizeGitHubProfile('123', githubUser), fetchedAt: Date.now() - age }; + let requests = 0; + const applied = []; + await loadGitHubAccountProfile('123', p => applied.push(p), new AbortController().signal, + { getItem: () => JSON.stringify(profile), setItem: () => assert.fail('must retain cache') }, + async () => { requests++; throw new Error('offline'); }); + assert.equal(applied[0].login, 'example'); + assert.equal(requests, age ? 1 : 0); + } +}); + +test('GitHub profile ignores corrupt/foreign cache and stale account responses', async () => { + for (const raw of ['{', JSON.stringify({ userId: '456', fetchedAt: Date.now(), login: 'another' })]) { + const controller = new AbortController(); + await loadGitHubAccountProfile('123', () => assert.fail('stale profile applied'), controller.signal, + { getItem: () => raw, setItem: () => assert.fail('stale profile cached') }, + async () => { + controller.abort(); + return { ok: true, json: async () => githubUser }; + }); + } +});