diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index b37a8d059f..df8de278f2 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -126,6 +126,101 @@ "states": ["loading", "text", "markdown", "image", "unsupported", "failed", "truncated"], "tokens": ["sheet_header_height", "file_link", "card", "line", "soft"], "platformNotes": "Placement is shared policy; rendering, file export, and platform-safe dismissal remain native adapters." + }, + "miniapp_navigation": { + "purpose": "Opens locally bundled tools from primary sidebar navigation, without requiring account or device access.", + "anatomy": [ + "outlined_apps_glyph", + "leading_aligned_label", + "full_row_touch_target" + ], + "states": [ + "idle", + "pressed", + "focused", + "signed_out", + "connected" + ], + "tokens": [ + "ink", + "body_large", + "control_touch_size" + ], + "platformNotes": "Use a quiet 48vp icon-and-label row in compact and wide sidebars, with 16vp medium-weight navigation text, a 24vp icon slot and a 14vp gap. HarmonyOS uses the native outlined sys.symbol.square_grid_2x2 at 24vp, centered in its slot and tinted with the semantic ink color. Render the catalog inside the shell content pane so selecting it slides the right pane left as the drawer closes, using the existing 250ms ease-out shell motion. Sidebar session selection uses the same shell motion without a second route-push animation. Do not promote this navigation destination to a primary action on remote home." + }, + "miniapp_catalog": { + "purpose": "Presents bundled apps with large visual previews that open each app directly.", + "anatomy": [ + "back_control", + "centered_page_title", + "offline_caption", + "square_preview", + "app_title" + ], + "states": [ + "catalog", + "loading", + "error", + "app_open", + "light", + "dark" + ], + "tokens": [ + "page_bg", + "ink", + "muted", + "soft", + "body_large", + "title_small", + "body_medium", + "label_small" + ], + "platformNotes": "Use a two-column library of square previews on compact screens and three columns when the gallery reaches 600vp. Clip previews to 24vp corners with 14vp column and 20vp row gaps; keep app names below images. The centered gallery caps at 1000vp, while open apps use full width. Reuse established showcase assets. Resize the grid without reloading an open app." + }, + "welcome_home": { + "purpose": "Introduces the mobile companion and provides a direct connection action with an offline MiniApp alternative.", + "anatomy": [ + "desktop_contour_brand_mark", + "centered_heading", + "connection_guidance", + "bottom_primary_action", + "quiet_miniapp_action" + ], + "states": [ + "disconnected", + "connecting", + "connected", + "light", + "dark", + "compact", + "wide" + ], + "tokens": [ + "page_bg", + "ink", + "muted", + "primary_action", + "content_on_action", + "body_medium" + ], + "platformNotes": "Reuse the desktop contour mark at 104vp. Use a 22vp medium heading and 14vp supporting copy, a scrollable hero, 24vp side margins, and a 52vp capsule action constrained to 360vp. Keep connection state truthful; offline MiniApps remain available." + }, + "startup_brand_reveal": { + "purpose": "Provides a short desktop-inspired identity transition on cold launch without waiting for network or account loading.", + "anatomy": [ + "theme_background", + "desktop_contour_brand_mark" + ], + "states": [ + "cold_start", + "reduced_motion", + "backgrounded" + ], + "tokens": [ + "page_bg", + "ink" + ], + "platformNotes": "Center the shared mark at 104vp above a 24vp medium OpenBitFun wordmark. Scale the mark from 0.94 to 1 over 360ms; reveal letters with opacity and an 8vp rise over 260ms each, starting at 140ms with 55ms staggering. Reserve the full wordmark width. Dissolve the overlay over 240ms starting at 1050ms. Remove on completion or background; never replay on foreground. Skip when the system reduced-motion setting is available and enabled. Clean up timers on removal." } } } diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index 5f13c3b4d0..f1bd229a11 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -826,6 +826,101 @@ export const mobileComponents = { "soft" ], "platformNotes": "Placement is shared policy; rendering, file export, and platform-safe dismissal remain native adapters." + }, + "miniapp_navigation": { + "purpose": "Opens locally bundled tools from primary sidebar navigation, without requiring account or device access.", + "anatomy": [ + "outlined_apps_glyph", + "leading_aligned_label", + "full_row_touch_target" + ], + "states": [ + "idle", + "pressed", + "focused", + "signed_out", + "connected" + ], + "tokens": [ + "ink", + "body_large", + "control_touch_size" + ], + "platformNotes": "Use a quiet 48vp icon-and-label row in compact and wide sidebars, with 16vp medium-weight navigation text, a 24vp icon slot and a 14vp gap. HarmonyOS uses the native outlined sys.symbol.square_grid_2x2 at 24vp, centered in its slot and tinted with the semantic ink color. Render the catalog inside the shell content pane so selecting it slides the right pane left as the drawer closes, using the existing 250ms ease-out shell motion. Sidebar session selection uses the same shell motion without a second route-push animation. Do not promote this navigation destination to a primary action on remote home." + }, + "miniapp_catalog": { + "purpose": "Presents bundled apps with large visual previews that open each app directly.", + "anatomy": [ + "back_control", + "centered_page_title", + "offline_caption", + "square_preview", + "app_title" + ], + "states": [ + "catalog", + "loading", + "error", + "app_open", + "light", + "dark" + ], + "tokens": [ + "page_bg", + "ink", + "muted", + "soft", + "body_large", + "title_small", + "body_medium", + "label_small" + ], + "platformNotes": "Use a two-column library of square previews on compact screens and three columns when the gallery reaches 600vp. Clip previews to 24vp corners with 14vp column and 20vp row gaps; keep app names below images. The centered gallery caps at 1000vp, while open apps use full width. Reuse established showcase assets. Resize the grid without reloading an open app." + }, + "welcome_home": { + "purpose": "Introduces the mobile companion and provides a direct connection action with an offline MiniApp alternative.", + "anatomy": [ + "desktop_contour_brand_mark", + "centered_heading", + "connection_guidance", + "bottom_primary_action", + "quiet_miniapp_action" + ], + "states": [ + "disconnected", + "connecting", + "connected", + "light", + "dark", + "compact", + "wide" + ], + "tokens": [ + "page_bg", + "ink", + "muted", + "primary_action", + "content_on_action", + "body_medium" + ], + "platformNotes": "Reuse the desktop contour mark at 104vp. Use a 22vp medium heading and 14vp supporting copy, a scrollable hero, 24vp side margins, and a 52vp capsule action constrained to 360vp. Keep connection state truthful; offline MiniApps remain available." + }, + "startup_brand_reveal": { + "purpose": "Provides a short desktop-inspired identity transition on cold launch without waiting for network or account loading.", + "anatomy": [ + "theme_background", + "desktop_contour_brand_mark" + ], + "states": [ + "cold_start", + "reduced_motion", + "backgrounded" + ], + "tokens": [ + "page_bg", + "ink" + ], + "platformNotes": "Center the shared mark at 104vp above a 24vp medium OpenBitFun wordmark. Scale the mark from 0.94 to 1 over 360ms; reveal letters with opacity and an 8vp rise over 260ms each, starting at 140ms with 55ms staggering. Reserve the full wordmark width. Dissolve the overlay over 240ms starting at 1050ms. Remove on completion or background; never replay on foreground. Skip when the system reduced-motion setting is available and enabled. Clean up timers on removal." } } }; diff --git a/src/apps/mobile/harmonyos/.gitignore b/src/apps/mobile/harmonyos/.gitignore index aedfdbe8ea..42c31716c8 100644 --- a/src/apps/mobile/harmonyos/.gitignore +++ b/src/apps/mobile/harmonyos/.gitignore @@ -29,3 +29,6 @@ *.pem *.hap *.app + +# Generated from shared desktop MiniApp sources by entry/hvigorfile.ts +/entry/src/main/resources/rawfile/miniapps/ diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index f447897f62..966318bf79 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -69,6 +69,7 @@ through `LocaleController`. Do not import Web UI or mobile-web locale catalogs. The current local HarmonyOS verification loop is: ```bash +node --test miniapps/*.test.cjs # bundled sources, browser bridge, and local storage compatibility source scripts/ohos-env.sh "$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon "$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon diff --git a/src/apps/mobile/harmonyos/README.md b/src/apps/mobile/harmonyos/README.md index b1621fbaee..48e1295b46 100644 --- a/src/apps/mobile/harmonyos/README.md +++ b/src/apps/mobile/harmonyos/README.md @@ -43,3 +43,56 @@ a local signing identity in DevEco Studio when installing the app on a device. The current project targets HarmonyOS `6.1.1(24)` and supports `6.0.1(21)` or newer on phone and tablet devices. + +## Built-in MiniApps + +Choose **MiniApps** from the sidebar to open Gomoku, Regex Playground, +or Daily Divination. The gallery uses two columns of rounded showcase previews +on phones and three on wide screens; tapping a preview opens the app directly. +Showcase images reuse the existing desktop artwork under +`src/web-ui/src/assets/miniapps/showcases/`. Navigation typography follows the +shared mobile body role, with the same proportions on compact and wide hosts. + +These bundled pages run locally in ArkWeb without signing +in, pairing, a desktop connection, or internet access. Storage and clipboard +writes use the phone. Data is isolated by app and retained across app upgrades; +it is not synchronized with desktop MiniApps. Closing a page discards its +unsaved page state. Changing desktop targets has no effect on these local tools. + +Every IDE/CLI HAP build runs `miniapps/generate.cjs` through +`entry/hvigorfile.ts`. It packages the existing shared MiniApp sources and +canonical appearance tokens into generated raw resources; do not edit those +outputs. `miniapps/mobile.css` owns phone-only layout adaptation. The pages +follow the app language and system light/dark appearance. They resize in place +for compact, wide, and folded/unfolded layouts. + +The sandboxed iframe has no network access or local-file access. The native +bridge permits only per-app storage keys and clipboard writes; it has no generic +invoke, Worker, shell, workspace, AI, or Agent API. Local records are replaced +atomically, and malformed records are retained and return an error. These local +tools do not read any remote workspace or peer content, and do not execute jobs +on CLI/Detached Dispatch hosts. Existing Remote Connect protocols are unchanged. +Desktop catalogs, page transfer, and remote MiniApp execution are deferred. + +Run the local test and HAP build commands in `AGENTS.md`, plus: + +```bash +node --test miniapps/*.test.cjs +``` + +Before claiming device verification, exercise local/offline launches, saved data +after relaunch, clipboard, keyboard input, compact/wide layouts, light/dark +appearance, and a live resize/fold transition on supported hardware. + +### Home and launch presentation + +Cold launches reuse the desktop contour mark and reveal the OpenBitFun wordmark +letter by letter in approximately 1.3 seconds. This presentation does not wait +for network/account initialization, is dismissed when backgrounded, and does not +replay on foreground. The system reduced-motion preference skips it where the +platform exposes that preference (API 23+). Native launch artwork and the app +surface both follow the system light/dark theme. + +The welcome surface keeps connection actions at the bottom and offers bundled +MiniApps without a desktop connection. Connected, connecting, and empty wide +surfaces retain their existing remote capability and session routing behavior. diff --git a/src/apps/mobile/harmonyos/entry/hvigorfile.ts b/src/apps/mobile/harmonyos/entry/hvigorfile.ts index b0e3a1ab98..91d8ca97dd 100644 --- a/src/apps/mobile/harmonyos/entry/hvigorfile.ts +++ b/src/apps/mobile/harmonyos/entry/hvigorfile.ts @@ -1,5 +1,8 @@ import { hapTasks } from '@ohos/hvigor-ohos-plugin'; +// Include the current shared sources on every IDE and CLI build. +require('../miniapps/generate.cjs').generate(); + export default { system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ 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 f9b1406007..798b51c3a4 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 @@ -1,4 +1,21 @@ export const EN_US_MESSAGES: [string, string][] = [ + ['home.welcomeTitle', 'Pick up wherever you are'], + ['home.welcomeDetail', 'Connect your computer to follow your tasks and continue the conversation.'], + ['home.connectComputer', 'Connect computer'], + ['home.connecting', 'Connecting…'], + ['home.exploreMiniApps', 'Explore MiniApps'], + ['home.miniAppsHint', 'Ready to use, even without a computer'], + ['miniapp.allApps', 'All apps'], + ['miniapp.availableOffline', 'Available offline'], + + ['miniapp.timeout', 'The local operation timed out. It may still finish; it was not retried.'], + ['miniapp.title', 'MiniApps'], + ['miniapp.refresh', 'Refresh'], + ['miniapp.empty', 'No built-in MiniApps are available.'], + ['miniapp.unsupported', 'This capability is unavailable in built-in mobile MiniApps.'], + ['miniapp.invalidPage', 'The MiniApp page is incomplete. Reopen it.'], + ['miniapp.closed', 'The MiniApp was closed or changed.'], + ['miniapp.previewHint', 'Built in · Works offline. Data stays on this phone.'], ['notification.onboardingTitle', 'Task completion notifications'], ['notification.onboardingBody', 'Allow OpenBitFun to notify you when tasks finish. You can change this later in system settings.'], ['notification.onboardingLater', 'Later'], 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 1322553bd3..ca24f6cf7b 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 @@ -1,4 +1,21 @@ export const ZH_CN_MESSAGES: [string, string][] = [ + ['home.welcomeTitle', '让灵感,随时继续'], + ['home.welcomeDetail', '连接你的电脑,查看任务进度,继续对话。'], + ['home.connectComputer', '连接电脑'], + ['home.connecting', '正在连接…'], + ['home.exploreMiniApps', '探索小应用'], + ['home.miniAppsHint', '无需连接电脑,也能即刻使用'], + ['miniapp.allApps', '全部应用'], + ['miniapp.availableOffline', '离线可用'], + + ['miniapp.timeout', '本地操作超时,操作可能仍会完成,未自动重试。'], + ['miniapp.title', '小应用'], + ['miniapp.refresh', '刷新'], + ['miniapp.empty', '暂无内置小应用。'], + ['miniapp.unsupported', '手机内置小应用不支持此能力。'], + ['miniapp.invalidPage', '小应用页面数据不完整,请重新打开。'], + ['miniapp.closed', '小应用已关闭或切换。'], + ['miniapp.previewHint', '内置应用 · 离线可用,数据保存在本机。'], ['notification.onboardingTitle', '开启任务完成提醒'], ['notification.onboardingBody', '允许 OpenBitFun 在任务完成时发送通知。你可以稍后在系统设置中更改。'], ['notification.onboardingLater', '稍后'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/MiniAppModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/MiniAppModels.ets new file mode 100644 index 0000000000..558e8dd0fa --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/MiniAppModels.ets @@ -0,0 +1,46 @@ +export interface MiniAppSummary { + id: string; + name: string; + description: string; +} + +export interface MiniAppBridgeParams { + key?: string; + value?: Object | null; + text?: string; +} + +export interface MiniAppBridgeRequest { + id: string; + method: string; + params?: MiniAppBridgeParams; +} + +export interface MiniAppBridgeError { message: string; } +export interface MiniAppBridgeReply { + jsonrpc: string; + id: string; + result?: Object | null; + error?: MiniAppBridgeError; +} + +/** Local data source, deliberately independent of any connection/session. */ +export interface MiniAppHost { + list(locale: string): Promise; + document(appId: string): Promise; + call(appId: string, method: string, params: MiniAppBridgeParams): Promise; +} + +export interface MiniAppActions { + open: () => void; + close: () => void; + back: () => void; + refresh: () => void; + select: (id: string) => void; + request: (raw: string, revision: number) => Promise; +} + +export function emptyMiniAppActions(): MiniAppActions { + return { open: () => {}, close: () => {}, back: () => {}, refresh: () => {}, select: () => {}, + request: async (_raw: string, _revision: number): Promise => '' }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index bdf2ce7e99..74de864560 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,3 +1,4 @@ +import { StartupBrandReveal } from './components/StartupBrandReveal'; import { AppRootPresentation } from './components/AppRootPresentation'; import { WatchProvisionCard } from './components/WatchProvisionCard'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; @@ -6,6 +7,7 @@ import { AppRootRuntime } from './runtime/AppRootRuntime'; @Entry @ComponentV2 struct AppRoot { + @Local showStartupBrand: boolean = true; private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); @@ -23,6 +25,7 @@ struct AppRoot { } onPageHide(): void { + this.showStartupBrand = false; this.runtime.onPageHide(); } @@ -31,6 +34,11 @@ struct AppRoot { } onBackPress(): boolean { + if (this.runtime.miniAppViewModel.state.visible) { + if (this.runtime.miniAppViewModel.state.appId) this.runtime.miniAppViewModel.actions.back(); + else this.runtime.miniAppViewModel.close(); + return true; + } return this.runtime.handleRootBack(); } @@ -38,6 +46,8 @@ struct AppRoot { Stack() { AppRootPresentation({ shellState: this.runtime.appShellState, + miniAppState: this.runtime.miniAppViewModel.state, + miniAppActions: this.runtime.miniAppViewModel.actions, navigationStack: this.runtime.navigationStack, remotePageState: this.runtime.remotePageState, deviceDirectoryState: this.runtime.deviceDirectoryState, @@ -47,6 +57,10 @@ struct AppRoot { actions: this.runtime.presentationActions }) + if (this.showStartupBrand) { + StartupBrandReveal({ onFinished: () => { this.showStartupBrand = false; } }) + } + // Sits above every route on purpose: a watch waiting for approval must // not be hidden behind whatever screen the phone happens to be on. WatchProvisionCard({ 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 1f2a04a49c..2b135f2388 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 @@ -26,6 +26,7 @@ export interface FilePreviewPresentationActions { } export interface RemoteHomePresentationActions { + readonly openMiniApps: () => void; readonly openSidebar: () => void; readonly connectWorkspace: () => void; readonly addConnection: () => void; @@ -112,6 +113,7 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { onCompactLayoutEntered: () => {}, onLayoutModeChanged: () => {}, onRemoteHome: { + openMiniApps: () => {}, openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {}, refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, 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 28a80e16e3..561637c08a 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 @@ -50,6 +50,7 @@ export struct AppSidebarSurface { onClose: this.actions.onSidebar.close, onNewChat: this.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, + onOpenMiniApps: this.actions.onRemoteHome.openMiniApps, onScanDesktop: this.actions.onSidebar.scanDesktop, onOpenViewSettings: this.onOpenRemoteViewSettings, onSearchQueryChange: (query: string) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index fee810df12..ac22af056e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -1,3 +1,6 @@ +import { MiniAppActions, emptyMiniAppActions } from '../../model/MiniAppModels'; +import { MiniAppState } from '../state/MiniAppState'; +import { MiniAppSurface } from './MiniAppSurface'; import display from '@ohos.display'; import mediaQuery from '@ohos.mediaquery'; import { RemoteUiState } from '../../services/RemoteUiState'; @@ -60,6 +63,8 @@ enum WideWorkspacePresentationMode { @ComponentV2 export struct AppRootPresentation { + @Param miniAppState: MiniAppState = new MiniAppState(); + @Param miniAppActions: MiniAppActions = emptyMiniAppActions(); @Param shellState: AppShellState = new AppShellState(); @Param navigationStack: NavPathStack = new NavPathStack(); @Param remotePageState: RemotePageState = new RemotePageState(); @@ -183,6 +188,12 @@ export struct AppRootPresentation { Column() { this.WideRouteContent() } .width('100%').height('100%').backgroundColor(PAGE_BG) } + + // Sidebar destinations belong to the moving content pane. A root-level + // overlay would cover the drawer before its closing animation can run. + if (this.miniAppState.visible) { + MiniAppSurface({ state: this.miniAppState, actions: this.miniAppActions }) + } } .width('100%') .height('100%') 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 1855b6e799..c8931d72f8 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 @@ -22,6 +22,7 @@ export struct AppSidebar { @Param showViewSettingsButton: boolean = false; @Param showWorkspaceSection: boolean = false; @Event onClose: () => void = () => {}; + @Event onOpenMiniApps: () => void = () => {}; @Event onNewChat: (agentType: string) => void = (_agentType: string) => {}; @Event onEnterCode: () => void = () => {}; @Event onScanDesktop: () => void = () => {}; @@ -57,6 +58,23 @@ export struct AppSidebar { // Device sections own their remote workspace and session rows. Scroll() { Column() { + Button({ type: ButtonType.Normal }) { + Row({ space: 14 }) { + Stack({ alignContent: Alignment.Center }) { + SidebarGlyph({ kind: 'apps' }) + }.width(24).height(24) + Text(RemoteI18n.t('miniapp.title')) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Blank() + }.width('100%').alignItems(VerticalAlign.Center) + } + .width('100%').height(48).padding({ left: 4, right: 8 }) + .backgroundColor(TRANSPARENT).borderRadius(12) + .margin({ top: 16, bottom: 16 }).stateEffect(true) + .accessibilityText(RemoteI18n.t('miniapp.title')) + .onClick(() => this.onOpenMiniApps()) if (this.showWorkspaceSection) { this.contentSlot() } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BrandMark.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BrandMark.ets new file mode 100644 index 0000000000..794b512be3 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BrandMark.ets @@ -0,0 +1,12 @@ +import { TemplateIcon } from './TemplateIcon'; + +/** The same contour mark used by the desktop splash, tinted for both themes. */ +@ComponentV2 +export struct BrandMark { + @Param markSize: number = 96; + + build() { + TemplateIcon({ src: $r('app.media.openbitfun_brand_mark'), iconWidth: this.markSize, iconHeight: this.markSize }) + .accessibilityLevel('no') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets new file mode 100644 index 0000000000..4603b3bf1e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -0,0 +1,87 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { MiniAppActions, MiniAppSummary, emptyMiniAppActions } from '../../model/MiniAppModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MiniAppState } from '../state/MiniAppState'; +import { MiniAppWebView } from './MiniAppWebView'; +import { NavigationBackButton } from './NavigationBackButton'; +import { PAGE_BG, INK, MUTED, SOFT, LINE, TRANSPARENT } from './Theme'; + +@ComponentV2 +export struct MiniAppSurface { + @Param state: MiniAppState = new MiniAppState(); + @Param actions: MiniAppActions = emptyMiniAppActions(); + @Local galleryWidth: number = 0; + + build() { + Column() { + Row({ space: 8 }) { + NavigationBackButton({ onBack: (): void => { + if (this.state.appId) this.actions.back(); + else this.actions.close(); + } }) + Text(this.state.appId ? this.state.name : RemoteI18n.t('miniapp.title')) + .fontSize(MobileDesignTypography.bodyLarge.size).fontWeight(FontWeight.Medium) + .fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center).layoutWeight(1) + Blank().width(48) + }.width('100%').height(56).padding({ left: 12, right: 12 }) + if (this.state.document) { + MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request }) + .layoutWeight(1) + } else { + Column() { + Row() { + Text(RemoteI18n.t('miniapp.allApps')) + .fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium).fontColor(INK) + .padding({ left: 16, right: 16, top: 10, bottom: 10 }).backgroundColor(SOFT).borderRadius(24) + Blank() + Text(RemoteI18n.t('miniapp.availableOffline')) + .fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + }.width('100%').padding({ top: 16, bottom: 20 }) + if (this.state.loading) { + LoadingProgress().width(32).height(32).margin(24) + } else if (this.state.error) { + Text(this.state.error).fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(MUTED).padding({ top: 16, bottom: 16 }).copyOption(CopyOptions.InApp) + Button(RemoteI18n.t('miniapp.refresh')) + .fontColor(INK).backgroundColor(SOFT).onClick(() => this.actions.refresh()) + } else if (this.state.apps.length === 0) { + Text(RemoteI18n.t('miniapp.empty')).fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(MUTED).padding(24) + } else { + Grid() { + ForEach(this.state.apps, (app: MiniAppSummary) => { + GridItem() { this.PreviewTile(app) } + }, (app: MiniAppSummary) => app.id) + }.columnsTemplate(this.galleryWidth >= 600 ? '1fr 1fr 1fr' : '1fr 1fr') + .columnsGap(14).rowsGap(20).layoutWeight(1).width('100%').scrollBar(BarState.Off) + } + }.width('100%').constraintSize({ maxWidth: 1000 }).layoutWeight(1) + .alignItems(HorizontalAlign.Start).padding({ left: 16, right: 16, bottom: 24 }) + .onAreaChange((_oldArea: Area, area: Area) => { this.galleryWidth = Number(area.width); }) + } + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private PreviewTile(app: MiniAppSummary) { + Button({ type: ButtonType.Normal }) { + Column({ space: 10 }) { + Image(this.preview(app.id)).width('100%').aspectRatio(1).objectFit(ImageFit.Cover) + .backgroundColor(SOFT).borderRadius(24).border({ width: 0.5, color: LINE }) + .draggable(false).accessibilityLevel('no') + Text(app.name).fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium).fontColor(INK).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%').padding({ left: 4, right: 4 }) + }.width('100%').alignItems(HorizontalAlign.Start) + }.width('100%').padding(0).backgroundColor(TRANSPARENT).borderRadius(24) + .stateEffect(true).accessibilityText(`${app.name}, ${app.description}`) + .onClick(() => this.actions.select(app.id)) + } + + private preview(id: string): Resource { + if (id === 'builtin-gomoku') return $r('app.media.miniapp_gomoku_preview'); + if (id === 'builtin-regex-playground') return $r('app.media.miniapp_regex_preview'); + return $r('app.media.miniapp_divination_preview'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppWebView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppWebView.ets new file mode 100644 index 0000000000..7f63c34e53 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppWebView.ets @@ -0,0 +1,61 @@ +import { webview } from '@kit.ArkWeb'; + +// A synthetic origin for in-memory HTML; no document is fetched from this URL. +const MINIAPP_DOCUMENT_URL: string = 'https://miniapp.invalid/'; + +/** Platform leaf. ArkWeb exposes proxies to frames; the ViewModel treats every request as untrusted. */ +class MiniAppNativeBridge { + private readonly dispatch: (raw: string) => void; + constructor(dispatch: (raw: string) => void) { this.dispatch = dispatch; } + request(raw: string): void { this.dispatch(raw); } +} + +@ComponentV2 +export struct MiniAppWebView { + @Param document: string = ''; + @Param revision: number = 0; + @Param request: (raw: string, revision: number) => Promise = async (_raw: string, _revision: number): Promise => ''; + private controller: webview.WebviewController = new webview.WebviewController(); + private active: boolean = true; + private bridge: MiniAppNativeBridge = new MiniAppNativeBridge((raw: string): void => { + void this.reply(raw); + }); + + private async reply(raw: string): Promise { + if (!this.active) return; + const revision = this.revision; + const response = await this.request(raw, revision); + if (!this.active || revision !== this.revision || !response) return; + try { + await this.controller.runJavaScript(`window.__miniappReply(JSON.parse(${JSON.stringify(response)}))`); + } catch (_error) { + // Navigation/close invalidates replies; never replay a host mutation. + } + } + + aboutToDisappear(): void { this.active = false; } + + build() { + // A nonempty initial src can overwrite loadData after controller attachment. + Web({ src: '', controller: this.controller }) + .javaScriptAccess(true) + .darkMode(WebDarkMode.Auto) + .forceDarkAccess(false) + .domStorageAccess(false) + .fileAccess(false) + .javaScriptProxy({ object: this.bridge, name: 'miniappNative', methodList: ['request'], controller: this.controller }) + .onControllerAttached(() => { + this.active = true; + // Without an HTTP(S) base, ArkWeb interprets raw HTML as a data URL: + // CSS hashes become URL fragments and truncate the page before its script. + this.controller.loadData(this.document, 'text/html', 'UTF-8', MINIAPP_DOCUMENT_URL); + }) + .onOverrideUrlLoading((request: WebResourceRequest) => { + // Only the wrapper and its sandboxed blob child may navigate here. + const url = request.getRequestUrl(); + return request.isMainFrame() ? url !== 'about:blank' && url !== MINIAPP_DOCUMENT_URL : !url.startsWith('blob:'); + }) + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteWelcomePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteWelcomePanel.ets new file mode 100644 index 0000000000..9adcf018c0 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteWelcomePanel.ets @@ -0,0 +1,62 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { BrandMark } from './BrandMark'; +import { INK, MUTED, TRANSPARENT } from './Theme'; + +/** Shared welcome composition for phone home and an empty wide master pane. */ +@ComponentV2 +export struct RemoteWelcomePanel { + @Param title: string = ''; + @Param detail: string = ''; + @Param busy: boolean = false; + @BuilderParam primaryAction: () => void = this.EmptyAction; + @Event onMiniApps: () => void = () => {}; + + build() { + Column() { + Scroll() { + Column({ space: 16 }) { + BrandMark({ markSize: 104 }) + .margin({ bottom: 12 }) + Text(this.title) + .fontSize(MobileDesignTypography.headlineLarge.size).fontWeight(FontWeight.Medium).fontColor(INK) + .textAlign(TextAlign.Center) + Text(this.detail) + .fontSize(MobileDesignTypography.bodyMedium.size) + .lineHeight(23).fontColor(MUTED).textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 300 }) + if (this.busy) { + LoadingProgress().width(20).height(20).color(MUTED) + } + } + .width('100%').padding({ top: 32, bottom: 32, left: 12, right: 12 }) + } + .width('100%').layoutWeight(1).scrollBar(BarState.Off) + .align(Alignment.Center) + + Column({ space: 10 }) { + this.primaryAction() + Button({ type: ButtonType.Normal }) { + Row({ space: 9 }) { + SymbolGlyph($r('sys.symbol.square_grid_2x2')).fontSize(18).fontColor([MUTED]) + Text(RemoteI18n.t('home.exploreMiniApps')) + .fontSize(MobileDesignTypography.bodyMedium.size).fontColor(MUTED) + } + .justifyContent(FlexAlign.Center) + } + .width('100%').height(48).backgroundColor(TRANSPARENT) + .borderRadius(24).onClick(this.onMiniApps) + Text(RemoteI18n.t('home.miniAppsHint')) + .fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%').constraintSize({ maxWidth: 360 }) + .padding({ top: 12, bottom: 24 }) + } + .width('100%').height('100%').constraintSize({ maxWidth: 560 }) + .padding({ left: 24, right: 24 }).alignItems(HorizontalAlign.Center) + } + + @Builder + private EmptyAction() { Row() {} } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets index 6a2292e434..1b841d41c1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets @@ -90,18 +90,12 @@ export struct SidebarGlyph { @Builder private Apps() { - Column({ space: 8 }) { - Row({ space: 8 }) { this.AppDot(); this.AppDot(); } - Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.square_grid_2x2')) + .fontSize(24) + .fontColor([this.tint]) } .width(24).height(24) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - @Builder - private AppDot() { - Text('').width(8).height(8).borderRadius(4).backgroundColor(INK) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StartupBrandReveal.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StartupBrandReveal.ets new file mode 100644 index 0000000000..1f3a56ac12 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StartupBrandReveal.ets @@ -0,0 +1,69 @@ +import { MotionPreference } from '../../services/MotionPreference'; +import { BrandMark } from './BrandMark'; +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { PAGE_BG, INK } from './Theme'; + +/** A brief cold-start brand reveal, independent of account/network readiness. */ +@ComponentV2 +export struct StartupBrandReveal { + @Event onFinished: () => void = () => {}; + @Local opacityValue: number = 1; + @Local markScale: number = 0.94; + @Local lettersVisible: boolean = false; + private readonly letters: string[] = ['O', 'p', 'e', 'n', 'B', 'i', 't', 'F', 'u', 'n']; + private entranceTimer: number = -1; + private exitTimer: number = -1; + private active: boolean = false; + + aboutToAppear(): void { + this.active = true; + if (MotionPreference.reduceMotion()) { + this.onFinished(); + return; + } + this.entranceTimer = setTimeout(() => { + if (!this.active) return; + this.lettersVisible = true; + this.getUIContext().animateTo({ duration: 360, curve: Curve.EaseOut }, () => { + this.markScale = 1; + }); + }, 16); + this.exitTimer = setTimeout(() => { + if (!this.active) return; + this.getUIContext().animateTo({ + duration: 240, + curve: Curve.EaseOut, + onFinish: () => { if (this.active) this.onFinished(); } + }, () => { this.opacityValue = 0; }); + }, 1050); + } + + aboutToDisappear(): void { + this.active = false; + clearTimeout(this.entranceTimer); + clearTimeout(this.exitTimer); + } + + build() { + Stack({ alignContent: Alignment.Center }) { + Column({ space: 24 }) { + BrandMark({ markSize: 104 }).scale({ x: this.markScale, y: this.markScale }) + Row() { + ForEach(this.letters, (letter: string, index: number) => { + Text(letter) + .fontSize(MobileDesignTypography.displayLarge.size) + .fontWeight(FontWeight.Medium).fontColor(INK) + .opacity(this.lettersVisible ? 1 : 0) + .translate({ y: this.lettersVisible ? 0 : 8 }) + .animation({ duration: 260, delay: 140 + index * 55, curve: Curve.EaseOut }) + }, (_letter: string, index: number) => `${index}`) + } + .height(34).justifyContent(FlexAlign.Center) + } + .alignItems(HorizontalAlign.Center) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + .opacity(this.opacityValue).hitTestBehavior(HitTestMode.Block) + .accessibilityLevel('no-hide-descendants') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index c14a78e9b9..e8a831526a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -141,6 +141,7 @@ export struct WideConversationHost { onClose: this.actions.onSidebar.close, onNewChat: this.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, + onOpenMiniApps: this.actions.onRemoteHome.openMiniApps, onScanDesktop: this.actions.onSidebar.scanDesktop, onCollapse: this.onCollapseMasterPane, onOpenViewSettings: this.onOpenRemoteViewSettings, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 042f30d732..01a9cf9b1e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -1,3 +1,5 @@ +import { RemoteWelcomePanel } from '../RemoteWelcomePanel'; +import { BrandMark } from '../BrandMark'; import { MobileDesignTypography } from '../../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../../i18n/RemoteI18n'; import { REMOTE_CAPABILITY_HARNESS_PROFILES_V1, RemoteSession } from '../../../model/RemoteModels'; @@ -183,38 +185,32 @@ export struct RemoteSurfaceHost { @Builder private DisconnectedState() { - Column({ space: 12 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.desktop')).fontSize(42).fontColor([INK]) - } - .width(74) - .height(74) - .backgroundColor(CARD) - .borderRadius(24) - .border({ width: 1, color: LINE }) - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(MobileDesignTypography.headlineSmall.size).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(MobileDesignTypography.bodySmall.size).lineHeight(MobileDesignTypography.bodySmall.lineHeight).fontColor(MUTED).textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.connect')) - .width(136).height(44).fontSize(MobileDesignTypography.titleSmall.size).fontColor(CONTENT_ON_ACTION) - .backgroundColor(PRIMARY_ACTION).textAlign(TextAlign.Center).borderRadius(22) - .onClick(() => this.actions.onRemoteHome.connectWorkspace()) - } + RemoteWelcomePanel({ + title: RemoteI18n.t('home.welcomeTitle'), + detail: this.isSyncing() ? this.statusText() : RemoteI18n.t('home.welcomeDetail'), + busy: this.isSyncing(), + onMiniApps: this.actions.onRemoteHome.openMiniApps, + primaryAction: () => { this.DisconnectedPrimaryAction(); } + }) .layoutWeight(1) - .width('100%') - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 20, right: 20, bottom: 48 }) + } + + @Builder + private DisconnectedPrimaryAction() { + Button(this.isSyncing() ? RemoteI18n.t('home.connecting') : RemoteI18n.t('home.connectComputer')) + .width('100%').height(52).fontSize(MobileDesignTypography.bodyLarge.size).fontWeight(FontWeight.Medium) + .fontColor(CONTENT_ON_ACTION).backgroundColor(PRIMARY_ACTION).borderRadius(26) + .enabled(!this.isSyncing()) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) } @Builder private CompactHomeContent() { Column() { ConversationHeader({ - title: RemoteI18n.t('remote.title'), + title: RemoteI18n.t('app.title'), subtitle: this.compactHeaderContext(), - titleFallback: RemoteI18n.t('remote.title'), + titleFallback: RemoteI18n.t('app.title'), showSidebarButton: true, onOpenSidebar: this.onOpenSidebar }) @@ -231,51 +227,50 @@ export struct RemoteSurfaceHost { @Builder private CompactEmptyState() { - Column({ space: 10 }) { - if (this.isInitialLoading()) { - LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) - } - Text(this.compactTitle()) - .fontSize(MobileDesignTypography.headlineMedium.size).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) - Text(this.compactText()) - .fontSize(MobileDesignTypography.bodyMedium.size).lineHeight(MobileDesignTypography.bodyMedium.lineHeight).fontColor(MUTED).maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - // Starting a session needs a desktop on the other end, so while the link - // is down the button that is actually useful is the one that brings it - // back up. - if (this.canReconnect()) { - Text(RemoteI18n.t('remote.settings.reconnect')) - .width(148).height(46).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) - .fontColor(CONTENT_ON_ACTION).backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) - .onClick(() => this.actions.onRemoteHome.reconnect()) - } else { - Text(RemoteI18n.t('remote.startSession')) - .width(148).height(46).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) - .fontColor(CONTENT_ON_ACTION).backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) - .bindPopup(this.showCreateModeMenu, { - builder: () => { - this.CreateModeMenu() - }, - placement: Placement.Top, - popupColor: CARD, - enableArrow: false, - autoCancel: true, - mask: true, - targetSpace: 8, - onStateChange: (event) => { - if (!event.isVisible) { - this.showCreateModeMenu = false; - } + RemoteWelcomePanel({ + title: this.compactTitle(), + detail: this.compactText(), + busy: this.isInitialLoading(), + onMiniApps: this.actions.onRemoteHome.openMiniApps, + primaryAction: () => { this.ConnectedPrimaryAction(); } + }) + .layoutWeight(1) + } + + @Builder + private ConnectedPrimaryAction() { + // Starting a session needs a desktop on the other end, so while the link + // is down the button that is actually useful is the one that brings it + // back up. + if (this.canReconnect()) { + Text(RemoteI18n.t('remote.settings.reconnect')) + .width('100%').height(52).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) + .fontColor(CONTENT_ON_ACTION).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(26) + .onClick(() => this.actions.onRemoteHome.reconnect()) + } else { + Text(RemoteI18n.t('remote.startSession')) + .width('100%').height(52).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) + .fontColor(CONTENT_ON_ACTION).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(26) + .bindPopup(this.showCreateModeMenu, { + builder: () => { + this.CreateModeMenu() + }, + placement: Placement.Top, + popupColor: CARD, + enableArrow: false, + autoCancel: true, + mask: true, + targetSpace: 8, + onStateChange: (event) => { + if (!event.isVisible) { + this.showCreateModeMenu = false; } - }) - .onClick(() => this.requestCreateSession()) - } + } + }) + .onClick(() => this.requestCreateSession()) } - .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 56 }) } @Builder @@ -302,7 +297,8 @@ export struct RemoteSurfaceHost { if (this.isInitialLoading()) { LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) } - Text(this.placeholderTitle()).fontSize(MobileDesignTypography.headlineLarge.size).fontWeight(FontWeight.Bold).fontColor(INK) + BrandMark({ markSize: 104 }).margin({ bottom: 20 }) + Text(this.placeholderTitle()).fontSize(MobileDesignTypography.headlineSmall.size).fontWeight(FontWeight.Bold).fontColor(INK) Text(this.statusText()).fontSize(MobileDesignTypography.bodyMedium.size).fontColor(MUTED).maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index 7b2566b20c..fcbcb2680d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -104,6 +104,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } aboutToDisappear(): void { + this.miniAppViewModel.close(); this.settingsController.stopPresencePolling(); this.watchProvisionController.stop(); this.taskCompletionNotificationController.stop(); 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 9965439d67..ee59db2044 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,5 @@ +import { BuiltinMiniAppHost } from '../../services/BuiltinMiniAppHost'; +import { MiniAppViewModel } from '../viewmodel/MiniAppViewModel'; import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy'; import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { @@ -138,6 +140,7 @@ export abstract class AppRootRuntimeComposition { abstract toggleVoiceInput(): Promise; readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly miniAppViewModel: MiniAppViewModel = new MiniAppViewModel(new BuiltinMiniAppHost((): Context => this.host.context())); readonly taskCompletionNotificationPort: HarmonyTaskCompletionNotificationPort = new HarmonyTaskCompletionNotificationPort((): Context => this.host.context()); readonly taskCompletionNotificationController: TaskCompletionNotificationController = @@ -752,6 +755,7 @@ export abstract class AppRootRuntimeComposition { onCompactLayoutEntered: (): void => this.enterCompactLayout(), onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), onRemoteHome: { + openMiniApps: (): void => { this.closeAppSidebar(); this.miniAppViewModel.actions.open(); }, openSidebar: (): void => this.openAppSidebar(), connectWorkspace: (): void => this.openConnectSheet(), addConnection: (): void => this.openConnectSheet(), @@ -819,8 +823,10 @@ export abstract class AppRootRuntimeComposition { setTimeout(() => this.appShellState.openSettings('account'), 180); }, openSession: (session: RemoteSession): void => { + // The shell supplies the horizontal motion; do not stack a second + // NavDestination push animation on top of the closing drawer. this.closeAppSidebar(); - this.conversationController.openHomeSession(session); + this.conversationController.openHomeSession(session, true); }, deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } }, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/MiniAppState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/MiniAppState.ets new file mode 100644 index 0000000000..c5bba426ab --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/MiniAppState.ets @@ -0,0 +1,13 @@ +import { MiniAppSummary } from '../../model/MiniAppModels'; + +@ObservedV2 +export class MiniAppState { + @Trace visible: boolean = false; + @Trace loading: boolean = false; + @Trace error: string = ''; + @Trace apps: MiniAppSummary[] = []; + @Trace appId: string = ''; + @Trace name: string = ''; + @Trace document: string = ''; + @Trace revision: number = 0; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/MiniAppViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/MiniAppViewModel.ets new file mode 100644 index 0000000000..e3c1f84f76 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/MiniAppViewModel.ets @@ -0,0 +1,90 @@ +import { MiniAppActions, MiniAppBridgeReply, MiniAppBridgeRequest, MiniAppHost } from '../../model/MiniAppModels'; +import { MiniAppDocument } from '../../services/MiniAppDocument'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MiniAppState } from '../state/MiniAppState'; + +export class MiniAppViewModel { + readonly state: MiniAppState = new MiniAppState(); + private generation: number = 0; + private readonly host: MiniAppHost; + readonly actions: MiniAppActions = { + open: (): void => { void this.open(); }, + close: (): void => this.close(), + back: (): void => { void this.open(); }, + refresh: (): void => { void this.open(); }, + select: (id: string): void => { void this.select(id); }, + request: (raw: string, revision: number): Promise => this.request(raw, revision) + }; + + constructor(host: MiniAppHost) { this.host = host; } + + close(): void { + this.generation += 1; + this.state.visible = false; + this.state.loading = false; + this.state.document = ''; + this.state.appId = ''; + } + + async open(): Promise { + const generation = ++this.generation; + this.state.visible = true; + this.state.document = ''; + this.state.appId = ''; + this.state.name = ''; + this.state.apps = []; + this.state.error = ''; + this.state.loading = true; + try { + const apps = await this.host.list(RemoteI18n.language()); + if (generation === this.generation) this.state.apps = apps; + } catch (error) { + if (generation === this.generation) this.state.error = String(error); + } finally { + if (generation === this.generation) this.state.loading = false; + } + } + + async select(id: string): Promise { + const app = this.state.apps.find((entry) => entry.id === id); + if (!this.state.visible || !app) return; + const generation = ++this.generation; + this.state.loading = true; + this.state.document = ''; + this.state.appId = ''; + this.state.error = ''; + try { + const html = await this.host.document(id); + if (generation !== this.generation) return; + if (!html) throw new Error(RemoteI18n.t('miniapp.invalidPage')); + this.state.appId = id; + this.state.name = app.name; + this.state.document = MiniAppDocument.build(html, RemoteI18n.language()); + this.state.revision += 1; + } catch (error) { + if (generation === this.generation) this.state.error = String(error); + } finally { + if (generation === this.generation) this.state.loading = false; + } + } + + async request(raw: string, revision: number = this.state.revision): Promise { + const generation = this.generation; + let id: string = ''; + try { + const rpc = JSON.parse(raw) as MiniAppBridgeRequest; + id = rpc.id; + if (revision !== this.state.revision || typeof id !== 'string' || !rpc.params || !this.state.visible || !this.state.appId || + !['storage.get', 'storage.set', 'clipboard.writeText'].includes(rpc.method)) { + throw new Error(RemoteI18n.t('miniapp.unsupported')); + } + const result = await this.host.call(this.state.appId, rpc.method, rpc.params); + if (generation !== this.generation) throw new Error(RemoteI18n.t('miniapp.closed')); + const reply: MiniAppBridgeReply = { jsonrpc: '2.0', id, result }; + return JSON.stringify(reply); + } catch (error) { + const reply: MiniAppBridgeReply = { jsonrpc: '2.0', id, error: { message: String(error) } }; + return JSON.stringify(reply); + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/BuiltinMiniAppHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/BuiltinMiniAppHost.ets new file mode 100644 index 0000000000..1f48e527d0 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/BuiltinMiniAppHost.ets @@ -0,0 +1,86 @@ +import { util } from '@kit.ArkTS'; +import { fileIo as fs } from '@kit.CoreFileKit'; +import { MiniAppBridgeParams, MiniAppHost, MiniAppSummary } from '../model/MiniAppModels'; +import { RemoteI18n } from '../i18n/RemoteI18n'; +import { ClipboardService } from './ClipboardService'; + +interface BuiltinMiniAppLocale { name: string; description: string; } +interface BuiltinMiniAppEntry { id: string; locales: Record; } +interface MiniAppFileError { code?: number; } + +/** Only these product-selected bundles may access the local bridge. */ +export const BUILTIN_MINIAPP_IDS: string[] = ['builtin-gomoku', 'builtin-regex-playground', 'builtin-daily-divination']; + +export class BuiltinMiniAppHost implements MiniAppHost { + private readonly context: () => Context; + // Serialize mutations and reads so close/reopen cannot overtake a pending save. + private storageQueue: Promise = Promise.resolve(null); + + constructor(context: () => Context) { this.context = context; } + + private assertApp(appId: string): void { + if (!BUILTIN_MINIAPP_IDS.includes(appId)) throw new Error(RemoteI18n.t('miniapp.unsupported')); + } + + private async resource(name: string): Promise { + const bytes = await this.context().resourceManager.getRawFileContent(`miniapps/${name}`); + return util.TextDecoder.create('utf-8').decodeWithStream(bytes); + } + + async list(locale: string): Promise { + const catalog = JSON.parse(await this.resource('catalog.json')) as BuiltinMiniAppEntry[]; + return catalog.map((entry: BuiltinMiniAppEntry): MiniAppSummary => { + this.assertApp(entry.id); + const copy = entry.locales[locale] || entry.locales['en-US']; + return { id: entry.id, name: copy.name, description: copy.description }; + }); + } + + async document(appId: string): Promise { + this.assertApp(appId); + return this.resource(`${appId}.html`); + } + + async call(appId: string, method: string, params: MiniAppBridgeParams): Promise { + this.assertApp(appId); + if (method === 'clipboard.writeText' && typeof params.text === 'string') { + await new ClipboardService().writeText(params.text); + return null; + } + if ((method !== 'storage.get' && method !== 'storage.set') || typeof params.key !== 'string' || + !this.allowedKey(appId, params.key)) throw new Error(RemoteI18n.t('miniapp.unsupported')); + const operation = this.storageQueue.then((): Promise => this.storage(appId, method, params)); + this.storageQueue = operation.catch((): null => null); + return operation; + } + + private allowedKey(appId: string, key: string): boolean { + return (appId === 'builtin-gomoku' && key === 'stats') || + (appId === 'builtin-regex-playground' && key === 'regex-state') || + (appId === 'builtin-daily-divination' && key === 'lastReading'); + } + + private async storage(appId: string, method: string, params: MiniAppBridgeParams): Promise { + // Stable path across bundle upgrades. Never remove unreadable records. + const directory = `${this.context().filesDir}/miniapps`; + const path = `${directory}/${appId}-${params.key}.json`; + if (method === 'storage.get') { + try { + return JSON.parse(await fs.readText(path)) as Object | null; + } catch (error) { + if ((error as MiniAppFileError).code === 13900002) return null; // ENOENT only + throw new Error(String(error)); + } + } + await fs.mkdir(directory, true); + const file = await fs.open(`${path}.tmp`, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); + try { + await fs.write(file.fd, JSON.stringify(params.value === undefined ? null : params.value)); + await fs.fsync(file.fd); + } finally { + await fs.close(file); + } + await fs.rename(`${path}.tmp`, path); + return null; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MiniAppDocument.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MiniAppDocument.ets new file mode 100644 index 0000000000..56817895d2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MiniAppDocument.ets @@ -0,0 +1,39 @@ +import { RemoteI18n } from '../i18n/RemoteI18n'; + +/** Trusted wrapper and untrusted H5 are separate browsing contexts. */ +export class MiniAppDocument { + static build(html: string, locale: string): string { + const language = JSON.stringify(locale).replace(/', ``); + const source = JSON.stringify(localized).replace(/ + + +`; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MotionPreference.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MotionPreference.ets new file mode 100644 index 0000000000..bdafe42009 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MotionPreference.ets @@ -0,0 +1,13 @@ +import accessibility from '@ohos.accessibility'; +import deviceInfo from '@ohos.deviceInfo'; + +/** Optional on older phones; never let a presentation preference block startup. */ +export class MotionPreference { + static reduceMotion(): boolean { + try { + return deviceInfo.sdkApiVersion >= 23 && accessibility.isAnimationReduceEnabledSync(); + } catch (_err) { + return false; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index 371070a8f3..c47e1d3306 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -68,7 +68,7 @@ "icon": "$media:openbitfun_app_icon", "label": "$string:EntryAbility_label", "startWindowIcon": "$media:openbitfun_start_window", - "startWindowBackground": "$color:start_window_background", + "startWindowBackground": "$color:page_bg", "exported": true, "skills": [ { diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_divination_preview.webp b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_divination_preview.webp new file mode 100644 index 0000000000..2bcccda7ed Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_divination_preview.webp differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_gomoku_preview.webp b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_gomoku_preview.webp new file mode 100644 index 0000000000..3b7109e686 Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_gomoku_preview.webp differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_regex_preview.webp b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_regex_preview.webp new file mode 100644 index 0000000000..dd7ddb72ae Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/miniapp_regex_preview.webp differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_brand_mark.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_brand_mark.png new file mode 100644 index 0000000000..a14b2966c7 Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_brand_mark.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_start_window.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_start_window.png index a05c7af45d..a14b2966c7 100644 Binary files a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_start_window.png and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/openbitfun_start_window.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/media/openbitfun_start_window.png b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/media/openbitfun_start_window.png new file mode 100644 index 0000000000..0bf92c7847 Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/media/openbitfun_start_window.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index 609b4f895f..88aad63924 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -1,3 +1,4 @@ +import miniAppUnitTest from './MiniAppUnit.test'; import transportAndGeneralChatUnitTest from './TransportAndGeneralChatUnit.test'; import conversationStateUnitTest from './ConversationStateUnit.test'; import conversationPresentationUnitTest from './ConversationPresentationUnit.test'; @@ -11,6 +12,7 @@ import i18nUnitTest from './I18nUnit.test'; import watchProvisionUnitTest from './WatchProvisionUnit.test'; export default function localUnitTest() { + miniAppUnitTest(); i18nUnitTest(); watchProvisionUnitTest(); architectureUnitTest(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/MiniAppUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/MiniAppUnit.test.ets new file mode 100644 index 0000000000..b0be951882 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/MiniAppUnit.test.ets @@ -0,0 +1,81 @@ +import { describe, expect, it } from '@ohos/hypium'; +import { MiniAppDocument } from '../main/ets/services/MiniAppDocument'; +import { MiniAppViewModel } from '../main/ets/pages/viewmodel/MiniAppViewModel'; +import { MiniAppBridgeParams, MiniAppHost, MiniAppSummary } from '../main/ets/model/MiniAppModels'; + +class FakeMiniAppHost implements MiniAppHost { + calls: string[] = []; + pendingOpen?: (html: string) => void; + holdOpen: boolean = false; + stored: Map = new Map(); + async list(_locale: string): Promise { + return [{ id: 'game', name: 'Game', description: '' }, { id: 'regex', name: 'Regex', description: '' }]; + } + async document(_id: string): Promise { + if (this.holdOpen) return new Promise((resolve) => { this.pendingOpen = resolve; }); + return '

Game

'; + } + async call(appId: string, method: string, params: MiniAppBridgeParams): Promise { + this.calls.push(appId); + const key = `${appId}:${params.key}`; + if (method === 'storage.set') this.stored.set(key, params.value || null); + return this.stored.get(key) || null; + } +} + +export default function miniAppUnitTest() { + describe('MobileMiniApp', () => { + it('opens_offline_and_binds_storage_to_selected_app_across_reopen', 0, async () => { + const host = new FakeMiniAppHost(); + const model = new MiniAppViewModel(host); + await model.open(); + await model.select('game'); + await model.request('{"id":"save","method":"storage.set","params":{"key":"stats","value":{"wins":3}},"app_id":"attacker"}'); + expect(host.calls[0]).assertEqual('game'); + model.close(); + await model.open(); + await model.select('regex'); + expect((await model.request('{"id":"stale","method":"storage.set","params":{"key":"stats","value":3}}', 1)).includes('error')).assertTrue(); + expect((await model.request('{"id":"get","method":"storage.get","params":{"key":"stats"}}')).includes('"result":null')).assertTrue(); + await model.open(); + await model.select('game'); + expect((await model.request('{"id":"get","method":"storage.get","params":{"key":"stats"}}')).includes('"wins":3')).assertTrue(); + }); + + it('does_not_resurrect_a_closed_page_after_a_slow_open', 0, async () => { + const host = new FakeMiniAppHost(); + const model = new MiniAppViewModel(host); + await model.open(); + host.holdOpen = true; + const loading = model.select('game'); + model.close(); + host.pendingOpen?.('

Late

'); + await loading; + expect(model.state.visible).assertFalse(); + expect(model.state.document).assertEqual(''); + expect(model.state.loading).assertFalse(); + }); + + it('rejects_unknown_apps_closed_pages_and_unsupported_methods', 0, async () => { + const host = new FakeMiniAppHost(); + const model = new MiniAppViewModel(host); + await model.open(); + await model.select('attacker'); + expect(model.state.appId).assertEqual(''); + await model.select('game'); + expect((await model.request('{"id":"bad","method":"host_invoke","params":{}}')).includes('error')).assertTrue(); + model.close(); + expect((await model.request('{"id":"bad","method":"storage.get","params":{"key":"stats"}}')).includes('error')).assertTrue(); + expect(host.calls.length).assertEqual(0); + }); + + it('keeps_app_markup_outside_the_trusted_wrapper_and_disables_network', 0, () => { + const html = MiniAppDocument.build('', 'en-US'); + expect(html.includes('`; + const html = read('index.html') + .replace('', ``) + .replace('', () => `${appearance}${script(bridge)}`) + .replace('', () => `${script(js)}`); + const file = path.join(output, `${meta.id}.html`); + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== html) fs.writeFileSync(file, html); + catalog.push({ id: meta.id, locales: meta.i18n.locales }); + } + const file = path.join(output, 'catalog.json'); + const value = JSON.stringify(catalog); + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== value) fs.writeFileSync(file, value); +} +module.exports = { generate }; +if (require.main === module) generate(); diff --git a/src/apps/mobile/harmonyos/miniapps/mobile.css b/src/apps/mobile/harmonyos/miniapps/mobile.css new file mode 100644 index 0000000000..3c3fb42db4 --- /dev/null +++ b/src/apps/mobile/harmonyos/miniapps/mobile.css @@ -0,0 +1,32 @@ +/* Mobile-only layout adaptation; visual tokens still come from the shared theme. */ +.gomoku .board { touch-action: pan-y; } +@media (max-width: 720px) { + .gomoku { height: auto; min-height: 100vh; } + .gomoku .topbar { flex-wrap: wrap; gap: 12px; } + .gomoku .layout { flex: none; overflow: visible; grid-template-rows: auto auto; } + .gomoku .board-wrap { min-width: 0; } + .gomoku .board-frame { width: 100%; } + .gomoku .sidepanel { flex-direction: column; overflow: visible; } + .gomoku .sidepanel .card { min-width: 0; } + .gomoku .seg__btn, .gomoku .btn { min-height: 44px; } +} + +/* The desktop stage clips a fixed-height canvas. Results must keep their + * intrinsic height and scroll inside the mobile WebView, including wide hosts. */ +.div-app > .header, .div-app > .footer { flex-shrink: 0; } +.div-app > .stage { overflow-x: hidden; overflow-y: auto; } +.div-app .result { flex-shrink: 0; align-self: flex-start; } +@media (max-width: 720px) { + .div-app .result { display: flex; flex-direction: column; gap: 24px; } + .div-app .card-front, .div-app .result__panels { width: 100%; flex-shrink: 0; } + .div-app > .header { flex-wrap: wrap; gap: 8px; padding: 12px 16px; } + .div-app .header__title { font-size: 14px; letter-spacing: 3px; } + .div-app .header__date { font-size: 12px; letter-spacing: 1px; } + .div-app > .footer { flex-direction: column; align-items: stretch; gap: 8px; padding: 12px 16px; } + .div-app .link-btn { min-height: 44px; justify-content: center; flex-shrink: 0; } + .div-app .footer__hint { font-size: 11px; letter-spacing: 0; text-align: center; } +} +@media (max-width: 880px) { + .div-app .lucky__cell:nth-child(3) { grid-column: 1 / -1; } + .div-app .lucky__value { white-space: normal; overflow-wrap: anywhere; } +} diff --git a/src/apps/mobile/harmonyos/miniapps/storage.test.cjs b/src/apps/mobile/harmonyos/miniapps/storage.test.cjs new file mode 100644 index 0000000000..a8eb6b788d --- /dev/null +++ b/src/apps/mobile/harmonyos/miniapps/storage.test.cjs @@ -0,0 +1,84 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +// Exercise the production ArkTS owner against a real temporary filesystem. +// Only platform APIs are adapted; storage ordering and path policy are unchanged. +async function hostFixture(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'builtin-miniapp-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const handles = new Map(); + let failWrite = false; + const io = { + OpenMode: { CREATE: 1, READ_WRITE: 2, TRUNC: 4 }, + readText: async file => { + try { return await fs.readFile(file, 'utf8'); } + catch (error) { if (error.code === 'ENOENT') error.code = 13900002; throw error; } + }, + mkdir: file => fs.mkdir(file, { recursive: true }), + open: async file => { const handle = await fs.open(file, 'w'); handles.set(handle.fd, handle); return handle; }, + write: async (fd, value) => { if (failWrite) throw Error('disk full'); await handles.get(fd).writeFile(value); }, + fsync: fd => handles.get(fd).sync(), + close: async handle => { handles.delete(handle.fd); await handle.close(); }, + rename: fs.rename + }; + const source = await fs.readFile(path.resolve(__dirname, '../entry/src/main/ets/services/BuiltinMiniAppHost.ets'), 'utf8'); + const exports = {}; + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { + exports, + require: name => { + if (name === '@kit.CoreFileKit') return { fileIo: io }; + if (name.endsWith('RemoteI18n')) return { RemoteI18n: { t: key => key } }; + return {}; + } + }); + const create = () => new exports.BuiltinMiniAppHost(() => ({ filesDir: directory })); + return { host: create(), create, directory, failWrite: () => { failWrite = true; } }; +} + +test('legacy records and unknown fields survive read/reopen; apps remain isolated', async t => { + const fixture = await hostFixture(t); + const dir = path.join(fixture.directory, 'miniapps'); + await fs.mkdir(dir); + const record = '{"wins":3,"future_field":{"keep":true}}'; + const file = path.join(dir, 'builtin-gomoku-stats.json'); + await fs.writeFile(file, record); + const result = await fixture.create().call('builtin-gomoku', 'storage.get', { key: 'stats' }); + assert.equal(JSON.stringify(result), record); + assert.equal(await fs.readFile(file, 'utf8'), record); + assert.equal(await fixture.host.call('builtin-daily-divination', 'storage.get', { key: 'lastReading' }), null); + await assert.rejects(fixture.host.call('builtin-daily-divination', 'storage.get', { key: 'stats' })); + await assert.rejects(fixture.host.call('../escape', 'storage.get', { key: 'stats' })); +}); + +test('corrupt records return errors without removing or resetting data', async t => { + const fixture = await hostFixture(t); + const dir = path.join(fixture.directory, 'miniapps'); + await fs.mkdir(dir); + const file = path.join(dir, 'builtin-gomoku-stats.json'); + await fs.writeFile(file, '{broken'); + await assert.rejects(fixture.host.call('builtin-gomoku', 'storage.get', { key: 'stats' })); + assert.equal(await fs.readFile(file, 'utf8'), '{broken'); +}); + +test('queued saves are durable across host recreation and retain large regex input', async t => { + const fixture = await hostFixture(t); + const value = { text: 'x'.repeat(20000), flags: ['g'], future: true }; + const first = fixture.host.call('builtin-regex-playground', 'storage.set', { key: 'regex-state', value: {} }); + const second = fixture.host.call('builtin-regex-playground', 'storage.set', { key: 'regex-state', value }); + await Promise.all([first, second]); + const stored = await fixture.create().call('builtin-regex-playground', 'storage.get', { key: 'regex-state' }); + assert.equal(JSON.stringify(stored), JSON.stringify(value)); +}); + +test('failed writes preserve the last saved record and do not poison reads', async t => { + const fixture = await hostFixture(t); + await fixture.host.call('builtin-gomoku', 'storage.set', { key: 'stats', value: { wins: 1 } }); + fixture.failWrite(); + await assert.rejects(fixture.host.call('builtin-gomoku', 'storage.set', { key: 'stats', value: { wins: 2 } }), /disk full/); + assert.equal((await fixture.host.call('builtin-gomoku', 'storage.get', { key: 'stats' })).wins, 1); +});