diff --git a/.gitignore b/.gitignore index 380c603b6..ee516d36a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ temp-build-goal-check/ /public/generated-custom-world-scenes temp*build*/ /server-rs/target/ +/apps/desktop-shell/src-tauri/target/ +/apps/desktop-shell/src-tauri/gen/ +/apps/desktop-shell/src-tauri/permissions/autogenerated/ +/apps/mobile-shell/.expo/ +/apps/mobile-shell/.expo-export-smoke/ /server-rs/.spacetimedb/ /server-rs/.data/ /public/generated-animations diff --git a/apps/desktop-shell/package.json b/apps/desktop-shell/package.json new file mode 100644 index 000000000..f43d8a039 --- /dev/null +++ b/apps/desktop-shell/package.json @@ -0,0 +1,16 @@ +{ + "name": "@genarrative/desktop-shell", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "WEB_PORT=3000 tauri dev", + "build": "tauri build", + "stage-release-binary": "node scripts/stage-release-binary.mjs", + "typecheck": "node scripts/check-config.mjs" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.2", + "typescript": "~5.8.2" + } +} diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs new file mode 100644 index 000000000..9cf16dea4 --- /dev/null +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -0,0 +1,3533 @@ +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +const configPath = new URL('../src-tauri/tauri.conf.json', import.meta.url); +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +const packagePath = new URL('../package.json', import.meta.url); +const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const rootPackagePath = new URL('../../../package.json', import.meta.url); +const rootPackageConfig = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8')); +const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url); +const rootPackageLock = JSON.parse(fs.readFileSync(rootPackageLockPath, 'utf8')); +const nativeShellCheckPath = new URL( + '../../../scripts/check-native-shells.mjs', + import.meta.url, +); +const nativeShellCheckSource = fs.readFileSync(nativeShellCheckPath, 'utf8'); +const capabilityPath = new URL( + '../src-tauri/capabilities/main.json', + import.meta.url, +); +const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8')); +const macInfoPlistPath = new URL('../src-tauri/Info.plist', import.meta.url); +const macInfoPlist = fs.readFileSync(macInfoPlistPath, 'utf8'); +const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url); +const buildScript = fs.readFileSync(buildScriptPath, 'utf8'); +const cargoManifestPath = new URL('../src-tauri/Cargo.toml', import.meta.url); +const cargoManifest = fs.readFileSync(cargoManifestPath, 'utf8'); +const cargoLockPath = new URL('../src-tauri/Cargo.lock', import.meta.url); +const cargoLock = fs.readFileSync(cargoLockPath, 'utf8'); +const iconDirPath = new URL('../src-tauri/icons/', import.meta.url); +const generatedPermissionDir = new URL( + '../src-tauri/permissions/autogenerated/', + import.meta.url, +); +const sharedContractPath = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); +const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); +const nativeAppHostBridgePath = new URL( + '../../../src/services/host-bridge/nativeAppHostBridge.ts', + import.meta.url, +); +const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8'); +const h5HostBridgePath = new URL( + '../../../src/services/host-bridge/hostBridge.ts', + import.meta.url, +); +const h5HostBridgeSource = fs.readFileSync(h5HostBridgePath, 'utf8'); +const appPath = new URL('../src-tauri/src/app.rs', import.meta.url); +const app = fs.readFileSync(appPath, 'utf8'); +const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url); +const main = fs.readFileSync(mainPath, 'utf8'); +if ( + !main.includes( + '#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")]', + ) +) { + throw new Error('desktop shell release builds on Windows must use the GUI subsystem'); +} +const rustSourceDir = new URL('../src-tauri/src/', import.meta.url); +const desktopHostBridgeAppearancePath = new URL( + '../src-tauri/src/host_bridge/appearance.rs', + import.meta.url, +); +const desktopHostBridgeAppearanceSource = fs.readFileSync( + desktopHostBridgeAppearancePath, + 'utf8', +); +const desktopHostBridgeBadgePath = new URL( + '../src-tauri/src/host_bridge/badge.rs', + import.meta.url, +); +const desktopHostBridgeBadgeSource = fs.readFileSync( + desktopHostBridgeBadgePath, + 'utf8', +); +const desktopHostBridgeCapabilitiesPath = new URL( + '../src-tauri/src/host_bridge/capabilities.rs', + import.meta.url, +); +const desktopHostBridgeCapabilitiesSource = fs.readFileSync( + desktopHostBridgeCapabilitiesPath, + 'utf8', +); +const desktopHostBridgeClipboardPath = new URL( + '../src-tauri/src/host_bridge/clipboard.rs', + import.meta.url, +); +const desktopHostBridgeClipboardSource = fs.readFileSync( + desktopHostBridgeClipboardPath, + 'utf8', +); +const desktopHostBridgeDispatchPath = new URL( + '../src-tauri/src/host_bridge/dispatch.rs', + import.meta.url, +); +const desktopHostBridgeDispatchSource = fs.readFileSync( + desktopHostBridgeDispatchPath, + 'utf8', +); +const desktopHostBridgeFilesPath = new URL( + '../src-tauri/src/host_bridge/files.rs', + import.meta.url, +); +const desktopHostBridgeFilesSource = fs.readFileSync( + desktopHostBridgeFilesPath, + 'utf8', +); +const desktopHostBridgeFilePayloadsPath = new URL( + '../src-tauri/src/host_bridge/file_payloads.rs', + import.meta.url, +); +const desktopHostBridgeFilePayloadsSource = fs.readFileSync( + desktopHostBridgeFilePayloadsPath, + 'utf8', +); +const desktopHostBridgeNavigationPath = new URL( + '../src-tauri/src/host_bridge/navigation.rs', + import.meta.url, +); +const desktopHostBridgeNavigationSource = fs.readFileSync( + desktopHostBridgeNavigationPath, + 'utf8', +); +const desktopHostBridgeNetworkPath = new URL( + '../src-tauri/src/host_bridge/network.rs', + import.meta.url, +); +const desktopHostBridgeNetworkSource = fs.readFileSync( + desktopHostBridgeNetworkPath, + 'utf8', +); +const desktopHostBridgeNotificationsPath = new URL( + '../src-tauri/src/host_bridge/notifications.rs', + import.meta.url, +); +const desktopHostBridgeNotificationsSource = fs.readFileSync( + desktopHostBridgeNotificationsPath, + 'utf8', +); +const desktopHostBridgeProtocolPath = new URL( + '../src-tauri/src/host_bridge/protocol.rs', + import.meta.url, +); +const desktopHostBridgeProtocolSource = fs.readFileSync( + desktopHostBridgeProtocolPath, + 'utf8', +); +const desktopHostBridgeRuntimePath = new URL( + '../src-tauri/src/host_bridge/runtime.rs', + import.meta.url, +); +const desktopHostBridgeRuntimeSource = fs.readFileSync( + desktopHostBridgeRuntimePath, + 'utf8', +); +const desktopHostBridgeSharePath = new URL( + '../src-tauri/src/host_bridge/share.rs', + import.meta.url, +); +const desktopHostBridgeShareSource = fs.readFileSync( + desktopHostBridgeSharePath, + 'utf8', +); +const desktopHostBridgeTitlePath = new URL( + '../src-tauri/src/host_bridge/title.rs', + import.meta.url, +); +const desktopHostBridgeTitleSource = fs.readFileSync( + desktopHostBridgeTitlePath, + 'utf8', +); +const desktopShellUrlPath = new URL( + '../src-tauri/src/shell/url.rs', + import.meta.url, +); +const desktopShellUrlSource = fs.readFileSync(desktopShellUrlPath, 'utf8'); +const desktopShellNetworkPath = new URL( + '../src-tauri/src/shell/network.rs', + import.meta.url, +); +const desktopShellNetworkSource = fs.readFileSync( + desktopShellNetworkPath, + 'utf8', +); +const desktopShellDeepLinkPath = new URL( + '../src-tauri/src/shell/deep_link.rs', + import.meta.url, +); +const desktopShellDeepLinkSource = fs.readFileSync( + desktopShellDeepLinkPath, + 'utf8', +); +const desktopShellNavigationPath = new URL( + '../src-tauri/src/shell/navigation.rs', + import.meta.url, +); +const desktopShellNavigationSource = fs.readFileSync( + desktopShellNavigationPath, + 'utf8', +); +const desktopShellLifecyclePath = new URL( + '../src-tauri/src/shell/lifecycle.rs', + import.meta.url, +); +const desktopShellLifecycleSource = fs.readFileSync( + desktopShellLifecyclePath, + 'utf8', +); +const stageReleaseBinaryPath = new URL( + '../scripts/stage-release-binary.mjs', + import.meta.url, +); +const stageReleaseBinarySource = fs.readFileSync(stageReleaseBinaryPath, 'utf8'); +const productionSourceRoots = [ + new URL('../package.json', import.meta.url), + new URL('../scripts/', import.meta.url), + new URL('../src-tauri/Cargo.toml', import.meta.url), + new URL('../src-tauri/Info.plist', import.meta.url), + new URL('../src-tauri/build.rs', import.meta.url), + new URL('../src-tauri/capabilities/main.json', import.meta.url), + new URL('../src-tauri/src/', import.meta.url), + new URL('../src-tauri/tauri.conf.json', import.meta.url), +]; +const productionFileExtensions = new Set(['.json', '.mjs', '.plist', '.rs', '.toml']); +const productionSourceExcludedDirectories = new Set([ + 'target', +]); +const productionSourceExcludedRelativePaths = new Set([ + 'src-tauri/gen', + 'src-tauri/permissions/autogenerated', +]); +const devScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'place' + 'holder', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '占' + '位', + '模' + '拟', + '伪' + '造', + '未' + '实现', + '临' + '时', + '后' + '续', +]; +const blockedDesktopNpmDependencies = [ + '@amplitude/analytics-browser', + '@bugsnag/js', + '@datadog/browser-rum', + '@segment/analytics-next', + '@sentry/browser', + '@sentry/react', + '@sentry/tauri', + '@sentry/tracing', + '@tauri-apps/plugin-log', + '@tauri-apps/plugin-updater', + 'amplitude-js', + 'mixpanel-browser', + 'posthog-js', +]; +const blockedDesktopCargoDependencies = [ + 'bugsnag', + 'datadog', + 'opentelemetry', + 'opentelemetry-otlp', + 'posthog-rs', + 'sentry', + 'sentry-tauri', + 'tauri-plugin-log', + 'tauri-plugin-updater', + 'tracing-opentelemetry', +]; +const blockedDesktopSdkSnippets = [ + '@sentry/', + 'Bugsnag.start', + 'Datadog', + 'Sentry.init', + 'analytics.load', + 'amplitude.init', + 'datadogRum.init', + 'mixpanel.init', + 'opentelemetry::', + 'posthog.init', + 'sentry::init', + 'sentry_tauri', + 'tauri_plugin_log::', + 'tauri_plugin_updater::', + 'tracing_opentelemetry', +]; +const requiredBundleIcons = [ + 'icons/32x32.png', + 'icons/128x128.png', + 'icons/128x128@2x.png', + 'icons/icon.icns', + 'icons/icon.ico', + 'icons/icon.png', +]; +const requiredPngIconSizes = new Map([ + ['32x32.png', 32], + ['128x128.png', 128], + ['128x128@2x.png', 256], + ['icon.png', 512], +]); +const expectedDesktopRustRootEntries = [ + 'dir:host_bridge', + 'dir:shell', + 'file:app.rs', + 'file:main.rs', +]; +const expectedDesktopHostBridgeRustFiles = [ + 'appearance.rs', + 'badge.rs', + 'capabilities.rs', + 'clipboard.rs', + 'dispatch.rs', + 'file_payloads.rs', + 'files.rs', + 'mod.rs', + 'navigation.rs', + 'network.rs', + 'notifications.rs', + 'protocol.rs', + 'runtime.rs', + 'share.rs', + 'title.rs', +]; +const expectedDesktopShellRustFiles = [ + 'deep_link.rs', + 'events.rs', + 'file_drop.rs', + 'lifecycle.rs', + 'mod.rs', + 'navigation.rs', + 'network.rs', + 'runtime.rs', + 'tray.rs', + 'url.rs', + 'webview.rs', + 'window_state.rs', +]; +const expectedDesktopShellScripts = [ + 'check-config.mjs', + 'stage-release-binary.mjs', +]; + +function extractCargoPackageString(source, key) { + const match = source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm')); + if (!match) { + throw new Error(`unable to read Cargo package ${key}`); + } + + return match[1]; +} + +function extractCargoDependencyLine(source, sectionName, dependencyName) { + let inSection = false; + for (const line of source.split('\n')) { + if (/^\[[^\]]+\]$/.test(line)) { + inSection = line === `[${sectionName}]`; + continue; + } + + if (!inSection) { + continue; + } + + if (line.match(new RegExp(`^${escapeRegExp(dependencyName)}\\s*=`))) { + return line; + } + } + + throw new Error(`Cargo ${sectionName}.${dependencyName} is missing`); +} + +function extractCargoLockPackages(source) { + return source + .split('\n[[package]]\n') + .map((block) => block.trim()) + .filter((block) => block.includes('name = ')) + .map((block) => { + const name = block.match(/^name = "([^"]+)"/m)?.[1]; + const version = block.match(/^version = "([^"]+)"/m)?.[1]; + const dependenciesMatch = block.match(/^dependencies = \[\n([\s\S]*?)\n\]/m); + const dependencies = dependenciesMatch + ? [...dependenciesMatch[1].matchAll(/ "([^"]+)"/g)].map((entry) => entry[1]) + : []; + return { block, dependencies, name, version }; + }); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function assertNoBlockedNpmDependencies() { + const dependencySections = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]; + + for (const dependency of blockedDesktopNpmDependencies) { + for (const section of dependencySections) { + if (packageConfig[section]?.[dependency]) { + throw new Error( + `desktop shell must not depend on ${dependency} before real observability/channel contracts exist`, + ); + } + } + } +} + +function assertNoBlockedNpmLockPackages() { + const packageNames = Object.keys(rootPackageLock.packages ?? {}) + .filter((packagePath) => packagePath.startsWith('node_modules/')) + .map((packagePath) => packagePath.replace(/^node_modules\//, '')); + const dependencyNames = Object.keys(rootPackageLock.dependencies ?? {}); + const lockedPackageNames = new Set([...packageNames, ...dependencyNames]); + + for (const dependency of blockedDesktopNpmDependencies) { + if (lockedPackageNames.has(dependency)) { + throw new Error( + `root package-lock must not resolve ${dependency} before real observability/channel contracts exist`, + ); + } + } + + for (const dependency of lockedPackageNames) { + if (dependency === '@tauri-apps/api' || dependency.startsWith('@tauri-apps/plugin-')) { + throw new Error( + `root package-lock must not resolve ${dependency}; desktop H5 must use HostBridge through the injected Tauri global only`, + ); + } + } +} + +function collectNpmDependencyNames(packageJson) { + const dependencySections = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]; + + return dependencySections.flatMap((section) => + Object.keys(packageJson[section] ?? {}), + ); +} + +function assertNoTauriGuestNpmDependencies(packageJson, packageLabel) { + for (const dependency of collectNpmDependencyNames(packageJson)) { + if (dependency === '@tauri-apps/api' || dependency.startsWith('@tauri-apps/plugin-')) { + throw new Error( + `${packageLabel} must not depend on ${dependency}; desktop H5 must use HostBridge through the injected Tauri global only`, + ); + } + } +} + +function assertNoBlockedCargoDependencies() { + for (const dependency of blockedDesktopCargoDependencies) { + const dependencyPattern = new RegExp( + `^\\s*(?:"${escapeRegExp(dependency)}"|${escapeRegExp(dependency)})\\s*=`, + 'm', + ); + if (dependencyPattern.test(cargoManifest)) { + throw new Error( + `desktop shell must not depend on ${dependency} before real observability/channel contracts exist`, + ); + } + } +} + +function assertNoBlockedCargoLockPackages() { + const cargoPackageNames = new Set( + cargoLockPackages.map((entry) => entry.name).filter(Boolean), + ); + for (const dependency of blockedDesktopCargoDependencies) { + if (cargoPackageNames.has(dependency)) { + throw new Error( + `Cargo.lock must not resolve ${dependency} before real observability/channel contracts exist`, + ); + } + } +} + +function assertNoBlockedDesktopSdkSnippets() { + const sources = [ + ['tauri.conf.json', JSON.stringify(config)], + ['build.rs', buildScript], + ['src-tauri/src', rustHostSource], + ]; + + for (const [sourceName, source] of sources) { + for (const snippet of blockedDesktopSdkSnippets) { + if (source.includes(snippet)) { + throw new Error( + `desktop shell ${sourceName} must not initialize ${snippet} before real observability/channel contracts exist`, + ); + } + } + } +} + +function assertPackageScript(packageJson, packageLabel, scriptName, expected) { + const actual = packageJson.scripts?.[scriptName]; + if (actual !== expected) { + throw new Error( + `${packageLabel} script ${scriptName} drifted: expected ${expected} but got ${actual}`, + ); + } +} + +function assertPackageDependencyVersion( + packageJson, + packageLabel, + section, + dependency, + expected, +) { + const actual = packageJson[section]?.[dependency]; + if (actual !== expected) { + throw new Error( + `${packageLabel} ${section}.${dependency} drifted: expected ${expected} but got ${actual}`, + ); + } +} + +function assertPackageLockVersion(dependency, expected) { + const actual = rootPackageLock.packages?.[`node_modules/${dependency}`]?.version; + if (actual !== expected) { + throw new Error( + `root package-lock ${dependency} resolved version drifted: expected ${expected} but got ${actual}`, + ); + } +} + +function assertCargoDependencyLine(sectionName, dependencyName, expectedLine) { + const actualLine = extractCargoDependencyLine( + cargoManifest, + sectionName, + dependencyName, + ); + if (actualLine !== expectedLine) { + throw new Error( + `Cargo ${sectionName}.${dependencyName} drifted: expected ${expectedLine} but got ${actualLine}`, + ); + } +} + +const cargoLockPackages = extractCargoLockPackages(cargoLock); + +function findCargoLockPackage(packageName, expectedVersion) { + return cargoLockPackages.find( + (entry) => entry.name === packageName && entry.version === expectedVersion, + ); +} + +function assertCargoLockPackageVersion(packageName, expectedVersion) { + if (!findCargoLockPackage(packageName, expectedVersion)) { + const actualVersions = cargoLockPackages + .filter((entry) => entry.name === packageName) + .map((entry) => entry.version) + .join(', '); + throw new Error( + `Cargo.lock ${packageName} resolved version drifted: expected ${expectedVersion} but got ${actualVersions || 'missing'}`, + ); + } +} + +function assertCargoLockDirectDependency( + parentPackageName, + parentVersion, + dependencyName, + expectedVersion, +) { + const parentPackage = findCargoLockPackage(parentPackageName, parentVersion); + if (!parentPackage) { + throw new Error( + `Cargo.lock ${parentPackageName} ${parentVersion} is missing`, + ); + } + + const dependencyToken = parentPackage.dependencies.find((dependency) => { + const [name, version] = dependency.split(' '); + return name === dependencyName && (!version || version === expectedVersion); + }); + if (!dependencyToken) { + throw new Error( + `Cargo.lock ${parentPackageName} ${parentVersion} dependency ${dependencyName} drifted: expected ${expectedVersion}`, + ); + } + + assertCargoLockPackageVersion(dependencyName, expectedVersion); +} + +function collectProductionSourceFiles(entry) { + const stats = fs.statSync(entry); + if (stats.isDirectory()) { + const relativePath = pathRelativeToDesktopShell(entry); + if (productionSourceExcludedRelativePaths.has(relativePath)) { + return []; + } + + const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`); + return fs + .readdirSync(entry, { withFileTypes: true }) + .filter( + (child) => + !child.isDirectory() || !productionSourceExcludedDirectories.has(child.name), + ) + .flatMap((child) => + collectProductionSourceFiles( + new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory), + ), + ); + } + + const path = entry.pathname; + const extension = path.match(/\.[^.]+$/)?.[0] ?? ''; + if (!productionFileExtensions.has(extension)) { + return []; + } + if (path.includes('.test.')) { + return []; + } + + return [entry]; +} + +function pathRelativeToDesktopShell(entry) { + const desktopShellRoot = new URL('../', import.meta.url); + return pathRelative(desktopShellRoot.pathname, entry.pathname); +} + +function pathRelative(fromPath, toPath) { + return toPath + .slice(fromPath.length) + .replace(/^\/+/, '') + .replace(/\/$/, ''); +} + +function cspDirectiveValues(source, directiveName) { + const directive = source + .split(';') + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith(`${directiveName} `)); + if (!directive) { + return []; + } + + return directive + .split(/\s+/) + .slice(1) + .filter(Boolean); +} + +function assertExactCspDirective(source, directiveName, expectedValues, label) { + const actualValues = cspDirectiveValues(source, directiveName); + if ( + actualValues.length !== expectedValues.length || + expectedValues.some((value, index) => actualValues[index] !== value) + ) { + throw new Error( + `${label} ${directiveName} must be ${[directiveName, ...expectedValues].join(' ')}`, + ); + } +} + +const productionSourceFiles = productionSourceRoots.flatMap((root) => + collectProductionSourceFiles(root), +); +const rustHostSourceFiles = collectProductionSourceFiles(rustSourceDir); +const rustHostSource = rustHostSourceFiles + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); + +function assertNoDevScaffoldTerms(files) { + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const lineStarts = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') { + lineStarts.push(index + 1); + } + } + + for (const term of devScaffoldTerms) { + const matchIndex = source.toLowerCase().indexOf(term.toLowerCase()); + if (matchIndex === -1) { + continue; + } + const line = lineStarts.filter((start) => start <= matchIndex).length; + throw new Error( + `desktop shell production source/config must not include ${term}: ${file.pathname}:${line}`, + ); + } + } +} + +assertNoDevScaffoldTerms(productionSourceFiles); +assertNoBlockedNpmDependencies(); +assertNoTauriGuestNpmDependencies(packageConfig, 'desktop shell package'); +assertNoTauriGuestNpmDependencies(rootPackageConfig, 'root H5 package'); +assertNoBlockedNpmLockPackages(); +assertNoBlockedCargoDependencies(); +assertNoBlockedCargoLockPackages(); +assertNoBlockedDesktopSdkSnippets(); +assertDesktopSourceLayout(); + +for (const snippet of [ + 'label: \'desktop-shell-release-build-smoke\'', + "args: ['run', 'desktop-shell:build', '--', '--no-bundle']", + "label: 'desktop-shell-stage-release-binary'", + "args: ['run', 'desktop-shell:stage-release-binary']", + 'function assertDesktopReleaseBinaryArtifact()', + "'build'", + "'native'", + "'desktop'", + 'genarrative-desktop-shell.exe', + 'genarrative-desktop-shell', + 'desktop release binary is missing', + 'desktop release binary must be a real non-empty executable file', + 'desktop Linux release binary must be an executable ELF file', + 'desktop macOS release binary must be an executable Mach-O file', + 'desktop Windows release binary must be a PE executable', + 'header[0] === 0x7f', + 'header[1] === 0x45', + 'header[2] === 0x4c', + 'header[3] === 0x46', + '(stat.mode & 0o111) === 0', + 'header.readUInt32BE(0)', + 'machMagic === 0xfeedfacf', + 'header[0] !== 0x4d', + 'header[1] !== 0x5a', + "console.log('[check:native-shells] desktop-release-binary-artifact')", + 'assertDesktopReleaseBinaryArtifact();', +]) { + if (!nativeShellCheckSource.includes(snippet)) { + throw new Error(`root native shell gate must keep desktop release artifact check ${snippet}`); + } +} + +for (const snippet of [ + "'apps'", + "'desktop-shell'", + "'src-tauri'", + "'target'", + "'release'", + "'build'", + "'native'", + "'desktop'", + 'fs.copyFileSync(sourcePath, stagedPath)', + 'fs.chmodSync(stagedPath, sourceMode & 0o777)', + "console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`)", +]) { + if (!stageReleaseBinarySource.includes(snippet)) { + throw new Error(`desktop shell release staging script missing ${snippet}`); + } +} + +for (const [scriptName, expected] of Object.entries({ + dev: 'WEB_PORT=3000 tauri dev', + build: 'tauri build', + 'stage-release-binary': 'node scripts/stage-release-binary.mjs', + typecheck: 'node scripts/check-config.mjs', +})) { + assertPackageScript(packageConfig, 'desktop shell package', scriptName, expected); +} + +for (const [scriptName, expected] of Object.entries({ + 'desktop-shell:dev': 'npm --prefix apps/desktop-shell run dev', + 'desktop-shell:build': 'npm --prefix apps/desktop-shell run build --', + 'desktop-shell:stage-release-binary': 'npm --prefix apps/desktop-shell run stage-release-binary', + 'desktop-shell:typecheck': 'npm --prefix apps/desktop-shell run typecheck', + 'desktop-shell:test': 'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml', +})) { + assertPackageScript(rootPackageConfig, 'root package', scriptName, expected); +} + +for (const [dependency, expected] of Object.entries({ + '@tauri-apps/cli': '^2.11.2', + typescript: '~5.8.2', +})) { + assertPackageDependencyVersion( + packageConfig, + 'desktop shell package', + 'devDependencies', + dependency, + expected, + ); + assertPackageDependencyVersion( + rootPackageConfig, + 'root package', + 'devDependencies', + dependency, + expected, + ); +} + +for (const [dependency, expected] of Object.entries({ + '@tauri-apps/cli': '2.11.2', + typescript: '5.8.3', +})) { + assertPackageLockVersion(dependency, expected); +} + +for (const [sectionName, dependencyName, expectedLine] of [ + ['build-dependencies', 'tauri-build', 'tauri-build = { version = "2.6.2", features = [] }'], + ['dependencies', 'base64', 'base64 = "0.22"'], + ['dependencies', 'serde', 'serde = { version = "1", features = ["derive"] }'], + ['dependencies', 'serde_json', 'serde_json = "1"'], + ['dependencies', 'tauri', 'tauri = { version = "2.11.2", features = ["tray-icon"] }'], + [ + 'dependencies', + 'tauri-plugin-clipboard-manager', + 'tauri-plugin-clipboard-manager = "2.3.2"', + ], + [ + 'dependencies', + 'tauri-plugin-dialog', + 'tauri-plugin-dialog = "2.7.1"', + ], + [ + 'dependencies', + 'tauri-plugin-deep-link', + 'tauri-plugin-deep-link = "2.4.9"', + ], + [ + 'dependencies', + 'tauri-plugin-notification', + 'tauri-plugin-notification = "2.3.3"', + ], + [ + 'dependencies', + 'tauri-plugin-opener', + 'tauri-plugin-opener = "2.5.4"', + ], + [ + 'dependencies', + 'tauri-plugin-single-instance', + 'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }', + ], + [ + 'dependencies', + 'tauri-plugin-window-state', + 'tauri-plugin-window-state = "2.4.1"', + ], +]) { + assertCargoDependencyLine(sectionName, dependencyName, expectedLine); +} + +for (const [packageName, expectedVersion] of [ + ['tauri-build', '2.6.2'], + ['base64', '0.22.1'], + ['serde', '1.0.228'], + ['serde_json', '1.0.150'], + ['tauri', '2.11.2'], + ['tauri-plugin-clipboard-manager', '2.3.2'], + ['tauri-plugin-deep-link', '2.4.9'], + ['tauri-plugin-dialog', '2.7.1'], + ['tauri-plugin-notification', '2.3.3'], + ['tauri-plugin-opener', '2.5.4'], + ['tauri-plugin-single-instance', '2.4.2'], + ['tauri-plugin-window-state', '2.4.1'], +]) { + assertCargoLockPackageVersion(packageName, expectedVersion); +} + +for (const [dependencyName, expectedVersion] of [ + ['tauri-build', '2.6.2'], + ['base64', '0.22.1'], + ['serde', '1.0.228'], + ['serde_json', '1.0.150'], + ['tauri', '2.11.2'], + ['tauri-plugin-clipboard-manager', '2.3.2'], + ['tauri-plugin-deep-link', '2.4.9'], + ['tauri-plugin-dialog', '2.7.1'], + ['tauri-plugin-notification', '2.3.3'], + ['tauri-plugin-opener', '2.5.4'], + ['tauri-plugin-single-instance', '2.4.2'], + ['tauri-plugin-window-state', '2.4.1'], +]) { + assertCargoLockDirectDependency( + 'genarrative-desktop-shell', + config.version, + dependencyName, + expectedVersion, + ); +} + +function readPngSize(file) { + const buffer = fs.readFileSync(file); + if ( + buffer.length < 24 || + buffer.toString('hex', 0, 8) !== '89504e470d0a1a0a' + ) { + throw new Error(`desktop shell icon is not a PNG: ${file.pathname}`); + } + + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + const dataStart = buffer.indexOf(Buffer.from('IDAT')); + const hasTransparencyChunk = buffer.includes(Buffer.from('tRNS')); + const hasAlphaColorType = buffer[25] === 4 || buffer[25] === 6; + + return { + width, + height, + dataStart, + hasTransparency: hasTransparencyChunk || hasAlphaColorType, + }; +} + +function assertDesktopIconSet() { + const icons = config.bundle?.icon ?? []; + if ( + icons.length !== requiredBundleIcons.length || + requiredBundleIcons.some((icon) => !icons.includes(icon)) + ) { + throw new Error('desktop shell must bundle the full real desktop icon set'); + } + + for (const [fileName, expectedSize] of requiredPngIconSizes) { + const icon = readPngSize(new URL(fileName, iconDirPath)); + if (icon.width !== expectedSize || icon.height !== expectedSize) { + throw new Error(`desktop shell icon ${fileName} must be ${expectedSize}x${expectedSize}`); + } + if (!icon.hasTransparency || icon.dataStart === -1) { + throw new Error(`desktop shell icon ${fileName} must use a real transparent brand asset`); + } + } + + const ico = fs.readFileSync(new URL('icon.ico', iconDirPath)); + if ( + ico.length < 6 || + ico.readUInt16LE(0) !== 0 || + ico.readUInt16LE(2) !== 1 || + ico.readUInt16LE(4) < 3 + ) { + throw new Error('desktop shell Windows icon must be a multi-size ICO'); + } + + const icns = fs.readFileSync(new URL('icon.icns', iconDirPath)); + if ( + icns.length < 16 || + icns.toString('ascii', 0, 4) !== 'icns' || + icns.readUInt32BE(4) !== icns.length + ) { + throw new Error('desktop shell macOS icon must be a valid ICNS'); + } +} + +function extractStringArrayExport(source, exportName, seen = new Set()) { + if (seen.has(exportName)) { + throw new Error(`cyclic string array export ${exportName}`); + } + + const match = source.match( + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\][^;]*;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + const nextSeen = new Set(seen); + nextSeen.add(exportName); + const entries = []; + for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) { + if (entry[1]) { + entries.push(...extractStringArrayExport(source, entry[1], nextSeen)); + } else { + entries.push(entry[2]); + } + } + + return entries; +} + +function extractRustStringArrayConst(source, constName) { + const match = source.match( + new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`), + ); + if (!match) { + throw new Error(`unable to read Rust const ${constName}`); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function extractRustStringConst(source, constName) { + const match = source.match( + new RegExp(`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*&str\\s*=\\s*"([^"]+)";`), + ); + if (!match) { + throw new Error(`unable to read Rust const ${constName}`); + } + + return match[1]; +} + +function extractRustNumberConst(source, constName) { + const match = source.match( + new RegExp( + `(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|u64|usize|i64|i32)\\s*=\\s*([^;]+);`, + ), + ); + if (!match) { + throw new Error(`unable to read Rust const ${constName}`); + } + + return evaluateNumberExpression(match[1]); +} + +function extractTsStringConst(source, constName) { + const match = source.match( + new RegExp(`export const ${constName}\\s*=\\s*'([^']+)';`), + ); + if (!match) { + throw new Error(`unable to read TypeScript const ${constName}`); + } + + return match[1]; +} + +function extractTsStringObjectConst(source, constName) { + const match = source.match( + new RegExp(`export const ${constName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), + ); + if (!match) { + throw new Error(`unable to read TypeScript const ${constName}`); + } + + return Object.fromEntries( + [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ + entry[1], + entry[2], + ]), + ); +} + +function extractTsNumberConst(source, constName) { + const match = source.match( + new RegExp(`export const ${constName}\\s*=\\s*([^;]+);`), + ); + if (!match) { + throw new Error(`unable to read TypeScript const ${constName}`); + } + + return evaluateNumberExpression(match[1]); +} + +function evaluateNumberExpression(expression) { + const tokens = expression + .split('*') + .map((token) => token.trim()) + .filter(Boolean); + if ( + tokens.length === 0 || + tokens.some((token) => !/^\d+$/.test(token)) + ) { + throw new Error(`unsupported numeric expression ${expression}`); + } + + return tokens.reduce((value, token) => value * Number(token), 1); +} + +function extractRustStringMatchArms(source, functionName) { + const match = source.match( + new RegExp( + `(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{[\\s\\S]*?match [^\\{]*\\{([\\s\\S]*?)\\n \\}`, + ), + ); + if (!match) { + throw new Error(`unable to read Rust match function ${functionName}`); + } + + return [...match[1].matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]); +} + +function extractRustSomeStringValues(source, functionName) { + const match = source.match( + new RegExp(`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{([\\s\\S]*?)\\n\\}`), + ); + if (!match) { + throw new Error(`unable to read Rust function ${functionName}`); + } + + return [...match[1].matchAll(/=>\s*Some\("([^"]+)"\)/g)].map( + (entry) => entry[1], + ); +} + +function extractRustGuardedMimeValues(source, functionName) { + const match = source.match( + new RegExp(`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{([\\s\\S]*?)\\n\\}`), + ); + if (!match) { + throw new Error(`unable to read Rust function ${functionName}`); + } + + return [...match[1].matchAll(/if mime_type == "([^"]+)"/g)].map( + (entry) => entry[1], + ); +} + +function extractDesktopCapabilities(source) { + const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/); + if (!match) { + throw new Error('unable to read desktop shell capabilities'); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function extractDesktopHandledMethods(source) { + const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n \}/g)].map( + (match) => match[1], + ); + if (matchBodies.length === 0) { + throw new Error('unable to read desktop shell HostBridge handler methods'); + } + + return [ + ...new Set( + matchBodies.flatMap((body) => + [...body.matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]), + ), + ), + ]; +} + +function extractDesktopUnsupportedHandledMethods(source, handledMethods) { + return handledMethods.filter((method) => { + const methodBody = extractDesktopHostBridgeMethodBody(source, method); + return ( + methodBody.includes('"unsupported_method"') || + methodBody.includes('"unsupported_capability"') || + methodBody.includes('resolve_host_bridge_request(request)') + ); + }); +} + +function extractDesktopHostBridgeMethodBody(source, method) { + const methodStart = source.indexOf(`"${method}" =>`); + if (methodStart < 0) { + throw new Error(`desktop shell HostBridge missing method ${method}`); + } + + const nextMethodMatch = source + .slice(methodStart + method.length) + .match(/\n (?:"[^"]+"|_)\s*=>/); + const nextMethodStart = nextMethodMatch + ? methodStart + method.length + nextMethodMatch.index + : -1; + return source.slice( + methodStart, + nextMethodStart > methodStart ? nextMethodStart : undefined, + ); +} + +function extractFunctionBody(source, functionName) { + const functionStart = source.search( + new RegExp(`(?:pub\\(crate\\)\\s+)?(?:async\\s+)?fn\\s+${functionName}\\s*\\(`), + ); + if (functionStart < 0) { + throw new Error(`unable to read Rust function ${functionName}`); + } + + const bodyStart = source.indexOf('{', functionStart); + if (bodyStart < 0) { + throw new Error(`unable to read Rust function body ${functionName}`); + } + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const character = source[index]; + if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(bodyStart, index + 1); + } + } + } + + throw new Error(`unable to read Rust function body ${functionName}`); +} + +function assertSnippetOrder(source, firstSnippet, secondSnippet, label) { + const firstIndex = source.indexOf(firstSnippet); + const secondIndex = source.indexOf(secondSnippet); + if (firstIndex < 0 || secondIndex < 0 || firstIndex > secondIndex) { + throw new Error(`${label} must call ${firstSnippet} before ${secondSnippet}`); + } +} + +function extractDesktopDialogFilter(source, ownerName, filterLabel) { + const ownerBody = extractFunctionBody(source, ownerName); + const match = ownerBody.match( + new RegExp(`\\.add_filter\\(\\s*"${filterLabel}",\\s*&\\[([^\\]]*)\\]\\s*,?\\s*\\)`), + ); + if (!match) { + throw new Error( + `desktop shell ${ownerName} must use a ${filterLabel} system dialog filter`, + ); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function assertDesktopDialogBoundary(method, functionName, filterLabel, expected, action) { + const methodBody = extractDesktopHostBridgeMethodBody( + desktopHostBridgeDispatchSource, + method, + ); + if (!methodBody.includes(`${functionName}(&app, &request).await`)) { + throw new Error(`desktop shell ${method} must delegate to files module`); + } + const fileFunctionBody = extractFunctionBody( + desktopHostBridgeFilesSource, + functionName, + ); + assertSameList( + extractDesktopDialogFilter( + desktopHostBridgeFilesSource, + functionName, + filterLabel, + ), + expected, + `desktop shell ${method} ${filterLabel} dialog filter`, + ); + + const requiredDialogAction = + action === 'save' ? '.blocking_save_file()' : '.blocking_pick_file()'; + const blockedDialogAction = + action === 'save' ? '.blocking_pick_file()' : '.blocking_save_file()'; + if (!fileFunctionBody.includes(requiredDialogAction)) { + throw new Error(`desktop shell ${method} must use ${requiredDialogAction}`); + } + if (fileFunctionBody.includes(blockedDialogAction)) { + throw new Error(`desktop shell ${method} must not use ${blockedDialogAction}`); + } + const unavailableResponse = + action === 'save' + ? 'return file_export_unavailable_response(request)' + : 'return file_import_unavailable_response(request)'; + if (!fileFunctionBody.includes(unavailableResponse)) { + throw new Error(`desktop shell ${method} must hide native path conversion errors`); + } + const failureLogger = + action === 'save' + ? 'log_desktop_file_export_failure("path.convert")' + : 'log_desktop_file_import_failure("path.convert")'; + if (!fileFunctionBody.includes(failureLogger)) { + throw new Error(`desktop shell ${method} must log native path conversion errors`); + } + if ( + fileFunctionBody.includes( + 'return failed(request.id.clone(), "host_error", error.to_string())', + ) + ) { + throw new Error(`desktop shell ${method} must not expose native path errors`); + } +} + +function assertDesktopFileExportPayloadOrder(method, functionName, payloadFunction) { + const fileFunctionBody = extractFunctionBody( + desktopHostBridgeFilesSource, + functionName, + ); + assertSnippetOrder( + fileFunctionBody, + payloadFunction, + '.dialog()', + `desktop shell ${method} export boundary`, + ); +} + +function assertNoRawHostContextUrl(urlValue, label) { + const rawUrl = String(urlValue ?? ''); + const blockedQueryKeys = [ + 'clientRuntime', + 'clientType', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', + ]; + + for (const key of blockedQueryKeys) { + if (rawUrl.includes(`${key}=`)) { + throw new Error( + `${label} must not hardcode ${key}; desktop Rust shell/url.rs must append host context`, + ); + } + } +} + +function assertSameList(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some((value, index) => value !== expected[index]) + ) { + throw new Error( + `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, + ); + } +} + +function readDirectoryEntryList(directory, label) { + const entries = fs + .readdirSync(directory, { withFileTypes: true }) + .map((entry) => { + if (entry.isFile()) { + return `file:${entry.name}`; + } + if (entry.isDirectory()) { + return `dir:${entry.name}`; + } + throw new Error(`${label} must not contain special entries: ${entry.name}`); + }) + .sort(); + if (entries.length === 0) { + throw new Error(`${label} must not be empty`); + } + return entries; +} + +function readDirectoryFileList(directory, label) { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile()) { + throw new Error(`${label} must not contain nested entries: ${entry.name}`); + } + files.push(entry.name); + } + return files.sort(); +} + +function assertDesktopSourceLayout() { + assertSameList( + readDirectoryFileList(new URL('../scripts/', import.meta.url), 'desktop shell scripts'), + expectedDesktopShellScripts, + 'desktop shell scripts', + ); + assertSameList( + readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'), + expectedDesktopRustRootEntries, + 'desktop shell Rust root entries', + ); + assertSameList( + readDirectoryFileList( + new URL('../src-tauri/src/host_bridge/', import.meta.url), + 'desktop host bridge Rust files', + ), + expectedDesktopHostBridgeRustFiles, + 'desktop host bridge Rust files', + ); + assertSameList( + readDirectoryFileList( + new URL('../src-tauri/src/shell/', import.meta.url), + 'desktop shell Rust files', + ), + expectedDesktopShellRustFiles, + 'desktop shell Rust files', + ); + + if (!main.includes('mod app;') || !main.includes('app::run();')) { + throw new Error('desktop shell main.rs must stay a thin app entrypoint'); + } + if ( + main.includes('tauri::Builder::default()') || + main.includes('tauri::generate_handler!') + ) { + throw new Error('desktop shell main.rs must not own Tauri app setup'); + } + if ( + !app.includes('tauri::Builder::default()') || + !app.includes('crate::host_bridge::host_bridge_request') + ) { + throw new Error('desktop shell app.rs must own Tauri app setup'); + } +} + +function extractTauriBuildCommands(source) { + const match = source.match(/\.commands\(\s*&\[\s*([^\]]*?)\s*\]\s*\)/); + if (!match) { + throw new Error('unable to read Tauri build manifest commands'); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function extractTauriInvokeCommands(source) { + const match = source.match(/tauri::generate_handler!\[\s*([^\]]*?)\s*\]/); + if (!match) { + throw new Error('unable to read Tauri invoke handler commands'); + } + + return match[1] + .split(',') + .map((command) => command.trim()) + .map((command) => command.split('::').pop()) + .filter(Boolean); +} + +function collectRustSourceBasenames(files) { + return files.map((file) => file.pathname.split('/').pop()).sort(); +} + +function collectRustSourceRelativePaths(files) { + const rootPath = rustSourceDir.pathname.endsWith('/') + ? rustSourceDir.pathname + : `${rustSourceDir.pathname}/`; + + return files + .map((file) => file.pathname.replace(rootPath, '')) + .sort(); +} + +function extractNativeAppTauriInvokeCommands(source) { + return [ + ...source.matchAll( + /\btauriInvoke(?:<[^)]*>)?\(\s*([A-Z_][A-Z0-9_]*)\s*,/g, + ), + ] + .map((match) => match[1].trim()) + .filter(Boolean); +} + +function assertGeneratedPermissions(commandNames) { + if (!fs.existsSync(generatedPermissionDir)) { + throw new Error('desktop shell generated permissions directory is missing'); + } + + const expectedPermissionFiles = commandNames + .map((command) => `${command}.toml`) + .sort(); + const permissionEntries = fs.readdirSync(generatedPermissionDir, { + withFileTypes: true, + }).sort((left, right) => left.name.localeCompare(right.name)); + + assertSameList( + permissionEntries.map((entry) => + `${entry.isDirectory() ? 'dir' : 'file'}:${entry.name}`, + ), + expectedPermissionFiles.map((file) => `file:${file}`), + 'desktop shell generated permission files', + ); + + for (const entry of permissionEntries) { + if (entry.isDirectory()) { + throw new Error( + `desktop shell generated permissions must not include nested directory ${entry.name}`, + ); + } + + const permissionFile = new URL(entry.name, generatedPermissionDir); + const permissionSource = fs.readFileSync(permissionFile, 'utf8'); + const commandName = entry.name.replace(/\.toml$/, ''); + for (const expectedSnippet of [ + `identifier = "allow-${commandName.replaceAll('_', '-')}"`, + `commands.allow = ["${commandName}"]`, + `commands.deny = ["${commandName}"]`, + ]) { + if (!permissionSource.includes(expectedSnippet)) { + throw new Error( + `desktop shell generated permission ${entry.name} drifted from ${commandName}`, + ); + } + } + } +} + +function assertNoTrackedDesktopGeneratedTauriFiles() { + const generatedPaths = [ + 'apps/desktop-shell/src-tauri/gen', + 'apps/desktop-shell/src-tauri/permissions/autogenerated', + ]; + const result = spawnSync('git', ['ls-files', ...generatedPaths], { + cwd: new URL('../../..', import.meta.url), + encoding: 'utf8', + }); + + if (result.error) { + throw new Error( + `unable to check desktop generated Tauri files: ${result.error.message}`, + ); + } + if ((result.status ?? 0) !== 0) { + throw new Error( + `unable to check desktop generated Tauri files: ${result.stderr.trim()}`, + ); + } + + const trackedGeneratedFiles = result.stdout + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); + if (trackedGeneratedFiles.length > 0) { + throw new Error( + `desktop generated Tauri files must stay untracked: ${trackedGeneratedFiles.join(', ')}`, + ); + } +} + +function assertOnlyMainCapabilityFile() { + const capabilityDir = new URL('../src-tauri/capabilities/', import.meta.url); + const files = fs + .readdirSync(capabilityDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + + assertSameList(files, ['main.json'], 'desktop shell capability files'); +} + +const sharedCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_CAPABILITIES', +); +const sharedMethods = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_METHODS', +); +const sharedEvents = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EVENTS', +); +const sharedDesktopCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', +); +const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) => + sharedEvents.includes(capability), +); +const sharedHostBridgeProtocol = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_PROTOCOL', +); +const sharedHostBridgeVersion = extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_VERSION', +); +const sharedPublicWebOrigin = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', +); +const sharedPublicWebUrl = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_URL', +); +const sharedNativeAppQueryKeys = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS', +); +const sharedNativeAppQuery = extractTsStringObjectConst( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY', +); +const sharedHostBridgePayloadLimits = { + HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', + ), + HOST_BRIDGE_RESPONSE_CACHE_MAX: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + ), + HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS', + ), + HOST_BRIDGE_BADGE_COUNT_MAX: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_BADGE_COUNT_MAX', + ), + HOST_BRIDGE_APP_TITLE_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_APP_TITLE_MAX_LENGTH', + ), + HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH', + ), + HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_FILE_NAME_MAX_LENGTH', + ), + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', + ), +}; +const desktopHostBridgePayloadLimits = { + HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', + ), + HOST_BRIDGE_RESPONSE_CACHE_MAX: extractRustNumberConst( + rustHostSource, + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + ), + HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS: extractRustNumberConst( + desktopShellNetworkSource, + 'DESKTOP_NETWORK_CHECK_TIMEOUT_MS', + ), + HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst( + desktopHostBridgeBadgeSource, + 'BADGE_COUNT_MAX', + ), + HOST_BRIDGE_APP_TITLE_MAX_LENGTH: extractRustNumberConst( + desktopHostBridgeTitleSource, + 'WINDOW_TITLE_MAX_LENGTH', + ), + HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst( + desktopHostBridgeClipboardSource, + 'CLIPBOARD_TEXT_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst( + desktopHostBridgeNotificationsSource, + 'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractRustNumberConst( + desktopHostBridgeNotificationsSource, + 'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH', + ), + HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'EXPORT_FILE_NAME_MAX_LENGTH', + ), + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'EXPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_DOCUMENT_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'EXPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'EXPORT_AUDIO_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_AUDIO_MAX_BYTES', + ), +}; +const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); +const desktopEvents = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_EVENTS'); +const desktopErrorCodes = extractRustStringArrayConst( + rustHostSource, + 'HOST_BRIDGE_ERROR_CODES', +); +const desktopHostBridgeProtocol = extractRustStringConst( + rustHostSource, + 'HOST_BRIDGE_PROTOCOL', +); +const desktopHostBridgeVersion = extractRustNumberConst( + rustHostSource, + 'HOST_BRIDGE_VERSION', +); +const desktopPublicWebOrigin = extractRustStringConst( + desktopShellUrlSource, + 'WEB_APP_ORIGIN', +); +const desktopCapabilities = extractDesktopCapabilities( + desktopHostBridgeCapabilitiesSource, +); +const desktopHandledMethods = extractDesktopHandledMethods( + desktopHostBridgeDispatchSource, +); +const desktopUnsupportedHandledMethods = extractDesktopUnsupportedHandledMethods( + desktopHostBridgeDispatchSource, + desktopHandledMethods, +); +const hostBridgeAcceptedUnsupportedMethods = sharedMethods.filter( + (method) => !desktopCapabilities.includes(method), +); +const desktopCapabilityDelegationContracts = { + 'host.getRuntime': 'desktop_host_bridge_runtime_response(&request)', + 'appearance.getColorScheme': 'desktop_appearance_color_scheme(&app, &request)', + 'share.open': 'open_desktop_host_bridge_share(&app, &request)', + 'share.setTarget': 'set_desktop_host_bridge_share_target(&app, &request)', + 'navigation.openNativePage': 'open_desktop_host_bridge_native_page(&app, &request)', + 'app.reloadWebView': 'reload_desktop_host_bridge_webview(&app, &request)', + 'app.openExternalUrl': 'open_desktop_host_bridge_external_url(&app, &request)', + 'app.setTitle': 'set_desktop_host_bridge_window_title(&app, &request)', + 'app.setBadgeCount': 'set_desktop_app_badge_count(&app, &request)', + 'network.status': 'resolve_desktop_host_bridge_network_status(&request).await', + 'clipboard.writeText': 'write_desktop_host_bridge_clipboard_text(&app, &request)', + 'clipboard.readText': 'read_desktop_host_bridge_clipboard_text(&app, &request)', + 'file.exportText': 'export_desktop_host_bridge_text_file(&app, &request).await', + 'file.importText': 'import_desktop_host_bridge_text_file(&app, &request).await', + 'file.importDocument': 'import_desktop_host_bridge_document_file(&app, &request).await', + 'file.exportImage': 'export_desktop_host_bridge_image_file(&app, &request).await', + 'file.importImage': 'import_desktop_host_bridge_image_file(&app, &request).await', + 'file.importAudio': 'import_desktop_host_bridge_audio_file(&app, &request).await', + 'file.exportAudio': 'export_desktop_host_bridge_audio_file(&app, &request).await', + 'notification.showLocal': 'show_desktop_local_notification(&app, &request)', +}; +if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) { + throw new Error( + `desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`, + ); +} + +if (desktopHostBridgeVersion !== sharedHostBridgeVersion) { + throw new Error( + `desktop shell HostBridge version drifted: expected ${sharedHostBridgeVersion} but got ${desktopHostBridgeVersion}`, + ); +} + +assertSameList( + desktopErrorCodes, + [ + 'invalid_request', + 'unsupported_method', + 'unsupported_capability', + 'timeout', + 'cancelled', + 'host_error', + ], + 'desktop shell HostBridge error codes', +); +if ( + !rustHostSource.includes('HOST_BRIDGE_ERROR_CODES.contains(&code)') || + !rustHostSource.includes('desktop host bridge request failed') +) { + throw new Error('desktop shell protocol must normalize non-contract host errors'); +} + +if (desktopPublicWebOrigin !== sharedPublicWebOrigin) { + throw new Error( + `desktop shell public web origin drifted: expected ${sharedPublicWebOrigin} but got ${desktopPublicWebOrigin}`, + ); +} + +assertSameList( + extractRustStringArrayConst(desktopShellUrlSource, 'HOST_CONTEXT_QUERY_KEYS'), + sharedNativeAppQueryKeys, + 'desktop shell host-context query keys', +); + +if (!desktopShellUrlSource.includes('fn append_desktop_host_context(url: &mut Url)')) { + throw new Error('desktop shell host-context query appending must stay centralized'); +} + +for (const [key, value, label] of [ + ['clientRuntime', sharedNativeAppQuery.clientRuntime, 'client runtime'], + ['clientType', sharedNativeAppQuery.clientType, 'client type'], + ['hostShell', sharedNativeAppQuery.hostShellTauriDesktop, 'host shell'], +]) { + if (!desktopShellUrlSource.includes(`.append_pair("${key}", "${value}")`)) { + throw new Error(`desktop shell host-context ${label} drifted from shared contract`); + } +} + +for (const expectedNetworkSnippet of [ + 'use crate::shell::url::WEB_APP_ORIGIN', + 'Url::parse(WEB_APP_ORIGIN)', + 'url.host_str()', + 'url.port_or_known_default()', +]) { + if (!desktopShellNetworkSource.includes(expectedNetworkSnippet)) { + throw new Error( + `desktop shell network probe must derive its target from WEB_APP_ORIGIN: missing ${expectedNetworkSnippet}`, + ); + } +} + +if (desktopShellNetworkSource.includes('"www.genarrative.world"')) { + throw new Error('desktop shell network probe must not duplicate the public web host'); +} +for (const blockedDesktopNetworkEventSnippet of [ + 'register_desktop_network_events', + 'navigator.onLine', + 'window.addEventListener(\'online\'', + 'window.addEventListener(\'offline\'', +]) { + if (desktopShellNetworkSource.includes(blockedDesktopNetworkEventSnippet)) { + throw new Error( + `desktop shell must not declare network.statusChanged until Rust owns the event source: ${blockedDesktopNetworkEventSnippet}`, + ); + } +} + +for (const expectedShareSnippet of [ + 'normalize_public_share_url', + 'raw_url.starts_with("//")', + 'url.origin() != base_url.origin()', + 'DesktopSharePayload::Invalid', + '"share target is invalid"', + 'log_desktop_share_failure', + 'desktop share failed for {label}', + 'share_unavailable_response', + '"share unavailable"', + 'set_desktop_host_bridge_share_target', + 'share_text_from_value(target)', + 'log_desktop_share_failure("target.lock")', + 'log_desktop_share_failure("target.store")', + 'share_target_payload_must_be_valid_before_cache', + 'share_unavailable_response_is_stable', + 'share_failures_are_logged_without_exposing_native_detail', + 'share_failures_log_stable_label_only', + 'open_desktop_host_bridge_share', + 'write_desktop_clipboard_text(app, &share_text)', + '"copied_to_clipboard"', +]) { + if (!desktopHostBridgeShareSource.includes(expectedShareSnippet)) { + throw new Error( + `desktop shell share URL boundary drifted: missing ${expectedShareSnippet}`, + ); + } +} + +if ( + desktopHostBridgeShareSource.includes('failed(request.id.clone(), "host_error", "share target lock poisoned")') || + desktopHostBridgeShareSource.includes('failed(request.id.clone(), "host_error", error.to_string())') || + desktopHostBridgeShareSource.includes('desktop share failed for {label}: {error}') || + desktopHostBridgeShareSource.includes('log_desktop_share_failure("target.lock", "share target lock poisoned")') || + desktopHostBridgeShareSource.includes('log_desktop_share_failure("target.store", "share target lock poisoned")') +) { + throw new Error('desktop shell share module must hide native share errors'); +} + +if ( + !desktopHostBridgeDispatchSource.includes( + 'set_desktop_host_bridge_share_target(&app, &request)', + ) || + !desktopHostBridgeDispatchSource.includes('open_desktop_host_bridge_share(&app, &request)') || + desktopHostBridgeDispatchSource.includes('DesktopShareState') || + desktopHostBridgeDispatchSource.includes('share_text_from_request') || + desktopHostBridgeDispatchSource.includes('"copied_to_clipboard"') +) { + throw new Error('desktop shell share HostBridge methods must delegate to share module'); +} + +for (const [limitName, sharedLimit] of Object.entries( + sharedHostBridgePayloadLimits, +)) { + const desktopLimit = desktopHostBridgePayloadLimits[limitName]; + if (desktopLimit !== sharedLimit) { + throw new Error( + `desktop shell ${limitName} drifted: expected ${sharedLimit} but got ${desktopLimit}`, + ); + } +} + +if ( + extractRustStringConst(rustHostSource, 'EXPORT_FILE_NAME_FALLBACK') !== + extractTsStringConst(sharedContractSource, 'HOST_BRIDGE_FILE_NAME_FALLBACK') +) { + throw new Error('desktop shell file name fallback drifted from shared HostBridge contract'); +} + +const sharedTextMimeTypes = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_TEXT_MIME_TYPES', +); +const sharedImageMimeTypes = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_IMAGE_MIME_TYPES', +); +const sharedAudioMimeTypes = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_AUDIO_MIME_TYPES', +); +assertSameList( + extractRustSomeStringValues(rustHostSource, 'import_text_mime_type'), + sharedTextMimeTypes, + 'desktop shell text MIME types', +); +assertSameList( + extractRustGuardedMimeValues(rustHostSource, 'normalize_export_text_mime_type'), + sharedTextMimeTypes, + 'desktop shell export text MIME types', +); +assertSameList( + extractRustStringMatchArms(rustHostSource, 'export_image_extension'), + sharedImageMimeTypes, + 'desktop shell export image MIME types', +); +assertSameList( + extractRustSomeStringValues(rustHostSource, 'import_image_mime_type'), + sharedImageMimeTypes, + 'desktop shell import image MIME types', +); +assertSameList( + extractRustStringMatchArms(rustHostSource, 'export_audio_extension'), + sharedAudioMimeTypes, + 'desktop shell export audio MIME types', +); +assertSameList( + extractRustSomeStringValues(rustHostSource, 'import_audio_mime_type'), + sharedAudioMimeTypes, + 'desktop shell import audio MIME types', +); + +assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); +assertSameList( + desktopEvents, + sharedDesktopEvents, + 'desktop shell HostBridge event whitelist', +); +if (sharedDesktopCapabilities.includes('network.statusChanged')) { + throw new Error( + 'desktop shell must not declare network.statusChanged until Rust owns a real event source', + ); +} +if (desktopEvents.includes('network.statusChanged')) { + throw new Error( + 'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source', + ); +} +for (const eventName of sharedEvents) { + if (!sharedCapabilities.includes(eventName)) { + throw new Error(`shared HostBridge event must also be a capability: ${eventName}`); + } +} +const unknownHandledDesktopMethods = desktopHandledMethods.filter( + (method) => !sharedMethods.includes(method), +); +if (unknownHandledDesktopMethods.length > 0) { + throw new Error( + `desktop shell handles unknown HostBridge methods: ${unknownHandledDesktopMethods.join(', ')}`, + ); +} + +const unknownDesktopCapabilities = desktopCapabilities.filter( + (capability) => !sharedCapabilities.includes(capability), +); +if (unknownDesktopCapabilities.length > 0) { + throw new Error( + `desktop shell declares unknown HostBridge capabilities: ${unknownDesktopCapabilities.join(', ')}`, + ); +} + +assertSameList( + desktopCapabilities, + sharedDesktopCapabilities, + 'desktop shell HostBridge capability profile', +); + +for (const snippet of [ + 'fn runtime_capability_list_keeps_desktop_boundaries()', + 'let unique_capabilities = desktop_capabilities.iter().copied().collect::>();', + 'assert_eq!(unique_capabilities.len(), desktop_capabilities.len());', + '"notification.showLocal"', + '"auth.requestLogin"', + '"payment.request"', + '"file.captureImage"', + '"scanner.scanQrCode"', + '"haptics.impact"', + 'assert!(!desktop_capabilities.contains(&capability));', +]) { + if (!desktopHostBridgeCapabilitiesSource.includes(snippet)) { + throw new Error( + `desktop shell capability list boundary test is missing coverage: ${snippet}`, + ); + } +} + +for (const capability of ['auth.requestLogin', 'payment.request']) { + if (desktopCapabilities.includes(capability)) { + throw new Error( + `desktop shell must not declare ${capability} until a real SDK/channel flow is implemented`, + ); + } +} + +if ( + !desktopHostBridgeDispatchSource.includes( + 'format!("{} unsupported in desktop shell", request.method)', + ) +) { + throw new Error( + 'desktop shell unsupported HostBridge methods must return explicit unsupported_method errors', + ); +} +const desktopUnsupportedMethodTestMatch = desktopHostBridgeDispatchSource.match( + /fn unsupported_method_is_explicit\(\)[\s\S]*?assert_eq!\(\s*unsupported_methods,\s*vec!\[([^\]]+)\]/, +); +if (!desktopUnsupportedMethodTestMatch) { + throw new Error('desktop shell unsupported HostBridge method test is missing'); +} +for (const snippet of [ + 'use crate::host_bridge::capabilities::capabilities;', + 'use crate::host_bridge::protocol::HOST_BRIDGE_METHODS;', + 'let desktop_capabilities = capabilities();', + 'let unsupported_methods = HOST_BRIDGE_METHODS', + '.filter(|method| !desktop_capabilities.contains(method))', + 'for method in unsupported_methods', +]) { + if (!desktopHostBridgeDispatchSource.includes(snippet)) { + throw new Error( + `desktop shell unsupported HostBridge method test must derive coverage from method/capability diff: ${snippet}`, + ); + } +} +assertSameList( + [...desktopUnsupportedMethodTestMatch[1].matchAll(/"([^"]+)"/g)].map( + (match) => match[1], + ), + hostBridgeAcceptedUnsupportedMethods, + 'desktop shell unsupported HostBridge method coverage', +); + +const missingDesktopMethodHandlers = desktopCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + !desktopHandledMethods.includes(capability), +); +if (missingDesktopMethodHandlers.length > 0) { + throw new Error( + `desktop shell declares request capabilities without HostBridge handlers: ${missingDesktopMethodHandlers.join(', ')}`, + ); +} + +const unsupportedDesktopCapabilities = desktopCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + desktopUnsupportedHandledMethods.includes(capability), +); +if (unsupportedDesktopCapabilities.length > 0) { + throw new Error( + `desktop shell declares request capabilities backed by fallback-only handlers: ${unsupportedDesktopCapabilities.join(', ')}`, + ); +} + +for (const capability of desktopCapabilities.filter((entry) => + sharedMethods.includes(entry), +)) { + const requiredDelegation = desktopCapabilityDelegationContracts[capability]; + if (!requiredDelegation) { + throw new Error( + `desktop shell declared request capability is missing a delegation contract: ${capability}`, + ); + } + + const methodBody = extractDesktopHostBridgeMethodBody( + desktopHostBridgeDispatchSource, + capability, + ); + if (!methodBody.includes(requiredDelegation)) { + throw new Error( + `desktop shell ${capability} must delegate to ${requiredDelegation}`, + ); + } +} + +const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter( + (method) => + !desktopCapabilities.includes(method) && + !hostBridgeAcceptedUnsupportedMethods.includes(method), +); +if (undeclaredDesktopMethodHandlers.length > 0) { + throw new Error( + `desktop shell handles unadvertised HostBridge methods: ${undeclaredDesktopMethodHandlers.join(', ')}`, + ); +} + +if (config.productName !== 'Genarrative') { + throw new Error('desktop shell productName must be Genarrative'); +} + +if (config.identifier !== 'world.genarrative.desktop') { + throw new Error('desktop shell identifier must be world.genarrative.desktop'); +} + +if (config.version !== '0.1.0') { + throw new Error('desktop shell app version must be 0.1.0'); +} + +if (packageConfig.version !== config.version) { + throw new Error('desktop shell package version must match tauri.conf.json version'); +} + +if (extractCargoPackageString(cargoManifest, 'name') !== 'genarrative-desktop-shell') { + throw new Error('desktop shell Cargo package name must be genarrative-desktop-shell'); +} + +if (extractCargoPackageString(cargoManifest, 'version') !== config.version) { + throw new Error('desktop shell Cargo package version must match tauri.conf.json version'); +} + +if (!rustHostSource.includes('host_version: env!("CARGO_PKG_VERSION")')) { + throw new Error('desktop shell runtime response must use the Cargo package version'); +} + +for (const runtimeSnippet of [ + 'pub(crate) fn desktop_runtime() -> HostBridgeRuntime', + 'pub(crate) fn desktop_host_bridge_runtime_response(', + 'request: &HostBridgeRequest', + 'ok(request.id.clone(), json!(desktop_runtime()))', + 'shell: "tauri_desktop"', + 'platform: desktop_platform()', + 'host_version: env!("CARGO_PKG_VERSION")', + 'bridge_version: HOST_BRIDGE_VERSION', + 'capabilities: capabilities()', +]) { + if (!desktopHostBridgeRuntimeSource.includes(runtimeSnippet)) { + throw new Error(`desktop shell runtime response missing ${runtimeSnippet}`); + } +} + +const desktopDispatchRuntimeDelegationSource = [ + extractFunctionBody(desktopHostBridgeDispatchSource, 'resolve_host_bridge_request'), + extractFunctionBody(desktopHostBridgeDispatchSource, 'execute_host_bridge_request'), +].join('\n'); +if ( + !desktopDispatchRuntimeDelegationSource.includes( + '"host.getRuntime" => desktop_host_bridge_runtime_response(&request)', + ) || + desktopDispatchRuntimeDelegationSource.includes('json!(desktop_runtime())') || + desktopDispatchRuntimeDelegationSource.includes(' ok(') || + desktopDispatchRuntimeDelegationSource.includes('HostBridgeRuntime') || + desktopDispatchRuntimeDelegationSource.includes('HOST_BRIDGE_VERSION') || + desktopDispatchRuntimeDelegationSource.includes('capabilities()') || + desktopDispatchRuntimeDelegationSource.includes('desktop_platform()') || + desktopDispatchRuntimeDelegationSource.includes('env!("CARGO_PKG_VERSION")') +) { + throw new Error('desktop shell dispatch must delegate host.getRuntime to runtime.rs'); +} + +if (desktopDispatchRuntimeDelegationSource.includes('json!(HostBridgeRuntime {')) { + throw new Error('desktop shell host.getRuntime must be built by runtime.rs'); +} + +if ( + !desktopShellNavigationSource.includes('pub(crate) fn open_normalized_desktop_external_url') || + !desktopShellNavigationSource.includes( + 'open_normalized_desktop_external_url(app, external_url)', + ) +) { + throw new Error( + 'desktop shell WebView external navigation must use the shared external opener helper', + ); +} + +if ( + !desktopHostBridgeDispatchSource.includes( + 'open_desktop_host_bridge_external_url(&app, &request)', + ) || + !desktopHostBridgeDispatchSource.includes( + 'open_desktop_host_bridge_native_page(&app, &request)', + ) || + !desktopHostBridgeDispatchSource.includes( + 'reload_desktop_host_bridge_webview(&app, &request)', + ) || + desktopHostBridgeDispatchSource.includes('open_normalized_desktop_external_url(&app, url)') || + desktopHostBridgeDispatchSource.includes('normalize_external_url') || + desktopHostBridgeDispatchSource.includes('normalize_native_page_url') || + desktopHostBridgeDispatchSource.includes('app.opener().open_url(') +) { + throw new Error( + 'desktop shell navigation HostBridge methods must delegate to navigation module', + ); +} +for (const snippet of [ + 'open_desktop_host_bridge_external_url', + 'external_url_unavailable_response', + 'log_desktop_navigation_failure', + 'desktop navigation failed for {label}', + 'desktop_external_url_from_request', + 'normalize_external_url', + 'open_normalized_desktop_external_url(app, url)', + 'log_desktop_navigation_failure("external.open")', + 'desktop_external_url_request_accepts_only_safe_system_protocols', + '"external URL cannot be opened"', + 'external_url_unavailable_response_is_stable', + 'open_desktop_host_bridge_native_page', + 'native_page_unavailable_response', + 'desktop_native_page_url_from_request', + 'normalize_native_page_url', + 'window.navigate(url)', + 'log_desktop_navigation_failure("native.navigate")', + 'log_desktop_navigation_failure("native.window")', + 'desktop_native_page_request_accepts_only_same_origin_h5_routes', + 'navigation_failures_are_logged_without_exposing_native_detail', + 'navigation_failures_log_stable_label_only', + '"native page unavailable"', + 'native_page_unavailable_response_is_stable', + 'reload_desktop_host_bridge_webview', + 'webview_reload_unavailable_response', + 'window.reload()', + 'log_desktop_navigation_failure("webview.reload")', + 'log_desktop_navigation_failure("webview.window")', + 'webview_reload_failures_are_logged_without_exposing_native_detail', + 'webview_reload_failures_log_stable_label_only', + '"webview reload unavailable"', + 'webview_reload_unavailable_response_is_stable', +]) { + if (!desktopHostBridgeNavigationSource.includes(snippet)) { + throw new Error(`desktop shell navigation module is missing ${snippet}`); + } +} +if ( + desktopHostBridgeNavigationSource.includes('failed(request.id.clone(), "host_error", error.to_string())') || + desktopHostBridgeNavigationSource.includes('"main window not found"') || + desktopHostBridgeNavigationSource.includes('desktop navigation failed for {label}: {error}') || + desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("external.open", &error.to_string())') || + desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("native.navigate", &error.to_string())') || + desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("webview.reload", &error.to_string())') || + desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("native.window", "main window unavailable")') || + desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("webview.window", "main window unavailable")') +) { + throw new Error('desktop shell navigation module must hide native navigation errors'); +} + +if ( + !desktopHostBridgeDispatchSource.includes( + 'resolve_desktop_host_bridge_network_status(&request).await', + ) || + desktopHostBridgeDispatchSource.includes('resolve_desktop_network_status') || + desktopHostBridgeDispatchSource.includes('spawn_blocking(resolve_desktop_network_status)') || + desktopHostBridgeDispatchSource.includes('Err(error) => failed(request.id, "host_error"') +) { + throw new Error('desktop shell network HostBridge method must delegate to network module'); +} +for (const snippet of [ + 'resolve_desktop_host_bridge_network_status', + 'map_desktop_host_bridge_network_status_result', + 'resolve_desktop_host_bridge_network_status_payload', + 'resolve_desktop_host_bridge_network_status_payload_with', + 'network_status_unavailable_response', + 'log_desktop_network_failure', + 'desktop network failed for {label}', + 'spawn_blocking(resolver)', + 'resolve_desktop_host_bridge_network_status_payload_with(resolve_desktop_network_status)', + 'log_desktop_network_failure("status.resolve")', + '"isConnected": true', + '"connectionType": "unknown"', + '"network status unavailable"', + 'network_status_unavailable_response_is_stable', + 'network_status_success_response_reports_contract_shape', + 'network_status_failure_hides_native_error_detail', + 'network_status_failures_are_logged_without_exposing_native_detail', + 'network_status_failures_log_stable_label_only', + 'private native resolver detail', +]) { + if (!desktopHostBridgeNetworkSource.includes(snippet)) { + throw new Error(`desktop shell network module is missing ${snippet}`); + } +} +if ( + desktopHostBridgeNetworkSource.includes('desktop network failed for {label}: {error}') || + desktopHostBridgeNetworkSource.includes('log_desktop_network_failure("status.resolve", &error)') +) { + throw new Error('desktop shell network module must hide native network errors'); +} + +if ( + !desktopHostBridgeDispatchSource.includes( + 'write_desktop_host_bridge_clipboard_text(&app, &request)', + ) || + !desktopHostBridgeDispatchSource.includes( + 'read_desktop_host_bridge_clipboard_text(&app, &request)', + ) || + desktopHostBridgeDispatchSource.includes('required_string_payload(&request, "text")') || + desktopHostBridgeDispatchSource.includes('read_desktop_clipboard_text(&app)') +) { + throw new Error('desktop shell clipboard HostBridge methods must delegate to clipboard module'); +} +for (const snippet of [ + 'write_desktop_clipboard_text(app, text)', + 'read_desktop_clipboard_text(app)', + 'normalize_clipboard_text(&text)', + 'log_desktop_clipboard_failure', + 'desktop clipboard failed for {label}', + 'log_desktop_clipboard_failure("write.text")', + 'log_desktop_clipboard_failure("read.text")', + 'clipboard_write_unavailable_response', + 'clipboard_read_unavailable_response', + 'required_string_payload(request, "text")', + 'truncated.chars().count()', + '"text": text', + '"clipboard write unavailable"', + '"clipboard read unavailable"', + 'clipboard_unavailable_responses_are_stable', + 'clipboard_failures_are_logged_without_exposing_native_detail', + 'clipboard_failures_log_stable_label_only', +]) { + if (!desktopHostBridgeClipboardSource.includes(snippet)) { + throw new Error(`desktop shell clipboard module is missing ${snippet}`); + } +} +if ( + desktopHostBridgeClipboardSource.includes('desktop clipboard failed for {label}: {error}') || + desktopHostBridgeClipboardSource.includes('log_desktop_clipboard_failure("write.text", &error)') || + desktopHostBridgeClipboardSource.includes('log_desktop_clipboard_failure("read.text", &error)') +) { + throw new Error('desktop shell clipboard module must hide native clipboard errors'); +} + +if (Object.hasOwn(config.build ?? {}, 'frontendDist')) { + throw new Error('desktop shell release must load the shared public H5 URL without packaging root dist'); +} + +if (config.build?.beforeBuildCommand !== 'npm run typecheck') { + throw new Error('desktop shell build command must run from apps/desktop-shell'); +} + +if (config.build?.beforeDevCommand !== 'npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port') { + throw new Error('desktop shell dev command must run the root H5 Vite dev server on the Tauri devUrl port'); +} + +if ( + packageConfig.scripts?.dev !== 'WEB_PORT=3000 tauri dev' || + config.build?.devUrl !== 'http://127.0.0.1:3000/' +) { + throw new Error( + 'desktop shell dev script and beforeDevCommand must pin WEB_PORT=3000 to match Tauri devUrl', + ); +} + +const [mainWindow] = config.app?.windows ?? []; +if (!mainWindow || mainWindow.create !== false) { + throw new Error('desktop shell must create the main window from Rust setup'); +} + +if ((config.app?.windows ?? []).length !== 1) { + throw new Error('desktop shell must expose exactly one configured main window'); +} + +if (mainWindow.label !== 'main') { + throw new Error('desktop shell main window label must be main'); +} + +if (String(mainWindow.url ?? '') !== sharedPublicWebUrl) { + throw new Error('desktop shell release window must load the shared public H5 URL'); +} + +if (String(config.build?.devUrl ?? '') !== 'http://127.0.0.1:3000/') { + throw new Error('desktop shell dev URL must load the local Vite H5 entry'); +} + +assertNoRawHostContextUrl(mainWindow.url, 'desktop shell main window URL'); +assertNoRawHostContextUrl(config.build?.devUrl, 'desktop shell dev URL'); + +if (mainWindow.devtools !== false) { + throw new Error('desktop shell main WebView devtools must be explicitly disabled'); +} + +assertDesktopIconSet(); + +if (config.bundle?.active !== true || config.bundle?.targets !== 'all') { + throw new Error('desktop shell bundle targets must remain enabled for all platforms'); +} + +if (config.bundle?.macOS?.infoPlist !== 'Info.plist') { + throw new Error('desktop shell macOS bundle must merge the checked Info.plist'); +} + +for (const [key, value] of [ + [ + 'NSCameraUsageDescription', + '允许 Genarrative 使用摄像头运行需要实时动作输入的同源 H5 体验。', + ], + [ + 'NSMicrophoneUsageDescription', + '允许 Genarrative 使用麦克风运行需要实时声音输入的同源 H5 玩法。', + ], +]) { + if (!macInfoPlist.includes(`${key}`)) { + throw new Error(`desktop shell macOS Info.plist missing ${key}`); + } + if (!macInfoPlist.includes(`${value}`)) { + throw new Error(`desktop shell macOS Info.plist ${key} text drifted`); + } +} + +const csp = String(config.app?.security?.csp ?? ''); +const devCsp = String(config.app?.security?.devCsp ?? ''); +for (const blockedCspToken of ["'unsafe-eval'", 'tauri:', 'file:']) { + if (csp.includes(blockedCspToken)) { + throw new Error(`desktop shell CSP must not include ${blockedCspToken}`); + } + if (devCsp.includes(blockedCspToken)) { + throw new Error(`desktop shell dev CSP must not include ${blockedCspToken}`); + } +} + +for (const releaseOnlyBlockedCspToken of [ + 'http://127.0.0.1', + 'ws://127.0.0.1', +]) { + if (csp.includes(releaseOnlyBlockedCspToken)) { + throw new Error( + `desktop shell release CSP must not include ${releaseOnlyBlockedCspToken}`, + ); + } +} + +for (const requiredCspToken of [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + 'connect-src', + 'frame-src', +]) { + if (!csp.includes(requiredCspToken)) { + throw new Error(`desktop shell CSP missing ${requiredCspToken}`); + } +} + +for (const requiredDevCspToken of [ + 'http://127.0.0.1:*', + 'ws://127.0.0.1:*', + "script-src 'self'", +]) { + if (!devCsp.includes(requiredDevCspToken)) { + throw new Error(`desktop shell dev CSP missing ${requiredDevCspToken}`); + } +} + +assertExactCspDirective(csp, 'script-src', ["'self'"], 'desktop shell release CSP'); +assertExactCspDirective(devCsp, 'script-src', ["'self'"], 'desktop shell dev CSP'); + +const allowedPermissions = [ + 'allow-host-bridge-request', +]; +const blockedCoreDefaultPermissions = [ + 'core:default', + 'core:app:default', + 'core:event:default', + 'core:image:default', + 'core:menu:default', + 'core:path:default', + 'core:resources:default', + 'core:tray:default', + 'core:webview:default', + 'core:window:default', +]; +const blockedCorePermissionPrefixes = [ + 'core:app:', + 'core:event:', + 'core:image:', + 'core:menu:', + 'core:path:', + 'core:resources:', + 'core:tray:', + 'core:webview:', + 'core:window:', +]; +const blockedPluginPermissionPrefixes = [ + 'clipboard-manager:', + 'deep-link:', + 'dialog:', + 'fs:', + 'notification:', + 'opener:', + 'window-state:', +]; +const sharedTauriCommand = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_TAURI_COMMAND', +); +const allowedTauriCommands = [sharedTauriCommand]; +const expectedRustRootEntries = [ + 'dir:host_bridge', + 'dir:shell', + 'file:app.rs', + 'file:main.rs', +]; +const expectedRustHostBridgeFiles = [ + 'appearance.rs', + 'badge.rs', + 'capabilities.rs', + 'clipboard.rs', + 'dispatch.rs', + 'file_payloads.rs', + 'files.rs', + 'mod.rs', + 'navigation.rs', + 'network.rs', + 'notifications.rs', + 'protocol.rs', + 'runtime.rs', + 'share.rs', + 'title.rs', +]; +const expectedRustShellFiles = [ + 'deep_link.rs', + 'events.rs', + 'file_drop.rs', + 'lifecycle.rs', + 'mod.rs', + 'navigation.rs', + 'network.rs', + 'runtime.rs', + 'tray.rs', + 'url.rs', + 'webview.rs', + 'window_state.rs', +]; +const requiredRustHostModules = [ + 'app.rs', + ...expectedRustHostBridgeFiles.map((fileName) => `host_bridge/${fileName}`), + 'main.rs', + ...expectedRustShellFiles.map((fileName) => `shell/${fileName}`), +]; +const requiredRustHostSnippets = [ + 'tauri_plugin_single_instance::init', + 'desktop_window_state_plugin()', + 'tauri_plugin_window_state::Builder::default()', + 'desktop_window_state_flags()', + 'StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED', + 'fn desktop_window_state_flags_keep_visible_state_out_of_persistence()', + 'assert!(!flags.contains(StateFlags::VISIBLE));', + 'assert!(!flags.contains(StateFlags::FULLSCREEN));', + 'assert!(!flags.contains(StateFlags::DECORATIONS));', + 'tauri_plugin_deep_link::init()', + 'register_desktop_deep_link_events(app)', + 'register_desktop_deep_link_schemes(app)', + 'DeepLinkExt', + 'app.deep_link().on_open_url', + 'app.deep_link().get_current()', + 'app.deep_link().register_all()', + 'normalize_desktop_deep_link_url', + 'log_desktop_deep_link_open_result', + 'open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) -> tauri::Result<()>', + 'log_desktop_deep_link_current_result', + 'desktop host event failed for deep_link.current', + 'log_desktop_deep_link_window_missing', + 'desktop host event failed for deep_link.window', + 'main window unavailable during', + 'window.navigate(target_url)?', + 'show_main_window(window.app_handle())', + 'log_desktop_host_event_result("tray.show", show_main_window(app))', + 'log_desktop_host_event_result("tray.reload", reload_main_window(app))', + 'log_desktop_host_event_result("single_instance.show", show_main_window(app))', + '"navigation.external"', + '"webview.new_window.external"', + '"tray.close.lifecycle"', + 'log_desktop_host_event_result("tray.close.hide", close_window.hide())', + 'DESKTOP_DEEP_LINK_HOSTS', + 'resolve_desktop_single_instance_action', + 'tauri_plugin_clipboard_manager::init()', + 'TrayIconBuilder::with_id', + 'register_desktop_tray(app)', + 'DESKTOP_TRAY_ID', + 'TRAY_MENU_SHOW', + 'TRAY_MENU_RELOAD', + 'TRAY_MENU_QUIT', + 'show_main_window', + 'reload_main_window', + 'register_desktop_window_close_events', + 'resolve_desktop_window_close_action', + 'WindowEvent::CloseRequested', + 'api.prevent_close()', + 'close_window.hide()', + 'desktop tray registration failed', + '"appearance.getColorScheme"', + '"host.events"', + '"app.lifecycle"', + '"network.status"', + '"share.open"', + '"share.setTarget"', + '"navigation.openNativePage"', + '"navigation.canGoBack"', + '"app.reloadWebView"', + '"app.setTitle"', + '"app.setBadgeCount"', + '"clipboard.writeText"', + '"clipboard.readText"', + '"file.exportText"', + '"file.importText"', + '"file.importDocument"', + '"file.exportImage"', + '"file.importImage"', + '"file.importAudio"', + '"file.exportAudio"', + '"file.imageDropped"', + '"notification.showLocal"', + 'write_desktop_host_bridge_clipboard_text(&app, &request)', + 'read_desktop_host_bridge_clipboard_text(&app, &request)', + 'tauri_plugin_dialog::init()', + 'tauri_plugin_notification::init()', + 'tauri_plugin_notification::{NotificationExt, PermissionState}', + 'DesktopNotificationPermissionAction', + 'desktop_notification_permission_action', + 'PermissionState::Granted', + 'PermissionState::Denied', + 'PermissionState::Prompt | PermissionState::PromptWithRationale', + '"notification permission denied"', + '"copied_to_clipboard"', + 'file_export_cancelled_response', + 'file_import_cancelled_response', + 'file_export_unavailable_response', + 'file_import_unavailable_response', + 'file_import_payload_error_response', + 'log_desktop_file_export_failure', + 'log_desktop_file_import_failure', + 'desktop file export failed for {label}', + 'desktop file import failed for {label}', + 'ImportFilePayloadError::NativeRead', + 'ImportFilePayloadError::InvalidRequest', + 'log_desktop_file_export_failure("path.convert")', + 'log_desktop_file_import_failure("path.convert")', + 'log_desktop_file_export_failure("write.text")', + 'log_desktop_file_export_failure("write.image")', + 'log_desktop_file_export_failure("write.audio")', + 'file_import_payload_error_response(request, "read.text", error)', + 'file_import_payload_error_response(request, "read.document", error)', + 'file_import_payload_error_response(request, "read.image", error)', + 'file_import_payload_error_response(request, "read.audio", error)', + 'log_desktop_file_import_failure(label)', + 'log_desktop_file_import_failure("read.text.join")', + 'log_desktop_file_import_failure("read.document.join")', + 'log_desktop_file_import_failure("read.image.join")', + 'log_desktop_file_import_failure("read.audio.join")', + 'desktop_file_cancelled_responses_are_stable', + 'desktop_file_unavailable_responses_are_stable', + 'desktop_file_failures_are_logged_without_exposing_native_detail', + 'desktop_file_failures_log_stable_label_only', + 'desktop_file_import_validation_errors_keep_stable_invalid_request', + 'desktop_file_import_native_read_errors_use_stable_host_error', + '"file export cancelled"', + '"file import cancelled"', + '"file export unavailable"', + '"file import unavailable"', + 'BASE64_STANDARD.decode', + 'fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)', + 'fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)', + 'fs::read_to_string(&path).map_err(|_error| ImportFilePayloadError::NativeRead)', + 'blocking_pick_file', + 'import_text_file_payload', + 'import_image_file_payload', + 'import_audio_file_payload', + 'export_image_payload', + 'export_audio_payload', + 'detect_image_mime_type', + 'detect_audio_mime_type', + 'ensure_image_bytes_match_mime_type', + 'ensure_audio_bytes_match_mime_type', + '"image bytes do not match MIME"', + '"audio bytes do not match MIME"', + 'set_title', + 'set_badge_count', + 'window.reload()', + 'read_text()', + 'normalize_clipboard_text', + 'window.theme()', + 'WindowEvent::Focused', + 'WindowEvent::Resized', + 'resolve_desktop_lifecycle_payload', + 'resolve_desktop_lifecycle_window_flag', + 'emit_current_desktop_lifecycle_event', + 'desktop_lifecycle_window_flag_logs_failure_and_uses_default', + 'desktop_lifecycle_window_flag_logs_stable_label_only', + '"background"', + '"hidden"', + '"minimized"', + 'WindowEvent::DragDrop', + 'first_valid_desktop_image_drop_payload', + 'if !path.is_file() || import_image_mime_type(path).is_none()', + 'match import_image_file_payload(path.clone(), "dropped", Some(position))', + 'import_image_file_payload(path.clone(), "dropped", Some(position))', + 'log_desktop_image_drop_payload_failure()', + 'file.imageDropped.payload', + 'fn image_drop_without_valid_image_does_not_emit_payload()', + 'fn image_drop_logs_invalid_candidate_then_uses_next_valid_image()', + 'fn image_drop_payload_failure_reports_failure()', + 'fn image_drop_payload_failure_logs_stable_label_only()', + 'fs::create_dir_all(&directory_path)', + 'assert_eq!(payload, None);', + 'PageLoadEvent', + 'host_bridge_event_script', + 'is_host_bridge_event_name', + 'origin: window.location.origin', + 'source: window', + 'should_replay_desktop_webview_state_on_page_load', + 'PageLoadEvent::Finished', + 'replay_desktop_webview_state', + 'log_desktop_host_event_result', + 'desktop_host_event_result_logs_stable_label_only', + 'resolve_desktop_lifecycle_window_flag("visible",', + 'resolve_desktop_lifecycle_window_flag("minimized",', + 'resolve_desktop_lifecycle_window_flag(\n "focused",', + 'resolve_desktop_network_status', + 'resolve_desktop_network_reachability', + 'log_desktop_network_probe_failure', + 'desktop network reachability probe failed', + 'desktop_network_reachability_reports_missing_probe_target', + 'desktop_network_probe_failure_logs_stable_label_only', + 'register_desktop_navigation_events', + 'desktop_navigation_state_script', + 'navigation.canGoBack', + '__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__', + 'window.history.pushState', + 'window.history.replaceState', + "window.addEventListener('popstate'", + '__genarrativeDesktopHistoryIndex', + "console.warn('desktop navigation state sync failed')", + 'file.imageDropped', + 'normalize_desktop_drop_position', + '.round().max(0.0) as i32', + 'desktop_drop_position_is_rounded_and_clamped_to_window_bounds', + 'emit_desktop_image_drop_event(&drop_window, paths, drop_position)', + 'app.notification().builder()', + 'desktop_entry_url_with_host_context', + 'desktop_entry_url_removes_stale_host_context_before_appending_current_context', + 'desktop_h5_url_with_host_context', + 'desktop_h5_url_with_host_context(target_url)', + 'desktop_h5_url_with_host_context(normalized_url)', + 'desktop_window_config_with_runtime_platform', + 'apply_desktop_dev_url_to_main_window_config', + 'DESKTOP_MAIN_WINDOW_LABEL: &str = "main"', + 'desktop_main_window_config_from_windows', + '.find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL)', + 'tauri::Error::WindowNotFound', + 'desktop_main_window_config(app)?', + 'desktop_main_window_config_uses_labeled_main_window', + 'desktop_main_window_config_uses_dev_url_in_dev_builds', + 'config.url = tauri::WebviewUrl::External(dev_url.clone())', + 'assert_eq!(url.origin().ascii_serialization(), "http://127.0.0.1:3000")', + 'path.starts_with("index.html?")', + 'path.contains("clientRuntime=native_app")', + 'path.contains("clientType=native_app")', + 'path.contains("hostShell=tauri_desktop")', + 'path.contains("hostPlatform=")', + 'path.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION")))', + 'path.contains("bridgeVersion=")', + 'path.contains("hostCapabilities=")', + 'should_allow_desktop_webview_navigation', + 'matches!(url.scheme(), "http" | "https")', + 'url.host_str() == Some("127.0.0.1")', + 'url.port_or_known_default() == Some(3000)', + 'Url::parse("http://tauri.localhost/index.html")', + 'Url::parse("http://127.0.0.1:3000/works/detail?work=PZ-1")', + 'desktop_external_navigation_url', + 'open_normalized_desktop_external_url', + 'open_desktop_external_navigation', + 'desktop_new_window_external_url', + 'desktop_new_window_response', + '.on_navigation(move |url|', + '.on_new_window(move |url, _features|', + '.on_page_load(|window, payload|', + 'DownloadEvent', + 'should_allow_desktop_webview_download', + 'DownloadEvent::Requested { .. } => false', + 'handle_desktop_webview_download', + 'log_desktop_webview_download_blocked', + 'desktop host event blocked for webview.download', + 'desktop_webview_download_blocked_logs_stable_label_only', + 'desktop_webview_download_handler_logs_blocked_requests', + '.on_download(|_webview, event| handle_desktop_webview_download(&event))', + 'NewWindowResponse::Deny', + 'HOST_BRIDGE_METHODS', + 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', + 'normalize_request_id', + 'is_host_bridge_method', + 'invalid host bridge request id', + 'invalid host bridge method', + 'HostBridgeReplayState', + 'HostBridgeReplayReservation', + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + 'Result', + 'log_desktop_replay_failure("cache.lock")', + 'desktop host bridge replay failed for {label}', + 'replay_unavailable_response(request_id)', + 'host_bridge_replay_state_returns_stable_error_when_cache_lock_is_unavailable', + 'desktop_host_bridge_replay_logs_stable_label_only', + '.manage(HostBridgeReplayState::default())', + 'fn prepare_host_bridge_request(', + 'prepare_host_bridge_request(&mut request)', + 'match replay_state.reserve(&request.id)', + 'Err(response) => response', + 'HostBridgeReplayState::wait_for_response', + 'execute_host_bridge_request(app, request).await', + 'replay_state.complete(slot, response)', +]; + +for (const blockedDesktopFileLogSnippet of [ + 'desktop file export failed for {label}: {error}', + 'desktop file import failed for {label}: {error}', + 'Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error)', + 'fs::metadata(&path).map_err(|error| error.to_string())', + 'fs::read(&path).map_err(|error| error.to_string())', + 'fs::read_to_string(&path).map_err(|error| error.to_string())', + 'log_desktop_file_export_failure("path.convert", &error.to_string())', + 'log_desktop_file_import_failure("path.convert", &error.to_string())', + 'log_desktop_file_export_failure("write.text", &error.to_string())', + 'log_desktop_file_export_failure("write.image", &error.to_string())', + 'log_desktop_file_export_failure("write.audio", &error.to_string())', + 'log_desktop_file_import_failure("read.text.join", &error.to_string())', + 'log_desktop_file_import_failure("read.document.join", &error.to_string())', + 'log_desktop_file_import_failure("read.image.join", &error.to_string())', + 'log_desktop_file_import_failure("read.audio.join", &error.to_string())', +]) { + if (desktopHostBridgeFilesSource.includes(blockedDesktopFileLogSnippet)) { + throw new Error('desktop shell file module must hide native file errors'); + } +} + +const desktopNavigationStateScriptBody = extractFunctionBody( + desktopShellNavigationSource, + 'desktop_navigation_state_script', +); +if ( + desktopNavigationStateScriptBody.includes( + "console.warn('desktop navigation state sync failed', error)", + ) +) { + throw new Error( + 'desktop shell navigation state diagnostics must not log native error objects', + ); +} + +if ( + desktopShellNavigationSource.includes( + 'eprintln!("desktop host event blocked for webview.download: {url}")', + ) +) { + throw new Error('desktop WebView download diagnostics must not log URLs'); +} + +if ( + desktopHostBridgeProtocolSource.includes( + 'desktop host bridge replay failed for {label}: {error}', + ) || + desktopHostBridgeProtocolSource.includes('&error.to_string()') +) { + throw new Error('desktop HostBridge replay diagnostics must not log Rust error details'); +} + +if (desktopShellNetworkSource.includes('desktop network reachability probe failed: {reason}')) { + throw new Error('desktop network probe diagnostics must not log resolver or connection details'); +} + +assertSameList( + allowedTauriCommands, + ['host_bridge_request'], + 'shared Tauri HostBridge command', +); + +for (const snippet of [ + 'fn host_bridge_request_rejects_invalid_requests_before_replay()', + 'let replay_state = HostBridgeReplayState::default();', + 'prepare_host_bridge_request(&mut invalid).expect("invalid envelope")', + 'assert_eq!(response.id, "request-1");', + 'assert_eq!(response.error.expect("error").code, "invalid_request");', + '.reserve("request-1")', + 'invalid request must not reserve replay slot', +]) { + if (!rustHostSource.includes(snippet)) { + throw new Error(`desktop shell host_bridge command facade test missing ${snippet}`); + } +} + +for (const snippet of [ + 'expect("host bridge replay cache lock")', + 'expect("host bridge replay slot lock")', + 'expect("host bridge replay slot wait")', + 'expect("host bridge replay response")', +]) { + if (desktopHostBridgeProtocolSource.includes(snippet)) { + throw new Error(`desktop shell HostBridge replay path must not panic on ${snippet}`); + } +} + +assertSameList( + readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'), + expectedRustRootEntries, + 'desktop shell Rust root entries', +); +assertSameList( + readDirectoryFileList( + new URL('host_bridge/', rustSourceDir), + 'desktop shell host_bridge Rust files', + ), + expectedRustHostBridgeFiles, + 'desktop shell host_bridge Rust files', +); +assertSameList( + readDirectoryFileList( + new URL('shell/', rustSourceDir), + 'desktop shell shell Rust files', + ), + expectedRustShellFiles, + 'desktop shell shell Rust files', +); +assertSameList( + collectRustSourceRelativePaths(rustHostSourceFiles), + requiredRustHostModules, + 'desktop shell Rust bridge modules', +); +const desktopExportImagePayloadBody = extractFunctionBody( + desktopHostBridgeFilePayloadsSource, + 'export_image_payload', +); +if ( + !desktopExportImagePayloadBody.includes( + 'bytes.is_empty() || bytes.len() > EXPORT_IMAGE_MAX_BYTES', + ) +) { + throw new Error('desktop shell image export must reject empty and oversized bytes'); +} +assertSameList( + extractNativeAppTauriInvokeCommands(nativeAppHostBridgeSource), + ['HOST_BRIDGE_TAURI_COMMAND'], + 'H5 native app Tauri invoke command source', +); +if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) { + throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND'); +} +if ( + !desktopHostBridgeDispatchSource.includes( + 'desktop_appearance_color_scheme(&app, &request)', + ) || + desktopHostBridgeDispatchSource.includes('window.theme()') || + desktopHostBridgeDispatchSource.includes('color_scheme_from_theme') +) { + throw new Error('desktop shell appearance HostBridge method must delegate to appearance module'); +} +for (const snippet of [ + 'desktop_appearance_color_scheme', + 'appearance_unavailable_response', + 'log_desktop_appearance_failure', + 'desktop appearance failed for {label}', + 'color_scheme_from_theme(theme)', + 'window.theme()', + 'log_desktop_appearance_failure("theme")', + 'log_desktop_appearance_failure("window")', + '"colorScheme"', + '"appearance unavailable"', + 'appearance_unavailable_response_is_stable', + 'appearance_failures_are_logged_without_exposing_native_detail', + 'appearance_failures_log_stable_label_only', +]) { + if (!desktopHostBridgeAppearanceSource.includes(snippet)) { + throw new Error(`desktop shell appearance module is missing ${snippet}`); + } +} +if ( + desktopHostBridgeAppearanceSource.includes('failed(request.id.clone(), "host_error", error.to_string())') || + desktopHostBridgeAppearanceSource.includes('"main window not found"') || + desktopHostBridgeAppearanceSource.includes('desktop appearance failed for {label}: {error}') || + desktopHostBridgeAppearanceSource.includes('&error.to_string()') +) { + throw new Error('desktop shell appearance module must hide native window errors'); +} +if ( + !desktopHostBridgeDispatchSource.includes( + 'set_desktop_app_badge_count(&app, &request)', + ) || + desktopHostBridgeDispatchSource.includes('"app.setBadgeCount" => match') || + desktopHostBridgeDispatchSource.includes('Ok(()) => ok(request.id, json!(true))') || + desktopHostBridgeDispatchSource.includes('set_badge_count') || + desktopHostBridgeDispatchSource.includes('BADGE_COUNT_MAX') +) { + throw new Error('desktop shell badge HostBridge method must delegate to badge module'); +} +for (const snippet of [ + 'BADGE_COUNT_MAX', + 'fn badge_count_payload', + 'set_desktop_app_badge_count', + 'badge_unavailable_response', + 'log_desktop_badge_failure', + 'desktop app badge failed for {label}', + 'log_desktop_badge_failure("window")', + 'log_desktop_badge_failure("set")', + 'ok(request.id.clone(), json!(true))', + '"count must be an integer between 0 and 99999"', + '"badge unavailable"', + 'badge_unavailable_response_is_stable', + 'badge_failures_are_logged_without_exposing_native_detail', + 'badge_failures_log_stable_label_only', +]) { + if (!desktopHostBridgeBadgeSource.includes(snippet)) { + throw new Error(`desktop shell badge module is missing ${snippet}`); + } +} +if ( + desktopHostBridgeBadgeSource.includes('failed(request.id.clone(), "host_error", error.to_string())') || + desktopHostBridgeBadgeSource.includes('"main window not found"') || + desktopHostBridgeBadgeSource.includes('desktop app badge failed for {label}: {error}') || + desktopHostBridgeBadgeSource.includes('&error.to_string()') +) { + throw new Error('desktop shell badge module must hide native window errors'); +} +if (!desktopHostBridgeBadgeSource.match(/window\s*\.\s*set_badge_count\s*\(\s*count\s*\)/)) { + throw new Error('desktop shell badge module must call window.set_badge_count(count)'); +} +if ( + !desktopHostBridgeDispatchSource.includes( + 'set_desktop_host_bridge_window_title(&app, &request)', + ) || + desktopHostBridgeDispatchSource.includes('WINDOW_TITLE_MAX_LENGTH') || + desktopHostBridgeDispatchSource.includes('required_string_payload(&request, "title")') || + desktopHostBridgeDispatchSource.includes('window.set_title') +) { + throw new Error('desktop shell title HostBridge method must delegate to title module'); +} +for (const snippet of [ + 'WINDOW_TITLE_MAX_LENGTH', + 'normalize_window_title', + 'window_title_from_request', + 'window_title_unavailable_response', + 'log_desktop_window_title_failure', + 'desktop window title failed for {label}', + 'required_string_payload(request, "title")', + 'window.set_title(&title)', + 'log_desktop_window_title_failure("set")', + 'log_desktop_window_title_failure("window")', + '"title is required"', + '"window title unavailable"', + 'window_title_request_rejects_missing_or_invalid_payload', + 'window_title_request_trims_and_truncates_shared_boundary', + 'window_title_unavailable_response_is_stable', + 'window_title_failures_are_logged_without_exposing_native_detail', + 'window_title_failures_log_stable_label_only', +]) { + if (!desktopHostBridgeTitleSource.includes(snippet)) { + throw new Error(`desktop shell title module is missing ${snippet}`); + } +} +if ( + desktopHostBridgeTitleSource.includes('failed(request.id.clone(), "host_error", error.to_string())') || + desktopHostBridgeTitleSource.includes('"main window not found"') || + desktopHostBridgeTitleSource.includes('desktop window title failed for {label}: {error}') || + desktopHostBridgeTitleSource.includes('&error.to_string()') +) { + throw new Error('desktop shell title module must hide native window errors'); +} +if ( + !desktopHostBridgeDispatchSource.includes( + 'show_desktop_local_notification(&app, &request)', + ) || + desktopHostBridgeDispatchSource.includes('"notification.showLocal" => match') || + desktopHostBridgeDispatchSource.includes('Ok(()) => ok(request.id, json!(true))') || + desktopHostBridgeDispatchSource.includes('app.notification()') || + desktopHostBridgeDispatchSource.includes('NotificationExt') || + desktopHostBridgeDispatchSource.includes('PermissionState') +) { + throw new Error( + 'desktop shell notification HostBridge method must delegate to notifications module', + ); +} +assertSnippetOrder( + extractFunctionBody(desktopHostBridgeNotificationsSource, 'show_desktop_local_notification'), + 'local_notification_payload(request)', + 'app.notification()', + 'desktop shell notification boundary', +); +for (const snippet of [ + 'tauri_plugin_notification::{NotificationExt, PermissionState}', + 'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION', + 'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH', + 'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH', + 'DesktopNotificationPermissionAction', + 'desktop_notification_permission_action', + 'notification_permission_unavailable_response', + 'notification_delivery_unavailable_response', + 'log_desktop_notification_failure', + 'desktop notification failed for {label}', + 'log_desktop_notification_failure("permission.state")', + 'log_desktop_notification_failure("permission.request")', + 'log_desktop_notification_failure("delivery.show")', + 'PermissionState::Granted', + 'PermissionState::Denied', + 'PermissionState::Prompt | PermissionState::PromptWithRationale', + 'desktop_notification_delivered_to_system_result', + 'json!({"action": HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION})', + 'local_notification_payload_truncates_to_shared_contract_limits', + 'notification_permission_unavailable_response_is_stable', + 'notification_delivery_unavailable_response_is_stable', + 'notification_failures_are_logged_without_exposing_native_detail', + 'notification_failures_log_stable_label_only', + '"a".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH)', + '"b".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH)', + '"notification permission unavailable"', + '"notification delivery unavailable"', + '"notification permission denied"', + 'app.notification().builder()', +]) { + if (!desktopHostBridgeNotificationsSource.includes(snippet)) { + throw new Error(`desktop shell notification module is missing ${snippet}`); + } +} +if ( + !desktopHostBridgeNotificationsSource.match( + /notification_manager\s*\.\s*permission_state\s*\(\s*\)/, + ) || + !desktopHostBridgeNotificationsSource.match( + /notification_manager\s*\.\s*request_permission\s*\(\s*\)/, + ) +) { + throw new Error( + 'desktop shell notification module must check and request notification permission', + ); +} +if ( + desktopHostBridgeNotificationsSource.includes('desktop notification failed for {label}: {error}') || + desktopHostBridgeNotificationsSource.includes('log_desktop_notification_failure("permission.state", &error.to_string())') || + desktopHostBridgeNotificationsSource.includes('log_desktop_notification_failure("permission.request", &error.to_string())') || + desktopHostBridgeNotificationsSource.includes('log_desktop_notification_failure("delivery.show", &error.to_string())') +) { + throw new Error('desktop shell notification module must hide native notification errors'); +} +if (!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,')) { + throw new Error('H5 HostBridge facade must import shared runtime refresh timeout'); +} +if (!h5HostBridgeSource.includes('HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,')) { + throw new Error('H5 HostBridge facade must import shared user interaction timeout'); +} +if (!h5HostBridgeSource.includes('HOST_BRIDGE_SCANNER_TIMEOUT_MS,')) { + throw new Error('H5 HostBridge facade must import shared scanner timeout'); +} +if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) { + throw new Error('H5 HostBridge facade must not redeclare runtime refresh timeout'); +} +if (h5HostBridgeSource.includes('timeoutMs: 30000')) { + throw new Error('H5 HostBridge facade must not redeclare user interaction timeout'); +} +if (h5HostBridgeSource.includes('timeoutMs: 60000')) { + throw new Error('H5 HostBridge facade must not redeclare scanner timeout'); +} +for (const snippet of [ + 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', + 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', + 'function createNativeHostBridgeTimeoutError()', + 'isHostBridgeEventName(candidate.event)', + 'async function invokeTauriHostBridgeWithTimeout', + 'Promise.race', + 'tauriInvoke>(HOST_BRIDGE_TAURI_COMMAND', + "message: 'host_bridge_timeout'", +]) { + if (!nativeAppHostBridgeSource.includes(snippet)) { + throw new Error(`H5 native app Tauri transport missing ${snippet}`); + } +} +for (const staleTimeoutBoundary of [ + 'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS', + 'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS', +]) { + if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) { + throw new Error( + `H5 native app transport must use shared HostBridge timeout boundary instead of ${staleTimeoutBoundary}`, + ); + } +} + +assertDesktopDialogBoundary( + 'file.exportText', + 'export_desktop_host_bridge_text_file', + 'Text', + ['txt', 'json', 'md', 'csv'], + 'save', +); +assertDesktopDialogBoundary( + 'file.importText', + 'import_desktop_host_bridge_text_file', + 'Text', + ['txt', 'md', 'markdown', 'csv', 'json'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.importDocument', + 'import_desktop_host_bridge_document_file', + 'Document', + ['txt', 'md', 'markdown', 'csv', 'json', 'docx'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.exportImage', + 'export_desktop_host_bridge_image_file', + 'Image', + ['png', 'jpg', 'jpeg', 'webp'], + 'save', +); +assertDesktopDialogBoundary( + 'file.importImage', + 'import_desktop_host_bridge_image_file', + 'Image', + ['png', 'jpg', 'jpeg', 'webp'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.importAudio', + 'import_desktop_host_bridge_audio_file', + 'Audio', + ['mp3', 'm4a', 'mp4', 'wav', 'ogg', 'webm'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.exportAudio', + 'export_desktop_host_bridge_audio_file', + 'Audio', + ['mp3', 'm4a', 'wav', 'ogg', 'webm'], + 'save', +); +assertDesktopFileExportPayloadOrder( + 'file.exportText', + 'export_desktop_host_bridge_text_file', + 'export_text_payload(request)', +); +assertDesktopFileExportPayloadOrder( + 'file.exportImage', + 'export_desktop_host_bridge_image_file', + 'export_image_payload(request)', +); +assertDesktopFileExportPayloadOrder( + 'file.exportAudio', + 'export_desktop_host_bridge_audio_file', + 'export_audio_payload(request)', +); +for (const snippet of [ + '.dialog()', + 'blocking_save_file', + 'blocking_pick_file', + 'export_text_payload', + 'import_text_file_payload', + 'import_document_file_payload', + 'export_image_payload', + 'import_image_file_payload', + 'import_audio_file_payload', + 'export_audio_payload', + 'write_export_text_file', + 'write_export_bytes_file', +]) { + if (desktopHostBridgeDispatchSource.includes(snippet)) { + throw new Error(`desktop shell dispatch must delegate file boundary instead of ${snippet}`); + } +} + +assertSameList( + capability.windows ?? [], + ['main'], + 'desktop shell capability windows', +); +assertOnlyMainCapabilityFile(); +if (capability.identifier !== 'main') { + throw new Error('desktop shell capability identifier must be main'); +} +if (String(capability.description ?? '').length === 0) { + throw new Error('desktop shell capability description must be explicit'); +} +assertSameList( + capability.permissions ?? [], + allowedPermissions, + 'desktop shell capability permissions', +); +for (const permission of capability.permissions ?? []) { + if (blockedCoreDefaultPermissions.includes(permission)) { + throw new Error(`desktop shell must not expose ${permission} to H5`); + } + if ( + blockedCorePermissionPrefixes.some((prefix) => permission.startsWith(prefix)) + ) { + throw new Error(`desktop shell must not expose Tauri core permission ${permission} to H5`); + } + if ( + blockedPluginPermissionPrefixes.some((prefix) => permission.startsWith(prefix)) + ) { + throw new Error(`desktop shell must not expose plugin permission ${permission} to H5`); + } +} +assertSameList( + extractTauriBuildCommands(buildScript), + allowedTauriCommands, + 'desktop shell build manifest commands', +); +assertSameList( + extractTauriInvokeCommands(app), + allowedTauriCommands, + 'desktop shell invoke handler commands', +); +assertGeneratedPermissions(allowedTauriCommands); +assertNoTrackedDesktopGeneratedTauriFiles(); + +if (buildScript.includes('resolve_desktop_shell_runtime')) { + throw new Error('desktop shell build manifest exposes an unused runtime command'); +} + +if (!cargoManifest.includes('features = ["tray-icon"]')) { + throw new Error('desktop shell must enable the Tauri tray-icon feature'); +} + +if (cargoManifest.includes('"devtools"')) { + throw new Error('desktop shell must not enable Tauri devtools feature'); +} + +if ( + !cargoManifest.includes( + 'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }', + ) +) { + throw new Error('desktop shell must depend on tauri-plugin-single-instance with deep-link feature'); +} + +if (!cargoManifest.includes('tauri-plugin-deep-link = "2.4.9"')) { + throw new Error('desktop shell must depend on tauri-plugin-deep-link'); +} + +if (!cargoManifest.includes('tauri-plugin-window-state = "2.4.1"')) { + throw new Error('desktop shell must depend on tauri-plugin-window-state'); +} + +if ( + !config.plugins || + !config.plugins['deep-link'] || + JSON.stringify(config.plugins['deep-link']?.desktop?.schemes ?? []) !== + JSON.stringify(['genarrative']) +) { + throw new Error('desktop shell deep-link plugin must register only the genarrative scheme'); +} + +if ( + cargoManifest.includes('tauri-plugin-updater') || + JSON.stringify(config).includes('updater') +) { + throw new Error('desktop shell must not configure updater without real signing and endpoint'); +} + +const orderedDesktopPluginSnippets = [ + 'tauri_plugin_single_instance::init', + 'desktop_window_state_plugin()', + 'tauri_plugin_deep_link::init()', + 'tauri_plugin_clipboard_manager::init()', + 'tauri_plugin_dialog::init()', + 'tauri_plugin_notification::init()', + 'tauri_plugin_opener::init()', +]; +for (const pluginSnippet of orderedDesktopPluginSnippets) { + if (!app.includes(pluginSnippet)) { + throw new Error(`desktop shell app.rs missing plugin ${pluginSnippet}`); + } +} +for (let index = 1; index < orderedDesktopPluginSnippets.length; index += 1) { + const previousPlugin = orderedDesktopPluginSnippets[index - 1]; + const currentPlugin = orderedDesktopPluginSnippets[index]; + if (app.indexOf(previousPlugin) > app.indexOf(currentPlugin)) { + throw new Error( + `desktop shell plugin order drifted: ${previousPlugin} must be registered before ${currentPlugin}`, + ); + } +} + +if (app.includes('single-instance",') || app.includes('"single-instance"')) { + throw new Error('desktop shell must not emit secondary-instance argv to H5'); +} + +if (!main.includes('mod app;') || !main.includes('app::run();')) { + throw new Error('desktop shell main.rs must stay a thin app entrypoint'); +} + +for (const entrypointOnlySnippet of [ + 'tauri::Builder::default()', + 'tauri::generate_handler!', + 'WebviewWindowBuilder::from_config', +]) { + if (main.includes(entrypointOnlySnippet)) { + throw new Error( + `desktop shell main.rs must not own Tauri app setup: ${entrypointOnlySnippet}`, + ); + } +} + +for (const appSetupSnippet of [ + 'tauri::Builder::default()', + 'crate::host_bridge::host_bridge_request', + 'desktop_main_window_config(app)?', + 'WebviewWindowBuilder::from_config', + 'desktop_new_window_external_url(&url)', + 'desktop_new_window_response', + 'register_desktop_navigation_events(&window)?', + 'log_desktop_host_event_result(', +]) { + if (!app.includes(appSetupSnippet)) { + throw new Error(`desktop shell app.rs missing setup snippet ${appSetupSnippet}`); + } +} + +for (const blockedAppSetupSnippet of [ + 'app.config().app.windows.get(0)', + 'app.config().app.windows[0]', + 'if let Some(config) = window_config', + 'register_desktop_network_events(&window)', + 'let _ = register_desktop_network_events(&window)', + 'let _ = register_desktop_navigation_events(&window)', + 'let _ = emit_current_desktop_lifecycle_event(&window)', +]) { + if (app.includes(blockedAppSetupSnippet)) { + throw new Error( + `desktop shell app.rs must resolve the labeled main window before startup, not ${blockedAppSetupSnippet}`, + ); + } +} + +for (const blockedLifecycleSnippet of [ + 'let _ = register_desktop_network_events(window)', + 'let _ = register_desktop_navigation_events(window)', + 'let _ = emit_current_desktop_lifecycle_event(window)', + 'let _ = emit_current_desktop_lifecycle_event(&lifecycle_window)', + 'let _ = emit_desktop_image_drop_event(&drop_window', + '.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())', + 'window.is_visible().unwrap_or(', + 'window.is_minimized().unwrap_or(', + 'window.is_focused().unwrap_or(', + 'let _ = app.deep_link().register_all()', + 'if let Ok(Some(urls)) = app.deep_link().get_current()', + 'let _ = window.navigate(target_url)', + 'let _ = show_main_window(window.app_handle())', + 'let _ = show_main_window(app)', + 'let _ = eval_main_window_history_back(app)', + 'let _ = eval_main_window_history_forward(app)', + 'let _ = reload_main_window(app)', + 'let _ = open_normalized_desktop_external_url(', + 'let _ = emit_desktop_lifecycle_event(&close_window', + 'let _ = close_window.hide()', + 'catch (_error) {}', + 'catch (error) {}', + '.to_socket_addrs().ok()', + 'TcpStream::connect_timeout(&address, timeout)\n .map(', +]) { + if (rustHostSource.includes(blockedLifecycleSnippet)) { + throw new Error( + `desktop shell WebView state replay failures must be logged or propagated, not ${blockedLifecycleSnippet}`, + ); + } +} + +for (const snippet of requiredRustHostSnippets) { + if (!rustHostSource.includes(snippet)) { + throw new Error(`desktop shell Rust host bridge missing ${snippet}`); + } +} + +for (const snippet of [ + 'register_desktop_deep_link_schemes(app: &tauri::App) -> bool', + 'log_desktop_deep_link_register_result(app.deep_link().register_all())', + 'desktop host event failed for deep_link.register', + 'desktop_deep_link_register_result_reports_success_and_failure', + 'desktop_deep_link_register_result_logs_stable_label_only', + 'log_desktop_deep_link_current_result(app.deep_link().get_current())', + 'desktop host event failed for deep_link.current', + 'desktop_deep_link_current_result_reports_success_empty_and_failure', + 'desktop_deep_link_current_result_logs_stable_label_only', + 'log_desktop_deep_link_window_missing("open")', + 'log_desktop_deep_link_window_missing("current")', + 'desktop_deep_link_window_missing_reports_failure', +]) { + if (!desktopShellDeepLinkSource.includes(snippet)) { + throw new Error(`desktop shell deep-link registration logging missing ${snippet}`); + } +} + +if ( + desktopShellDeepLinkSource.includes( + 'desktop host event failed for deep_link.register: {error}', + ) || + desktopShellDeepLinkSource.includes( + 'desktop host event failed for deep_link.current: {error}', + ) +) { + throw new Error('desktop deep-link diagnostics must not log plugin error details'); +} + +if (app.includes('desktop tray registration failed: {error}')) { + throw new Error('desktop tray registration diagnostics must not log Tauri error details'); +} + +if ( + desktopShellLifecycleSource.includes( + 'desktop host event failed for app.lifecycle.{label}: {error}', + ) || + desktopShellLifecycleSource.includes( + 'desktop host event failed for {label}: {error}', + ) +) { + throw new Error('desktop lifecycle diagnostics must not log Tauri error details'); +} + +if ( + rustHostSource.includes( + 'desktop host event failed for file.imageDropped.payload: {error}', + ) || + rustHostSource.includes('log_desktop_image_drop_payload_failure(&error)') +) { + throw new Error('desktop image drop diagnostics must not log payload error details'); +} diff --git a/apps/desktop-shell/scripts/stage-release-binary.mjs b/apps/desktop-shell/scripts/stage-release-binary.mjs new file mode 100644 index 000000000..13ce5bac8 --- /dev/null +++ b/apps/desktop-shell/scripts/stage-release-binary.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(new URL('../../../', import.meta.url).pathname); +const releaseDir = path.join( + repoRoot, + 'apps', + 'desktop-shell', + 'src-tauri', + 'target', + 'release', +); +const executableName = + process.platform === 'win32' + ? 'genarrative-desktop-shell.exe' + : 'genarrative-desktop-shell'; +const sourcePath = path.join(releaseDir, executableName); +const stagedDir = path.join(repoRoot, 'build', 'native', 'desktop'); +const stagedPath = path.join(stagedDir, executableName); + +function assertExecutable(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new Error(`desktop release binary is missing: ${filePath}`); + } + + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size < 1024 * 1024) { + throw new Error(`${label} must be a real non-empty executable file`); + } + + const handle = fs.openSync(filePath, 'r'); + try { + const header = Buffer.alloc(8); + fs.readSync(handle, header, 0, header.length, 0); + + if (process.platform === 'linux') { + const isElf = + header[0] === 0x7f && + header[1] === 0x45 && + header[2] === 0x4c && + header[3] === 0x46; + if (!isElf || (stat.mode & 0o111) === 0) { + throw new Error(`${label} must be an executable ELF file`); + } + return; + } + + if (process.platform === 'darwin') { + const machMagic = header.readUInt32BE(0); + const isMachO = + machMagic === 0xcafebabe || + machMagic === 0xcafed00d || + machMagic === 0xfeedface || + machMagic === 0xfeedfacf; + if (!isMachO || (stat.mode & 0o111) === 0) { + throw new Error(`${label} must be an executable Mach-O file`); + } + return; + } + + if (process.platform === 'win32') { + if (header[0] !== 0x4d || header[1] !== 0x5a) { + throw new Error(`${label} must be a PE executable`); + } + } + } finally { + fs.closeSync(handle); + } +} + +assertExecutable(sourcePath, 'desktop release binary'); +fs.mkdirSync(stagedDir, {recursive: true}); +fs.copyFileSync(sourcePath, stagedPath); +const sourceMode = fs.statSync(sourcePath).mode; +fs.chmodSync(stagedPath, sourceMode & 0o777); +assertExecutable(stagedPath, 'staged desktop release binary'); + +console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`); diff --git a/apps/desktop-shell/src-tauri/Cargo.lock b/apps/desktop-shell/src-tauri/Cargo.lock new file mode 100644 index 000000000..a6eceb5c7 --- /dev/null +++ b/apps/desktop-shell/src-tauri/Cargo.lock @@ -0,0 +1,5800 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "genarrative-desktop-shell" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-window-state", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.13.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.118", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/apps/desktop-shell/src-tauri/Cargo.toml b/apps/desktop-shell/src-tauri/Cargo.toml new file mode 100644 index 000000000..d1877effa --- /dev/null +++ b/apps/desktop-shell/src-tauri/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "genarrative-desktop-shell" +version = "0.1.0" +edition = "2021" +publish = false + +[build-dependencies] +tauri-build = { version = "2.6.2", features = [] } + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2.11.2", features = ["tray-icon"] } +tauri-plugin-clipboard-manager = "2.3.2" +tauri-plugin-deep-link = "2.4.9" +tauri-plugin-dialog = "2.7.1" +tauri-plugin-notification = "2.3.3" +tauri-plugin-opener = "2.5.4" +tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } +tauri-plugin-window-state = "2.4.1" diff --git a/apps/desktop-shell/src-tauri/Info.plist b/apps/desktop-shell/src-tauri/Info.plist new file mode 100644 index 000000000..df41e8a70 --- /dev/null +++ b/apps/desktop-shell/src-tauri/Info.plist @@ -0,0 +1,10 @@ + + + + + NSCameraUsageDescription + 允许 Genarrative 使用摄像头运行需要实时动作输入的同源 H5 体验。 + NSMicrophoneUsageDescription + 允许 Genarrative 使用麦克风运行需要实时声音输入的同源 H5 玩法。 + + diff --git a/apps/desktop-shell/src-tauri/build.rs b/apps/desktop-shell/src-tauri/build.rs new file mode 100644 index 000000000..45d845fd2 --- /dev/null +++ b/apps/desktop-shell/src-tauri/build.rs @@ -0,0 +1,5 @@ +fn main() { + let app_manifest = tauri_build::AppManifest::new().commands(&["host_bridge_request"]); + tauri_build::try_build(tauri_build::Attributes::new().app_manifest(app_manifest)) + .expect("failed to run Tauri build script"); +} diff --git a/apps/desktop-shell/src-tauri/capabilities/main.json b/apps/desktop-shell/src-tauri/capabilities/main.json new file mode 100644 index 000000000..c0b6ea3f4 --- /dev/null +++ b/apps/desktop-shell/src-tauri/capabilities/main.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main", + "description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。", + "windows": ["main"], + "permissions": [ + "allow-host-bridge-request" + ] +} diff --git a/apps/desktop-shell/src-tauri/icons/128x128.png b/apps/desktop-shell/src-tauri/icons/128x128.png new file mode 100644 index 000000000..1d09353bb Binary files /dev/null and b/apps/desktop-shell/src-tauri/icons/128x128.png differ diff --git a/apps/desktop-shell/src-tauri/icons/128x128@2x.png b/apps/desktop-shell/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..a301280b5 Binary files /dev/null and b/apps/desktop-shell/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop-shell/src-tauri/icons/32x32.png b/apps/desktop-shell/src-tauri/icons/32x32.png new file mode 100644 index 000000000..ed8cd0c4b Binary files /dev/null and b/apps/desktop-shell/src-tauri/icons/32x32.png differ diff --git a/apps/desktop-shell/src-tauri/icons/icon.icns b/apps/desktop-shell/src-tauri/icons/icon.icns new file mode 100644 index 000000000..e73546ded Binary files /dev/null and b/apps/desktop-shell/src-tauri/icons/icon.icns differ diff --git a/apps/desktop-shell/src-tauri/icons/icon.ico b/apps/desktop-shell/src-tauri/icons/icon.ico new file mode 100644 index 000000000..735dbd072 Binary files /dev/null and b/apps/desktop-shell/src-tauri/icons/icon.ico differ diff --git a/apps/desktop-shell/src-tauri/icons/icon.png b/apps/desktop-shell/src-tauri/icons/icon.png new file mode 100644 index 000000000..4aa1f7d8b Binary files /dev/null and b/apps/desktop-shell/src-tauri/icons/icon.png differ diff --git a/apps/desktop-shell/src-tauri/src/app.rs b/apps/desktop-shell/src-tauri/src/app.rs new file mode 100644 index 000000000..b5d54f4e3 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/app.rs @@ -0,0 +1,241 @@ +use crate::host_bridge::{DesktopShareState, HostBridgeReplayState}; +use crate::shell::deep_link::{ + register_desktop_deep_link_events, register_desktop_deep_link_schemes, +}; +use crate::shell::tray::{ + register_desktop_tray, register_desktop_window_close_events, + resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction, +}; +use crate::shell::webview::{ + desktop_external_navigation_url, desktop_window_config_with_runtime_platform, + emit_current_desktop_lifecycle_event, handle_desktop_webview_download, + log_desktop_host_event_result, open_desktop_external_navigation, + open_normalized_desktop_external_url, register_desktop_file_drop_events, + register_desktop_lifecycle_events, register_desktop_navigation_events, + replay_desktop_webview_state, should_allow_desktop_webview_navigation, + should_replay_desktop_webview_state_on_page_load, +}; +use crate::shell::window_state::desktop_window_state_plugin; +use tauri::webview::NewWindowResponse; + +const DESKTOP_MAIN_WINDOW_LABEL: &str = "main"; + +fn desktop_main_window_config_from_windows( + windows: &[tauri::utils::config::WindowConfig], +) -> tauri::Result { + windows + .iter() + .find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL) + .cloned() + .map(desktop_window_config_with_runtime_platform) + .ok_or(tauri::Error::WindowNotFound) +} + +#[cfg(dev)] +fn apply_desktop_dev_url_to_main_window_config( + mut config: tauri::utils::config::WindowConfig, + dev_url: &tauri::Url, +) -> tauri::utils::config::WindowConfig { + config.url = tauri::WebviewUrl::External(dev_url.clone()); + desktop_window_config_with_runtime_platform(config) +} + +fn desktop_main_window_config( + app: &tauri::App, +) -> tauri::Result { + let config = desktop_main_window_config_from_windows(&app.config().app.windows)?; + #[cfg(dev)] + if let Some(dev_url) = app.config().build.dev_url.as_ref() { + return Ok(apply_desktop_dev_url_to_main_window_config(config, dev_url)); + } + Ok(config) +} + +fn desktop_new_window_external_url(url: &tauri::Url) -> Option { + desktop_external_navigation_url(url) +} + +fn desktop_new_window_response() -> NewWindowResponse { + NewWindowResponse::Deny +} + +pub(crate) fn run() { + tauri::Builder::default() + .manage(DesktopShareState::default()) + .manage(HostBridgeReplayState::default()) + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + match resolve_desktop_single_instance_action() { + DesktopSingleInstanceAction::ShowMainWindow => { + log_desktop_host_event_result("single_instance.show", show_main_window(app)); + } + } + })) + .plugin(desktop_window_state_plugin()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .setup(|app| { + let tray_registered = match register_desktop_tray(app) { + Ok(()) => true, + Err(_error) => { + eprintln!("desktop tray registration failed"); + false + } + }; + let config = desktop_main_window_config(app)?; + let app_handle = app.handle().clone(); + let new_window_app_handle = app.handle().clone(); + let window = tauri::WebviewWindowBuilder::from_config(app.handle(), &config)? + .on_navigation(move |url| { + if should_allow_desktop_webview_navigation(url) { + true + } else { + open_desktop_external_navigation(&app_handle, url); + false + } + }) + .on_new_window(move |url, _features| { + if let Some(external_url) = desktop_new_window_external_url(&url) { + log_desktop_host_event_result( + "webview.new_window.external", + open_normalized_desktop_external_url( + &new_window_app_handle, + external_url, + ), + ); + } + desktop_new_window_response::() + }) + .on_page_load(|window, payload| { + if should_replay_desktop_webview_state_on_page_load(payload.event()) { + replay_desktop_webview_state(&window); + } + }) + .on_download(|_webview, event| handle_desktop_webview_download(&event)) + .build()?; + register_desktop_window_close_events(&window, tray_registered); + register_desktop_lifecycle_events(&window); + log_desktop_host_event_result( + "app.lifecycle", + emit_current_desktop_lifecycle_event(&window), + ); + register_desktop_navigation_events(&window)?; + register_desktop_file_drop_events(&window); + register_desktop_deep_link_events(app)?; + register_desktop_deep_link_schemes(app); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + crate::host_bridge::host_bridge_request + ]) + .run(tauri::generate_context!()) + .expect("failed to run Genarrative desktop shell"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn desktop_main_window_config_uses_labeled_main_window() { + let mut secondary_window = tauri::utils::config::WindowConfig::default(); + secondary_window.label = "secondary".to_string(); + secondary_window.url = tauri::WebviewUrl::App(PathBuf::from("secondary.html")); + + let mut main_window = tauri::utils::config::WindowConfig::default(); + main_window.label = "main".to_string(); + main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html")); + + let config = desktop_main_window_config_from_windows(&[secondary_window, main_window]) + .expect("main window config"); + + assert_eq!(config.label, "main"); + match config.url { + tauri::WebviewUrl::App(path) => { + let path = path.to_string_lossy(); + assert!(path.starts_with("index.html?")); + assert!(path.contains("clientRuntime=native_app")); + assert!(path.contains("clientType=native_app")); + assert!(path.contains("hostShell=tauri_desktop")); + assert!(path.contains("hostPlatform=")); + assert!(path.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION")))); + assert!(path.contains("bridgeVersion=")); + assert!(path.contains("hostCapabilities=")); + } + other => panic!("unexpected main window url {other:?}"), + } + } + + #[cfg(dev)] + #[test] + fn desktop_main_window_config_uses_dev_url_in_dev_builds() { + let mut main_window = tauri::utils::config::WindowConfig::default(); + main_window.label = "main".to_string(); + main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html")); + + let mut config = tauri::utils::config::Config::default(); + config.app.windows = vec![main_window]; + config.build.dev_url = + Some(tauri::Url::parse("http://127.0.0.1:3000/").expect("desktop dev url")); + + let mut resolved = desktop_main_window_config_from_windows(&config.app.windows) + .expect("main window config"); + if let Some(dev_url) = config.build.dev_url.as_ref() { + resolved = apply_desktop_dev_url_to_main_window_config(resolved, dev_url); + } + + match resolved.url { + tauri::WebviewUrl::External(url) => { + assert_eq!(url.origin().ascii_serialization(), "http://127.0.0.1:3000"); + assert_eq!(url.path(), "/"); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "hostShell") + .map(|(_, value)| value.into_owned()), + Some("tauri_desktop".to_string()) + ); + } + other => panic!("unexpected dev main window url {other:?}"), + } + } + + #[test] + fn desktop_main_window_config_rejects_missing_main_window() { + let mut secondary_window = tauri::utils::config::WindowConfig::default(); + secondary_window.label = "secondary".to_string(); + + let error = desktop_main_window_config_from_windows(&[secondary_window]) + .expect_err("missing main window should fail startup"); + + assert!(matches!(error, tauri::Error::WindowNotFound)); + } + + #[test] + fn desktop_new_window_policy_denies_embedded_windows() { + assert!(matches!( + desktop_new_window_response::(), + NewWindowResponse::Deny + )); + } + + #[test] + fn desktop_new_window_policy_opens_only_safe_external_urls() { + let external_url = tauri::Url::parse("https://example.com/share") + .expect("external desktop new window url"); + assert_eq!( + desktop_new_window_external_url(&external_url), + Some("https://example.com/share".to_string()) + ); + + let same_origin_url = tauri::Url::parse("https://www.genarrative.world/works/detail") + .expect("same-origin desktop new window url"); + assert_eq!(desktop_new_window_external_url(&same_origin_url), None); + + let unsafe_url = + tauri::Url::parse("javascript:alert(1)").expect("unsafe desktop new window url"); + assert_eq!(desktop_new_window_external_url(&unsafe_url), None); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs b/apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs new file mode 100644 index 000000000..11e4238b2 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs @@ -0,0 +1,81 @@ +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use crate::shell::webview::color_scheme_from_theme; +use serde_json::json; +use tauri::Manager; + +pub(crate) fn desktop_appearance_color_scheme( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + match app.get_webview_window("main") { + Some(window) => match window.theme() { + Ok(theme) => ok_color_scheme(request, color_scheme_from_theme(theme)), + Err(_error) => { + log_desktop_appearance_failure("theme"); + appearance_unavailable_response(request) + } + }, + None => { + log_desktop_appearance_failure("window"); + appearance_unavailable_response(request) + } + } +} + +fn ok_color_scheme(request: &HostBridgeRequest, color_scheme: &'static str) -> HostBridgeResponse { + ok( + request.id.clone(), + json!({ + "colorScheme": color_scheme + }), + ) +} + +fn appearance_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "appearance unavailable") +} + +fn log_desktop_appearance_failure(label: &str) -> bool { + eprintln!("desktop appearance failed for {label}"); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + #[test] + fn ok_color_scheme_reports_contract_shape() { + let response = ok_color_scheme(&request("appearance.getColorScheme"), "dark"); + + assert!(response.ok); + assert_eq!( + response.result.expect("appearance result"), + json!({ + "colorScheme": "dark" + }) + ); + } + + #[test] + fn appearance_unavailable_response_is_stable() { + let response = appearance_unavailable_response(&request("appearance.getColorScheme")); + + assert!(!response.ok); + let error = response.error.expect("appearance error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "appearance unavailable"); + } + + #[test] + fn appearance_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_appearance_failure("theme")); + assert!(!log_desktop_appearance_failure("window")); + } + + #[test] + fn appearance_failures_log_stable_label_only() { + assert!(!log_desktop_appearance_failure("theme")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs b/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs new file mode 100644 index 000000000..f39c422d5 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs @@ -0,0 +1,117 @@ +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use serde_json::json; +use serde_json::Value; +use tauri::Manager; + +const BADGE_COUNT_MAX: i64 = 99999; + +fn badge_count_payload(request: &HostBridgeRequest) -> Result, HostBridgeResponse> { + let count = request + .payload + .as_ref() + .and_then(|value| value.get("count")) + .and_then(Value::as_i64) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "count must be an integer between 0 and 99999", + ) + })?; + + if !(0..=BADGE_COUNT_MAX).contains(&count) { + return Err(failed( + request.id.clone(), + "invalid_request", + "count must be an integer between 0 and 99999", + )); + } + + Ok(if count == 0 { None } else { Some(count) }) +} + +pub(crate) fn set_desktop_app_badge_count( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let count = match badge_count_payload(request) { + Ok(count) => count, + Err(response) => return response, + }; + let Some(window) = app.get_webview_window("main") else { + log_desktop_badge_failure("window"); + return badge_unavailable_response(request); + }; + + match window.set_badge_count(count) { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(_error) => { + log_desktop_badge_failure("set"); + badge_unavailable_response(request) + } + } +} + +fn badge_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "badge unavailable") +} + +fn log_desktop_badge_failure(label: &str) -> bool { + eprintln!("desktop app badge failed for {label}"); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + #[test] + fn badge_count_payload_accepts_clear_and_positive_counts() { + let mut clear = request("app.setBadgeCount"); + clear.payload = Some(json!({ "count": 0 })); + assert_eq!(badge_count_payload(&clear).expect("clear badge"), None); + + let mut count = request("app.setBadgeCount"); + count.payload = Some(json!({ "count": 12 })); + assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12)); + } + + #[test] + fn badge_count_payload_rejects_invalid_counts() { + for count in [json!(-1), json!(1.5), json!(100000), json!("1")] { + let mut invalid = request("app.setBadgeCount"); + invalid.payload = Some(json!({ "count": count })); + + let response = badge_count_payload(&invalid).expect_err("invalid count"); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!( + error.message, + "count must be an integer between 0 and 99999" + ); + } + } + + #[test] + fn badge_unavailable_response_is_stable() { + let response = badge_unavailable_response(&request("app.setBadgeCount")); + + assert!(!response.ok); + let error = response.error.expect("badge error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "badge unavailable"); + } + + #[test] + fn badge_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_badge_failure("set")); + assert!(!log_desktop_badge_failure("window")); + } + + #[test] + fn badge_failures_log_stable_label_only() { + assert!(!log_desktop_badge_failure("set")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs new file mode 100644 index 000000000..aed005368 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs @@ -0,0 +1,116 @@ +pub(crate) fn capabilities() -> Vec<&'static str> { + vec![ + "host.getRuntime", + "appearance.getColorScheme", + "host.events", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "navigation.canGoBack", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.importDocument", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::collections::HashSet; + + #[test] + fn runtime_capability_list_stays_ordered() { + assert_eq!( + capabilities(), + vec![ + "host.getRuntime", + "appearance.getColorScheme", + "host.events", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "navigation.canGoBack", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.importDocument", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] + ); + assert!(Value::from(capabilities()).as_array().is_some()); + } + + #[test] + fn runtime_capability_list_keeps_desktop_boundaries() { + let desktop_capabilities = capabilities(); + let unique_capabilities = desktop_capabilities.iter().copied().collect::>(); + + assert_eq!(unique_capabilities.len(), desktop_capabilities.len()); + + for capability in [ + "host.getRuntime", + "appearance.getColorScheme", + "host.events", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "navigation.canGoBack", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.importDocument", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] { + assert!(desktop_capabilities.contains(&capability)); + } + + for capability in [ + "auth.requestLogin", + "payment.request", + "file.captureImage", + "scanner.scanQrCode", + "haptics.impact", + ] { + assert!(!desktop_capabilities.contains(&capability)); + } + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs b/apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs new file mode 100644 index 000000000..7d9b53dfb --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs @@ -0,0 +1,165 @@ +use tauri_plugin_clipboard_manager::ClipboardExt; + +use crate::host_bridge::protocol::{ + failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse, +}; +use serde_json::json; + +pub(crate) const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; + +pub(crate) fn normalize_clipboard_text(text: &str) -> String { + text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect() +} + +pub(crate) fn write_desktop_clipboard_text( + app: &tauri::AppHandle, + text: &str, +) -> Result<(), String> { + app.clipboard() + .write_text(normalize_clipboard_text(text)) + .map_err(|error| error.to_string()) +} + +pub(crate) fn read_desktop_clipboard_text(app: &tauri::AppHandle) -> Result { + app.clipboard() + .read_text() + .map(|text| normalize_clipboard_text(&text)) + .map_err(|error| error.to_string()) +} + +fn log_desktop_clipboard_failure(label: &str) -> bool { + eprintln!("desktop clipboard failed for {label}"); + false +} + +pub(crate) fn clipboard_write_unavailable_response( + request: &HostBridgeRequest, +) -> HostBridgeResponse { + failed( + request.id.clone(), + "host_error", + "clipboard write unavailable", + ) +} + +pub(crate) fn clipboard_read_unavailable_response( + request: &HostBridgeRequest, +) -> HostBridgeResponse { + failed( + request.id.clone(), + "host_error", + "clipboard read unavailable", + ) +} + +pub(crate) fn write_desktop_host_bridge_clipboard_text( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let text = match required_string_payload(request, "text") { + Ok(text) => text, + Err(response) => return response, + }; + + match write_desktop_clipboard_text(app, text) { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(_error) => { + log_desktop_clipboard_failure("write.text"); + clipboard_write_unavailable_response(request) + } + } +} + +pub(crate) fn read_desktop_host_bridge_clipboard_text( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + match read_desktop_clipboard_text(app) { + Ok(text) => ok( + request.id.clone(), + json!({ + "text": text, + }), + ), + Err(_error) => { + log_desktop_clipboard_failure("read.text"); + clipboard_read_unavailable_response(request) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clipboard_text_is_truncated_to_contract_limit() { + assert_eq!(normalize_clipboard_text("作品号 PZ-1"), "作品号 PZ-1"); + assert_eq!( + normalize_clipboard_text(&"a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), + CLIPBOARD_TEXT_MAX_LENGTH + ); + + let unicode_text = "猫".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10); + let truncated = normalize_clipboard_text(&unicode_text); + assert_eq!(truncated.chars().count(), CLIPBOARD_TEXT_MAX_LENGTH); + assert!(truncated.ends_with('猫')); + } + + #[test] + fn clipboard_read_response_reports_contract_shape() { + let response = ok( + "clipboard-read".to_string(), + json!({ + "text": normalize_clipboard_text("邀请码 GEN-1") + }), + ); + + assert!(response.ok); + assert_eq!( + response.result.expect("clipboard result"), + json!({ + "text": "邀请码 GEN-1" + }) + ); + } + + #[test] + fn clipboard_unavailable_responses_are_stable() { + let write_response = clipboard_write_unavailable_response(&HostBridgeRequest { + bridge: "GenarrativeHostBridge".to_string(), + version: 1, + id: "clipboard-write".to_string(), + method: "clipboard.writeText".to_string(), + payload: None, + }); + let read_response = clipboard_read_unavailable_response(&HostBridgeRequest { + bridge: "GenarrativeHostBridge".to_string(), + version: 1, + id: "clipboard-read".to_string(), + method: "clipboard.readText".to_string(), + payload: None, + }); + + assert!(!write_response.ok); + let write_error = write_response.error.expect("clipboard write error"); + assert_eq!(write_error.code, "host_error"); + assert_eq!(write_error.message, "clipboard write unavailable"); + + assert!(!read_response.ok); + let read_error = read_response.error.expect("clipboard read error"); + assert_eq!(read_error.code, "host_error"); + assert_eq!(read_error.message, "clipboard read unavailable"); + } + + #[test] + fn clipboard_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_clipboard_failure("write.text")); + assert!(!log_desktop_clipboard_failure("read.text")); + } + + #[test] + fn clipboard_failures_log_stable_label_only() { + assert!(!log_desktop_clipboard_failure("write.text")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs new file mode 100644 index 000000000..0c49849ee --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -0,0 +1,110 @@ +use crate::host_bridge::appearance::desktop_appearance_color_scheme; +use crate::host_bridge::badge::set_desktop_app_badge_count; +use crate::host_bridge::clipboard::{ + read_desktop_host_bridge_clipboard_text, write_desktop_host_bridge_clipboard_text, +}; +use crate::host_bridge::files::{ + export_desktop_host_bridge_audio_file, export_desktop_host_bridge_image_file, + export_desktop_host_bridge_text_file, import_desktop_host_bridge_audio_file, + import_desktop_host_bridge_document_file, import_desktop_host_bridge_image_file, + import_desktop_host_bridge_text_file, +}; +use crate::host_bridge::navigation::{ + open_desktop_host_bridge_external_url, open_desktop_host_bridge_native_page, + reload_desktop_host_bridge_webview, +}; +use crate::host_bridge::network::resolve_desktop_host_bridge_network_status; +use crate::host_bridge::notifications::show_desktop_local_notification; +use crate::host_bridge::protocol::{ + failed, validate_request, HostBridgeRequest, HostBridgeResponse, +}; +use crate::host_bridge::runtime::desktop_host_bridge_runtime_response; +use crate::host_bridge::share::{ + open_desktop_host_bridge_share, set_desktop_host_bridge_share_target, +}; +use crate::host_bridge::title::set_desktop_host_bridge_window_title; + +pub(crate) fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse { + if let Some(response) = validate_request(&request) { + return response; + } + + match request.method.as_str() { + "host.getRuntime" => desktop_host_bridge_runtime_response(&request), + _ => failed( + request.id, + "unsupported_method", + format!("{} unsupported in desktop shell", request.method), + ), + } +} + +pub(super) async fn execute_host_bridge_request( + app: tauri::AppHandle, + request: HostBridgeRequest, +) -> HostBridgeResponse { + if let Some(response) = validate_request(&request) { + return response; + } + + match request.method.as_str() { + "app.openExternalUrl" => open_desktop_host_bridge_external_url(&app, &request), + "appearance.getColorScheme" => desktop_appearance_color_scheme(&app, &request), + "navigation.openNativePage" => open_desktop_host_bridge_native_page(&app, &request), + "app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request), + "clipboard.writeText" => write_desktop_host_bridge_clipboard_text(&app, &request), + "clipboard.readText" => read_desktop_host_bridge_clipboard_text(&app, &request), + "file.exportText" => export_desktop_host_bridge_text_file(&app, &request).await, + "file.importText" => import_desktop_host_bridge_text_file(&app, &request).await, + "file.importDocument" => import_desktop_host_bridge_document_file(&app, &request).await, + "file.exportImage" => export_desktop_host_bridge_image_file(&app, &request).await, + "file.importImage" => import_desktop_host_bridge_image_file(&app, &request).await, + "file.importAudio" => import_desktop_host_bridge_audio_file(&app, &request).await, + "file.exportAudio" => export_desktop_host_bridge_audio_file(&app, &request).await, + "app.setTitle" => set_desktop_host_bridge_window_title(&app, &request), + "app.setBadgeCount" => set_desktop_app_badge_count(&app, &request), + "network.status" => resolve_desktop_host_bridge_network_status(&request).await, + "notification.showLocal" => show_desktop_local_notification(&app, &request), + "share.setTarget" => set_desktop_host_bridge_share_target(&app, &request), + "share.open" => open_desktop_host_bridge_share(&app, &request), + _ => resolve_host_bridge_request(request), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::capabilities::capabilities; + use crate::host_bridge::protocol::HOST_BRIDGE_METHODS; + use crate::host_bridge::protocol::request; + + #[test] + fn unsupported_method_is_explicit() { + let desktop_capabilities = capabilities(); + let unsupported_methods = HOST_BRIDGE_METHODS + .iter() + .copied() + .filter(|method| !desktop_capabilities.contains(method)) + .collect::>(); + + assert_eq!( + unsupported_methods, + vec![ + "auth.requestLogin", + "payment.request", + "file.captureImage", + "scanner.scanQrCode", + "haptics.impact", + ], + ); + + for method in unsupported_methods { + let response = resolve_host_bridge_request(request(method)); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "unsupported_method"); + assert!(error.message.contains(method)); + } + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs b/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs new file mode 100644 index 000000000..d442898fc --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs @@ -0,0 +1,1172 @@ +use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; +const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; +const EXPORT_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024; +pub(crate) const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024; +const IMPORT_DOCUMENT_MAX_BYTES: u64 = 5 * 1024 * 1024; +const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024; +const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 1024 * 1024; +const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; +const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ImportFilePayloadError { + InvalidRequest(&'static str), + NativeRead, +} + +impl ImportFilePayloadError { + pub(crate) fn invalid_message(&self) -> Option<&'static str> { + match self { + Self::InvalidRequest(message) => Some(message), + Self::NativeRead => None, + } + } +} + +pub(crate) fn normalize_export_file_name(raw_file_name: &str) -> String { + let mut file_name = String::new(); + let mut last_was_space = false; + + for character in raw_file_name + .trim() + .chars() + .take(EXPORT_FILE_NAME_MAX_LENGTH) + { + if character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + { + file_name.push('-'); + last_was_space = false; + continue; + } + + if character.is_whitespace() { + if !last_was_space { + file_name.push(' '); + last_was_space = true; + } + continue; + } + + file_name.push(character); + last_was_space = false; + } + + let file_name = file_name + .trim() + .trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace()) + .trim(); + if file_name.is_empty() { + EXPORT_FILE_NAME_FALLBACK.to_string() + } else { + file_name.to_string() + } +} + +pub(crate) fn export_text_payload( + request: &HostBridgeRequest, +) -> Result<(String, String), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName and content are required", + ) + })?; + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(normalize_export_file_name) + .unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string()); + let content = payload + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?; + if normalize_export_text_mime_type(payload.get("mimeType").and_then(Value::as_str)).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed text type", + )); + } + + if content.len() > EXPORT_TEXT_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "content exceeds file export size limit", + )); + } + + Ok((file_name, content.to_string())) +} + +pub(crate) fn write_export_text_file(path: PathBuf, content: String) -> Result { + fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?; + Ok(content.len()) +} + +fn import_text_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("txt") => Some("text/plain"), + Some("md") | Some("markdown") => Some("text/markdown"), + Some("csv") => Some("text/csv"), + Some("json") => Some("application/json"), + _ => None, + } +} + +fn normalize_export_text_mime_type(value: Option<&str>) -> Option<&'static str> { + match value.map(|mime_type| mime_type.to_ascii_lowercase()) { + None => Some("text/plain"), + Some(mime_type) if mime_type == "text/plain" => Some("text/plain"), + Some(mime_type) if mime_type == "text/markdown" => Some("text/markdown"), + Some(mime_type) if mime_type == "text/csv" => Some("text/csv"), + Some(mime_type) if mime_type == "application/json" => Some("application/json"), + _ => None, + } +} + +fn import_document_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("docx") => { + Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + } + _ => import_text_mime_type(path), + } +} + +pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err(ImportFilePayloadError::InvalidRequest( + "text file is required", + )); + } + + let mime_type = import_text_mime_type(&path).ok_or(ImportFilePayloadError::InvalidRequest( + "text MIME must be allowed", + ))?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "text exceeds import size limit", + )); + } + + let content = fs::read_to_string(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = content.len() as u64; + if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "text exceeds import size limit", + )); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import.txt".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "content": content, + "mimeType": mime_type, + "bytes": byte_count, + })) +} + +pub(crate) fn import_document_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err(ImportFilePayloadError::InvalidRequest( + "document file is required", + )); + } + + let mime_type = import_document_mime_type(&path).ok_or( + ImportFilePayloadError::InvalidRequest("document MIME must be allowed"), + )?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_DOCUMENT_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "document exceeds import size limit", + )); + } + + let bytes = fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = bytes.len() as u64; + if byte_count == 0 || byte_count > IMPORT_DOCUMENT_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "document exceeds import size limit", + )); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import-document.txt".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + })) +} + +fn export_image_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + _ => None, + } +} + +pub(crate) fn import_image_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("png") => Some("image/png"), + Some("jpg") | Some("jpeg") => Some("image/jpeg"), + Some("webp") => Some("image/webp"), + _ => None, + } +} + +fn import_audio_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("mp3") => Some("audio/mpeg"), + Some("m4a") | Some("mp4") => Some("audio/mp4"), + Some("wav") => Some("audio/wav"), + Some("ogg") => Some("audio/ogg"), + Some("webm") => Some("audio/webm"), + _ => None, + } +} + +fn export_audio_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "audio/mpeg" => Some("mp3"), + "audio/mp4" => Some("m4a"), + "audio/wav" => Some("wav"), + "audio/ogg" => Some("ogg"), + "audio/webm" => Some("webm"), + _ => None, + } +} + +fn riff_container_matches(bytes: &[u8], kind: &[u8; 4]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == kind +} + +fn detect_image_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { + return Some("image/png"); + } + + if bytes.len() >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { + return Some("image/jpeg"); + } + + if riff_container_matches(bytes, b"WEBP") { + return Some("image/webp"); + } + + None +} + +fn detect_audio_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"ID3") + || (bytes.len() >= 2 && bytes[0] == 0xff && (bytes[1] & 0xe0) == 0xe0) + { + return Some("audio/mpeg"); + } + + if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { + return Some("audio/mp4"); + } + + if riff_container_matches(bytes, b"WAVE") { + return Some("audio/wav"); + } + + if bytes.starts_with(b"OggS") { + return Some("audio/ogg"); + } + + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Some("audio/webm"); + } + + None +} + +fn ensure_image_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), &'static str> { + if detect_image_mime_type(bytes) == Some(mime_type) { + Ok(()) + } else { + Err("image bytes do not match MIME") + } +} + +fn ensure_audio_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), &'static str> { + if detect_audio_mime_type(bytes) == Some(mime_type) { + Ok(()) + } else { + Err("audio bytes do not match MIME") + } +} + +fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> String { + let mut file_name = normalize_export_file_name(raw_file_name); + let extension = export_image_extension(mime_type).unwrap_or("png"); + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{}", extension)) + { + file_name.push('.'); + file_name.push_str(extension); + } + file_name +} + +fn normalize_export_audio_file_name(raw_file_name: &str, mime_type: &str) -> String { + let mut file_name = normalize_export_file_name(raw_file_name); + let extension = export_audio_extension(mime_type).unwrap_or("webm"); + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{}", extension)) + { + file_name.push('.'); + file_name.push_str(extension); + } + file_name +} + +pub(crate) fn export_image_payload( + request: &HostBridgeRequest, +) -> Result<(String, Vec), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName, mimeType and base64Data are required", + ) + })?; + let mime_type = payload + .get("mimeType") + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "mimeType is required", + ) + })?; + if export_image_extension(mime_type).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed image type", + )); + } + + let base64_data = payload + .get("base64Data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is required", + ) + })?; + let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is invalid", + ) + })?; + if bytes.is_empty() || bytes.len() > EXPORT_IMAGE_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "image exceeds file export size limit", + )); + } + ensure_image_bytes_match_mime_type(&bytes, mime_type) + .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; + + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(|file_name| normalize_export_image_file_name(file_name, mime_type)) + .unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type)); + + Ok((file_name, bytes)) +} + +pub(crate) fn export_audio_payload( + request: &HostBridgeRequest, +) -> Result<(String, Vec), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName, mimeType and base64Data are required", + ) + })?; + let mime_type = payload + .get("mimeType") + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "mimeType is required", + ) + })?; + if export_audio_extension(mime_type).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed audio type", + )); + } + + let base64_data = payload + .get("base64Data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is required", + ) + })?; + let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is invalid", + ) + })?; + if bytes.is_empty() || bytes.len() > EXPORT_AUDIO_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "audio exceeds file export size limit", + )); + } + ensure_audio_bytes_match_mime_type(&bytes, mime_type) + .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; + + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(|file_name| normalize_export_audio_file_name(file_name, mime_type)) + .unwrap_or_else(|| normalize_export_audio_file_name("genarrative-audio", mime_type)); + + Ok((file_name, bytes)) +} + +pub(crate) fn write_export_bytes_file(path: PathBuf, bytes: Vec) -> Result { + let byte_count = bytes.len(); + fs::write(path, bytes).map_err(|error| error.to_string())?; + Ok(byte_count) +} + +pub(crate) fn import_image_file_payload( + path: PathBuf, + action: &'static str, + position: Option<(i32, i32)>, +) -> Result { + if !path.is_file() { + return Err(ImportFilePayloadError::InvalidRequest( + "image file is required", + )); + } + + let mime_type = import_image_mime_type(&path).ok_or(ImportFilePayloadError::InvalidRequest( + "image MIME must be allowed", + ))?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "image exceeds import size limit", + )); + } + + let bytes = fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = bytes.len() as u64; + if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "image exceeds import size limit", + )); + } + ensure_image_bytes_match_mime_type(&bytes, mime_type) + .map_err(ImportFilePayloadError::InvalidRequest)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import.png".to_string()); + let mut payload = json!({ + "action": action, + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + }); + + if let Some((x, y)) = position { + payload["position"] = json!({ + "x": x, + "y": y, + }); + } + + Ok(payload) +} + +pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err(ImportFilePayloadError::InvalidRequest( + "audio file is required", + )); + } + + let mime_type = import_audio_mime_type(&path).ok_or(ImportFilePayloadError::InvalidRequest( + "audio MIME must be allowed", + ))?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "audio exceeds import size limit", + )); + } + + let bytes = fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; + let byte_count = bytes.len() as u64; + if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { + return Err(ImportFilePayloadError::InvalidRequest( + "audio exceeds import size limit", + )); + } + ensure_audio_bytes_match_mime_type(&bytes, mime_type) + .map_err(ImportFilePayloadError::InvalidRequest)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import-audio.webm".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + fn png_bytes() -> Vec { + vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] + } + + fn jpeg_bytes() -> Vec { + vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, b'J', b'F', b'I', b'F'] + } + + fn webp_bytes() -> Vec { + b"RIFF\x04\x00\x00\x00WEBP".to_vec() + } + + fn mp3_bytes() -> Vec { + b"ID3\x04\x00\x00\x00\x00\x00\x10".to_vec() + } + + fn mp4_audio_bytes() -> Vec { + b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00".to_vec() + } + + fn wav_bytes() -> Vec { + b"RIFF\x04\x00\x00\x00WAVE".to_vec() + } + + fn ogg_bytes() -> Vec { + b"OggS\x00\x02audio".to_vec() + } + + fn webm_bytes() -> Vec { + vec![0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00] + } + + #[test] + fn export_file_name_normalization_rejects_path_like_characters() { + assert_eq!( + normalize_export_file_name(" 作品:记录?.txt "), + "作品-记录-.txt" + ); + assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt"); + assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK); + + let long_file_name = "甲".repeat(140); + assert_eq!( + normalize_export_file_name(&long_file_name).chars().count(), + EXPORT_FILE_NAME_MAX_LENGTH + ); + } + + #[test] + fn export_text_payload_requires_text_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": 123 + })); + + let response = export_text_payload(&invalid).expect_err("invalid content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content is required"); + } + + #[test] + fn export_text_payload_rejects_oversized_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1) + })); + + let response = export_text_payload(&invalid).expect_err("oversized content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content exceeds file export size limit"); + } + + #[test] + fn export_text_payload_rejects_non_text_mime_type() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": "暖灯猫街", + "mimeType": "image/png" + })); + + let response = export_text_payload(&invalid).expect_err("invalid MIME"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "mimeType must be an allowed text type"); + } + + #[test] + fn write_export_text_file_persists_utf8_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-export-{}.txt", + std::process::id() + )); + + let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string()) + .expect("write export file"); + + assert_eq!(bytes, "暖灯猫街".len()); + assert_eq!( + fs::read_to_string(&path).expect("read export file"), + "暖灯猫街" + ); + fs::remove_file(path).expect("remove export file"); + } + + #[test] + fn import_text_file_payload_reads_allowed_text_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-{}.md", + std::process::id() + )); + fs::write(&path, "暖灯猫街").expect("write import text"); + + let payload = import_text_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["content"], "暖灯猫街"); + assert_eq!(payload["mimeType"], "text/markdown"); + assert_eq!(payload["bytes"], "暖灯猫街".len() as u64); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import text"); + } + + #[test] + fn import_text_file_payload_rejects_invalid_or_oversized_text() { + let image_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-{}.png", + std::process::id() + )); + fs::write(&image_path, "text").expect("write image-like text"); + assert_eq!( + import_text_file_payload(image_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("text MIME must be allowed") + ); + fs::remove_file(image_path).expect("remove image-like text"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-large-{}.txt", + std::process::id() + )); + fs::write(&large_path, "a".repeat(IMPORT_TEXT_MAX_BYTES as usize + 1)) + .expect("write large text"); + assert_eq!( + import_text_file_payload(large_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("text exceeds import size limit") + ); + fs::remove_file(large_path).expect("remove large text"); + } + + #[test] + fn import_document_file_payload_reads_docx_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-{}.docx", + std::process::id() + )); + let bytes = b"PK\x03\x04docx".to_vec(); + fs::write(&path, &bytes).expect("write import document"); + + let payload = import_document_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(&bytes)); + assert_eq!( + payload["mimeType"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + assert_eq!(payload["bytes"], bytes.len() as u64); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import document"); + } + + #[test] + fn import_document_file_payload_reuses_text_document_mime_types() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-{}.md", + std::process::id() + )); + fs::write(&path, "暖灯猫街").expect("write import document"); + + let payload = import_document_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode("暖灯猫街")); + assert_eq!(payload["mimeType"], "text/markdown"); + assert_eq!(payload["bytes"], "暖灯猫街".len() as u64); + + fs::remove_file(path).expect("remove import document"); + } + + #[test] + fn import_document_file_payload_rejects_invalid_or_oversized_documents() { + let image_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-{}.png", + std::process::id() + )); + fs::write(&image_path, b"text").expect("write image-like document"); + assert_eq!( + import_document_file_payload(image_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("document MIME must be allowed") + ); + fs::remove_file(image_path).expect("remove image-like document"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-large-{}.docx", + std::process::id() + )); + fs::write( + &large_path, + vec![b'a'; IMPORT_DOCUMENT_MAX_BYTES as usize + 1], + ) + .expect("write large document"); + assert_eq!( + import_document_file_payload(large_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("document exceeds import size limit") + ); + fs::remove_file(large_path).expect("remove large document"); + } + + #[test] + fn export_image_payload_decodes_allowed_image_base64() { + let mut valid = request("file.exportImage"); + valid.payload = Some(json!({ + "fileName": "分享:卡?.png", + "base64Data": BASE64_STANDARD.encode(png_bytes()), + "mimeType": "image/png" + })); + + let (file_name, bytes) = export_image_payload(&valid).expect("image payload"); + + assert_eq!(file_name, "分享-卡-.png"); + assert_eq!(bytes, png_bytes()); + } + + #[test] + fn import_image_file_payload_reads_allowed_image_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-{}.png", + std::process::id() + )); + fs::write(&path, png_bytes()).expect("write import image"); + + let payload = + import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(png_bytes())); + assert_eq!(payload["mimeType"], "image/png"); + assert_eq!(payload["bytes"], png_bytes().len() as u64); + assert_eq!(payload["position"], json!({ "x": 12, "y": 24 })); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import image"); + } + + #[test] + fn import_image_file_payload_rejects_invalid_or_oversized_images() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-{}.txt", + std::process::id() + )); + fs::write(&text_path, b"text").expect("write text file"); + assert_eq!( + import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(), + ImportFilePayloadError::InvalidRequest("image MIME must be allowed") + ); + fs::remove_file(text_path).expect("remove text file"); + + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-disguised-{}.png", + std::process::id() + )); + fs::write(&disguised_path, b"text").expect("write disguised image"); + assert_eq!( + import_image_file_payload(disguised_path.clone(), "selected", None).unwrap_err(), + ImportFilePayloadError::InvalidRequest("image bytes do not match MIME") + ); + fs::remove_file(disguised_path).expect("remove disguised image"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-large-{}.webp", + std::process::id() + )); + fs::write( + &large_path, + vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize], + ) + .expect("write large image"); + assert_eq!( + import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(), + ImportFilePayloadError::InvalidRequest("image exceeds import size limit") + ); + fs::remove_file(large_path).expect("remove large image"); + } + + #[test] + fn import_audio_file_payload_reads_allowed_audio_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-{}.webm", + std::process::id() + )); + fs::write(&path, webm_bytes()).expect("write import audio"); + + let payload = import_audio_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(webm_bytes())); + assert_eq!(payload["mimeType"], "audio/webm"); + assert_eq!(payload["bytes"], webm_bytes().len() as u64); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import audio"); + } + + #[test] + fn import_audio_file_payload_rejects_invalid_or_oversized_audio() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-{}.txt", + std::process::id() + )); + fs::write(&text_path, b"audio").expect("write text file"); + assert_eq!( + import_audio_file_payload(text_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("audio MIME must be allowed") + ); + fs::remove_file(text_path).expect("remove text file"); + + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-disguised-{}.mp3", + std::process::id() + )); + fs::write(&disguised_path, b"audio").expect("write disguised audio"); + assert_eq!( + import_audio_file_payload(disguised_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("audio bytes do not match MIME") + ); + fs::remove_file(disguised_path).expect("remove disguised audio"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-large-{}.mp3", + std::process::id() + )); + fs::write( + &large_path, + vec![1u8; (IMPORT_AUDIO_MAX_BYTES + 1) as usize], + ) + .expect("write large audio"); + assert_eq!( + import_audio_file_payload(large_path.clone()).unwrap_err(), + ImportFilePayloadError::InvalidRequest("audio exceeds import size limit") + ); + fs::remove_file(large_path).expect("remove large audio"); + } + + #[test] + fn export_audio_payload_decodes_allowed_audio_base64() { + let mut valid = request("file.exportAudio"); + valid.payload = Some(json!({ + "fileName": "敲击:音效?.wav", + "base64Data": BASE64_STANDARD.encode(wav_bytes()), + "mimeType": "audio/wav" + })); + + let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); + + assert_eq!(file_name, "敲击-音效-.wav"); + assert_eq!(bytes, wav_bytes()); + + let mut missing_extension = request("file.exportAudio"); + missing_extension.payload = Some(json!({ + "fileName": "敲击音效", + "base64Data": BASE64_STANDARD.encode(webm_bytes()), + "mimeType": "audio/webm" + })); + + let (file_name, _bytes) = export_audio_payload(&missing_extension).expect("audio payload"); + + assert_eq!(file_name, "敲击音效.webm"); + } + + #[test] + fn export_audio_payload_rejects_invalid_or_oversized_audio() { + let mut invalid_mime = request("file.exportAudio"); + invalid_mime.payload = Some(json!({ + "fileName": "hit.txt", + "base64Data": BASE64_STANDARD.encode(wav_bytes()), + "mimeType": "text/plain" + })); + let response = export_audio_payload(&invalid_mime).expect_err("invalid mime"); + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut empty = request("file.exportAudio"); + empty.payload = Some(json!({ + "fileName": "hit.wav", + "base64Data": "", + "mimeType": "audio/wav" + })); + let response = export_audio_payload(&empty).expect_err("empty audio"); + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut oversized = request("file.exportAudio"); + oversized.payload = Some(json!({ + "fileName": "hit.webm", + "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_AUDIO_MAX_BYTES + 1]), + "mimeType": "audio/webm" + })); + let response = export_audio_payload(&oversized).expect_err("oversized audio"); + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "audio exceeds file export size limit"); + + let mut mismatched = request("file.exportAudio"); + mismatched.payload = Some(json!({ + "fileName": "hit.wav", + "base64Data": BASE64_STANDARD.encode(mp3_bytes()), + "mimeType": "audio/wav" + })); + let response = export_audio_payload(&mismatched).expect_err("mismatched audio"); + assert_eq!( + response.error.expect("error").message, + "audio bytes do not match MIME" + ); + } + + #[test] + fn export_image_payload_rejects_invalid_mime_and_base64() { + let mut invalid_mime = request("file.exportImage"); + invalid_mime.payload = Some(json!({ + "fileName": "分享卡.txt", + "base64Data": "c2hhcmUtY2FyZA==", + "mimeType": "text/plain" + })); + let response = export_image_payload(&invalid_mime).expect_err("invalid mime"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut invalid_base64 = request("file.exportImage"); + invalid_base64.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": "not base64!", + "mimeType": "image/png" + })); + let response = export_image_payload(&invalid_base64).expect_err("invalid base64"); + assert_eq!( + response.error.expect("error").message, + "base64Data is invalid" + ); + + let mut empty = request("file.exportImage"); + empty.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": "", + "mimeType": "image/png" + })); + let response = export_image_payload(&empty).expect_err("empty image"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut mismatched = request("file.exportImage"); + mismatched.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": BASE64_STANDARD.encode(jpeg_bytes()), + "mimeType": "image/png" + })); + let response = export_image_payload(&mismatched).expect_err("mismatched image"); + assert_eq!( + response.error.expect("error").message, + "image bytes do not match MIME" + ); + } + + #[test] + fn export_image_payload_rejects_oversized_image() { + let mut invalid = request("file.exportImage"); + invalid.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]), + "mimeType": "image/png" + })); + + let response = export_image_payload(&invalid).expect_err("oversized image"); + + assert_eq!( + response.error.expect("error").message, + "image exceeds file export size limit" + ); + } + + #[test] + fn write_export_bytes_file_persists_binary_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-share-card-{}.png", + std::process::id() + )); + + let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G']) + .expect("write image file"); + + assert_eq!(bytes, 4); + assert_eq!( + fs::read(&path).expect("read image file"), + vec![0x89, b'P', b'N', b'G'] + ); + fs::remove_file(path).expect("remove image file"); + } + + #[test] + fn detects_allowed_image_and_audio_headers() { + assert_eq!(detect_image_mime_type(&png_bytes()), Some("image/png")); + assert_eq!(detect_image_mime_type(&jpeg_bytes()), Some("image/jpeg")); + assert_eq!(detect_image_mime_type(&webp_bytes()), Some("image/webp")); + assert_eq!(detect_image_mime_type(b"text"), None); + assert_eq!(detect_audio_mime_type(&mp3_bytes()), Some("audio/mpeg")); + assert_eq!( + detect_audio_mime_type(&mp4_audio_bytes()), + Some("audio/mp4") + ); + assert_eq!(detect_audio_mime_type(&wav_bytes()), Some("audio/wav")); + assert_eq!(detect_audio_mime_type(&ogg_bytes()), Some("audio/ogg")); + assert_eq!(detect_audio_mime_type(&webm_bytes()), Some("audio/webm")); + assert_eq!(detect_audio_mime_type(b"audio"), None); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs new file mode 100644 index 000000000..7fb34918c --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -0,0 +1,405 @@ +use crate::host_bridge::file_payloads::{ + export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, + import_document_file_payload, import_image_file_payload, import_text_file_payload, + write_export_bytes_file, write_export_text_file, ImportFilePayloadError, +}; +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use serde_json::json; +use tauri_plugin_dialog::DialogExt; + +fn file_export_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "cancelled", "file export cancelled") +} + +fn file_import_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "cancelled", "file import cancelled") +} + +fn file_export_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "file export unavailable") +} + +fn file_import_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "file import unavailable") +} + +fn log_desktop_file_export_failure(label: &str) -> bool { + eprintln!("desktop file export failed for {label}"); + false +} + +fn log_desktop_file_import_failure(label: &str) -> bool { + eprintln!("desktop file import failed for {label}"); + false +} + +fn file_import_payload_error_response( + request: &HostBridgeRequest, + label: &str, + error: ImportFilePayloadError, +) -> HostBridgeResponse { + match error.invalid_message() { + Some(message) => failed(request.id.clone(), "invalid_request", message), + None => { + log_desktop_file_import_failure(label); + file_import_unavailable_response(request) + } + } +} + +pub(crate) async fn export_desktop_host_bridge_text_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (file_name, content) = match export_text_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "json", "md", "csv"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return file_export_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_export_failure("path.convert"); + return file_export_unavailable_response(request); + } + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)).await; + let bytes = match export_result { + Ok(Ok(bytes)) => bytes, + Ok(Err(_error)) => { + log_desktop_file_export_failure("write.text"); + return file_export_unavailable_response(request); + } + Err(_error) => { + log_desktop_file_export_failure("write.text.join"); + return file_export_unavailable_response(request); + } + }; + + ok( + request.id.clone(), + json!({ + "action": "saved", + "fileName": file_name, + "bytes": bytes, + }), + ) +} + +pub(crate) async fn import_desktop_host_bridge_text_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "md", "markdown", "csv", "json"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return file_import_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_import_failure("path.convert"); + return file_import_unavailable_response(request); + } + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => file_import_payload_error_response(request, "read.text", error), + Err(_error) => { + log_desktop_file_import_failure("read.text.join"); + file_import_unavailable_response(request) + } + } +} + +pub(crate) async fn import_desktop_host_bridge_document_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter( + "Document", + &["txt", "md", "markdown", "csv", "json", "docx"], + ) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return file_import_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_import_failure("path.convert"); + return file_import_unavailable_response(request); + } + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => file_import_payload_error_response(request, "read.document", error), + Err(_error) => { + log_desktop_file_import_failure("read.document.join"); + file_import_unavailable_response(request) + } + } +} + +pub(crate) async fn export_desktop_host_bridge_image_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (file_name, bytes) = match export_image_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return file_export_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_export_failure("path.convert"); + return file_export_unavailable_response(request); + } + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(_error)) => { + log_desktop_file_export_failure("write.image"); + return file_export_unavailable_response(request); + } + Err(_error) => { + log_desktop_file_export_failure("write.image.join"); + return file_export_unavailable_response(request); + } + }; + ok( + request.id.clone(), + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) +} + +pub(crate) async fn import_desktop_host_bridge_image_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return file_import_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_import_failure("path.convert"); + return file_import_unavailable_response(request); + } + }; + let import_result = tauri::async_runtime::spawn_blocking(move || { + import_image_file_payload(path, "selected", None) + }) + .await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => file_import_payload_error_response(request, "read.image", error), + Err(_error) => { + log_desktop_file_import_failure("read.image.join"); + file_import_unavailable_response(request) + } + } +} + +pub(crate) async fn import_desktop_host_bridge_audio_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return file_import_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_import_failure("path.convert"); + return file_import_unavailable_response(request); + } + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => file_import_payload_error_response(request, "read.audio", error), + Err(_error) => { + log_desktop_file_import_failure("read.audio.join"); + file_import_unavailable_response(request) + } + } +} + +pub(crate) async fn export_desktop_host_bridge_audio_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (file_name, bytes) = match export_audio_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return file_export_cancelled_response(request); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(_error) => { + log_desktop_file_export_failure("path.convert"); + return file_export_unavailable_response(request); + } + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(_error)) => { + log_desktop_file_export_failure("write.audio"); + return file_export_unavailable_response(request); + } + Err(_error) => { + log_desktop_file_export_failure("write.audio.join"); + return file_export_unavailable_response(request); + } + }; + ok( + request.id.clone(), + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + #[test] + fn desktop_file_cancelled_responses_are_stable() { + let request = request("file.importText"); + + let export_error = file_export_cancelled_response(&request) + .error + .expect("export error"); + assert_eq!(export_error.code, "cancelled"); + assert_eq!(export_error.message, "file export cancelled"); + + let import_error = file_import_cancelled_response(&request) + .error + .expect("import error"); + assert_eq!(import_error.code, "cancelled"); + assert_eq!(import_error.message, "file import cancelled"); + } + + #[test] + fn desktop_file_unavailable_responses_are_stable() { + let request = request("file.importText"); + + let export_error = file_export_unavailable_response(&request) + .error + .expect("export error"); + assert_eq!(export_error.code, "host_error"); + assert_eq!(export_error.message, "file export unavailable"); + + let import_error = file_import_unavailable_response(&request) + .error + .expect("import error"); + assert_eq!(import_error.code, "host_error"); + assert_eq!(import_error.message, "file import unavailable"); + } + + #[test] + fn desktop_file_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_file_export_failure("path.convert")); + assert!(!log_desktop_file_export_failure("write.text")); + assert!(!log_desktop_file_import_failure("path.convert")); + assert!(!log_desktop_file_import_failure("read.text.join")); + } + + #[test] + fn desktop_file_failures_log_stable_label_only() { + assert!(!log_desktop_file_export_failure("write.image.join")); + assert!(!log_desktop_file_import_failure("read.audio.join")); + } + + #[test] + fn desktop_file_import_validation_errors_keep_stable_invalid_request() { + let request = request("file.importText"); + + let error = file_import_payload_error_response( + &request, + "read.text", + ImportFilePayloadError::InvalidRequest("text MIME must be allowed"), + ) + .error + .expect("error"); + + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "text MIME must be allowed"); + } + + #[test] + fn desktop_file_import_native_read_errors_use_stable_host_error() { + let request = request("file.importText"); + + let error = file_import_payload_error_response( + &request, + "read.text", + ImportFilePayloadError::NativeRead, + ) + .error + .expect("error"); + + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "file import unavailable"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs new file mode 100644 index 000000000..e1f3cb94b --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs @@ -0,0 +1,85 @@ +mod appearance; +pub(crate) mod capabilities; +mod badge; +mod clipboard; +mod dispatch; +pub(crate) mod file_payloads; +pub(crate) mod files; +mod navigation; +mod network; +mod notifications; +pub(crate) mod protocol; +mod runtime; +mod share; +mod title; + +pub(crate) use protocol::HostBridgeReplayState; +pub(crate) use share::DesktopShareState; + +use crate::host_bridge::dispatch::execute_host_bridge_request; +use crate::host_bridge::protocol::{ + normalize_request_id, validate_request, HostBridgeReplayReservation, HostBridgeRequest, + HostBridgeResponse, +}; + +fn prepare_host_bridge_request( + request: &mut HostBridgeRequest, +) -> Option { + if let Some(response) = validate_request(request) { + return Some(response); + } + request.id = normalize_request_id(&request.id).unwrap_or_else(|| request.id.clone()); + None +} + +#[tauri::command] +pub(crate) async fn host_bridge_request( + app: tauri::AppHandle, + replay_state: tauri::State<'_, HostBridgeReplayState>, + mut request: HostBridgeRequest, +) -> Result { + if let Some(response) = prepare_host_bridge_request(&mut request) { + return Ok(response); + } + + let response = match replay_state.reserve(&request.id) { + Err(response) => response, + Ok(HostBridgeReplayReservation::Wait(slot)) => { + HostBridgeReplayState::wait_for_response(slot) + } + Ok(HostBridgeReplayReservation::Execute(slot)) => { + let response = execute_host_bridge_request(app, request).await; + replay_state.complete(slot, response) + } + }; + + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + #[test] + fn host_bridge_request_rejects_invalid_requests_before_replay() { + let replay_state = HostBridgeReplayState::default(); + let mut invalid = request("share.open"); + invalid.id = " request-1 ".to_string(); + invalid.bridge = "OtherBridge".to_string(); + + let response = + prepare_host_bridge_request(&mut invalid).expect("invalid envelope"); + + assert!(!response.ok); + assert_eq!(response.id, "request-1"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + match replay_state.reserve("request-1").expect("replay reservation") { + HostBridgeReplayReservation::Execute(_) => {} + HostBridgeReplayReservation::Wait(_) => { + panic!("invalid request must not reserve replay slot") + } + } + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs new file mode 100644 index 000000000..c3972e506 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs @@ -0,0 +1,265 @@ +use crate::host_bridge::protocol::{ + failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse, +}; +use crate::shell::webview::{ + normalize_external_url, normalize_native_page_url, open_normalized_desktop_external_url, +}; +use serde_json::json; +use tauri::Manager; + +fn desktop_external_url_from_request( + request: &HostBridgeRequest, +) -> Result { + required_string_payload(request, "url") + .ok() + .and_then(normalize_external_url) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "url must use an allowed external protocol", + ) + }) +} + +fn desktop_native_page_url_from_request( + request: &HostBridgeRequest, +) -> Result { + required_string_payload(request, "url") + .ok() + .and_then(normalize_native_page_url) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "url must use an allowed same-origin H5 route", + ) + }) +} + +pub(crate) fn open_desktop_host_bridge_external_url( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let url = match desktop_external_url_from_request(request) { + Ok(url) => url, + Err(response) => return response, + }; + + match open_normalized_desktop_external_url(app, url) { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(_error) => { + log_desktop_navigation_failure("external.open"); + external_url_unavailable_response(request) + } + } +} + +fn external_url_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed( + request.id.clone(), + "host_error", + "external URL cannot be opened", + ) +} + +pub(crate) fn open_desktop_host_bridge_native_page( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let url = match desktop_native_page_url_from_request(request) { + Ok(url) => url, + Err(response) => return response, + }; + + match app.get_webview_window("main") { + Some(window) => match window.navigate(url) { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(_error) => { + log_desktop_navigation_failure("native.navigate"); + native_page_unavailable_response(request) + } + }, + None => { + log_desktop_navigation_failure("native.window"); + native_page_unavailable_response(request) + } + } +} + +fn native_page_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "native page unavailable") +} + +fn log_desktop_navigation_failure(label: &str) -> bool { + eprintln!("desktop navigation failed for {label}"); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + fn request_with_url(method: &str, url: &str) -> HostBridgeRequest { + let mut request = request(method); + request.payload = Some(json!({ "url": url })); + request + } + + #[test] + fn desktop_external_url_request_accepts_only_safe_system_protocols() { + let request = request_with_url("app.openExternalUrl", " https://example.com/share "); + + assert_eq!( + desktop_external_url_from_request(&request).expect("external url"), + "https://example.com/share" + ); + + for url in [ + "", + "/works/detail?work=PZ-1", + "javascript:alert(1)", + "file:///etc/passwd", + "https://example.com/\nnext", + ] { + let response = desktop_external_url_from_request(&request_with_url( + "app.openExternalUrl", + url, + )) + .expect_err("invalid external url"); + + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!( + error.message, + "url must use an allowed external protocol" + ); + } + } + + #[test] + fn external_url_unavailable_response_is_stable() { + let response = external_url_unavailable_response(&request("app.openExternalUrl")); + + assert!(!response.ok); + let error = response.error.expect("external url error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "external URL cannot be opened"); + } + + #[test] + fn desktop_native_page_request_accepts_only_same_origin_h5_routes() { + let route = desktop_native_page_url_from_request(&request_with_url( + "navigation.openNativePage", + "/works/detail?work=PZ-1", + )) + .expect("native page url"); + + assert_eq!( + route.origin().ascii_serialization(), + "https://www.genarrative.world" + ); + assert_eq!(route.path(), "/works/detail"); + assert!(route + .query_pairs() + .any(|(key, value)| key == "hostShell" && value == "tauri_desktop")); + + for url in [ + "https://example.com/works", + "//example.com/works", + "javascript:alert(1)", + "https://www.genarrative.world/\nnext", + ] { + let response = desktop_native_page_url_from_request(&request_with_url( + "navigation.openNativePage", + url, + )) + .expect_err("invalid native page url"); + + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!( + error.message, + "url must use an allowed same-origin H5 route" + ); + } + } + + #[test] + fn native_page_unavailable_response_is_stable() { + let response = native_page_unavailable_response(&request("navigation.openNativePage")); + + assert!(!response.ok); + let error = response.error.expect("native page error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "native page unavailable"); + } + + #[test] + fn navigation_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_navigation_failure("external.open")); + assert!(!log_desktop_navigation_failure("native.navigate")); + assert!(!log_desktop_navigation_failure("native.window")); + } + + #[test] + fn navigation_failures_log_stable_label_only() { + assert!(!log_desktop_navigation_failure("external.open")); + } +} + +pub(crate) fn reload_desktop_host_bridge_webview( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + match app.get_webview_window("main") { + Some(window) => match window.reload() { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(_error) => { + log_desktop_navigation_failure("webview.reload"); + webview_reload_unavailable_response(request) + } + }, + None => { + log_desktop_navigation_failure("webview.window"); + webview_reload_unavailable_response(request) + } + } +} + +fn webview_reload_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed( + request.id.clone(), + "host_error", + "webview reload unavailable", + ) +} + +#[cfg(test)] +mod reload_tests { + use super::*; + use crate::host_bridge::protocol::request; + + #[test] + fn webview_reload_unavailable_response_is_stable() { + let response = webview_reload_unavailable_response(&request("app.reloadWebView")); + + assert!(!response.ok); + let error = response.error.expect("webview reload error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "webview reload unavailable"); + } + + #[test] + fn webview_reload_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_navigation_failure("webview.reload")); + assert!(!log_desktop_navigation_failure("webview.window")); + } + + #[test] + fn webview_reload_failures_log_stable_label_only() { + assert!(!log_desktop_navigation_failure("webview.reload")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/network.rs b/apps/desktop-shell/src-tauri/src/host_bridge/network.rs new file mode 100644 index 000000000..24a7b565a --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/network.rs @@ -0,0 +1,106 @@ +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use crate::shell::webview::resolve_desktop_network_status; +use serde_json::Value; + +fn map_desktop_host_bridge_network_status_result( + status: Result, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + match status { + Ok(status) => ok(request.id.clone(), status), + Err(_error) => { + log_desktop_network_failure("status.resolve"); + network_status_unavailable_response(request) + } + } +} + +async fn resolve_desktop_host_bridge_network_status_payload_with( + resolver: fn() -> Value, +) -> Result { + tauri::async_runtime::spawn_blocking(resolver) + .await + .map_err(|error| error.to_string()) +} + +async fn resolve_desktop_host_bridge_network_status_payload() -> Result { + resolve_desktop_host_bridge_network_status_payload_with(resolve_desktop_network_status).await +} + +pub(crate) async fn resolve_desktop_host_bridge_network_status( + request: &HostBridgeRequest, +) -> HostBridgeResponse { + map_desktop_host_bridge_network_status_result( + resolve_desktop_host_bridge_network_status_payload().await, + request, + ) +} + +fn network_status_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "network status unavailable") +} + +fn log_desktop_network_failure(label: &str) -> bool { + eprintln!("desktop network failed for {label}"); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + #[test] + fn network_status_unavailable_response_is_stable() { + let response = network_status_unavailable_response(&request("network.status")); + + assert!(!response.ok); + let error = response.error.expect("network status error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "network status unavailable"); + } + + #[test] + fn network_status_success_response_reports_contract_shape() { + let payload = json!({ + "isConnected": true, + "isInternetReachable": true, + "connectionType": "unknown", + "nativeType": "online", + }); + let response = map_desktop_host_bridge_network_status_result( + Ok(payload.clone()), + &request("network.status"), + ); + + assert!(response.ok); + assert_eq!(response.id, "request-1"); + assert_eq!(response.result, Some(payload)); + assert!(response.error.is_none()); + } + + #[test] + fn network_status_failure_hides_native_error_detail() { + let response = map_desktop_host_bridge_network_status_result( + Err("private native resolver detail".to_string()), + &request("network.status"), + ); + + assert!(!response.ok); + let error = response.error.expect("network status error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "network status unavailable"); + assert!(!error.message.contains("private native resolver detail")); + } + + #[test] + fn network_status_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_network_failure("status.resolve")); + } + + #[test] + fn network_status_failures_log_stable_label_only() { + assert!(!log_desktop_network_failure("status.resolve")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs b/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs new file mode 100644 index 000000000..9bdd60f4f --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs @@ -0,0 +1,288 @@ +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use serde_json::json; +use serde_json::Value; +use tauri_plugin_notification::{NotificationExt, PermissionState}; + +const HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION: &str = "delivered_to_system"; +const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; +const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; + +fn normalize_plain_text( + value: Option<&str>, + max_length: usize, + required: bool, +) -> Option> { + let Some(value) = value else { + return if required { None } else { Some(None) }; + }; + if value.chars().any(char::is_control) { + return None; + } + + let text = value.split_whitespace().collect::>().join(" "); + if text.is_empty() { + return if required { None } else { Some(None) }; + } + + Some(Some(text.chars().take(max_length).collect())) +} + +fn local_notification_payload( + request: &HostBridgeRequest, +) -> Result<(String, Option), HostBridgeResponse> { + let payload = request + .payload + .as_ref() + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?; + let title = match normalize_plain_text( + payload.get("title").and_then(Value::as_str), + HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, + true, + ) { + Some(Some(title)) => title, + _ => { + return Err(failed( + request.id.clone(), + "invalid_request", + "title is required", + )) + } + }; + let body = match normalize_plain_text( + payload.get("body").and_then(Value::as_str), + HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH, + false, + ) { + Some(body) => body, + None => { + return Err(failed( + request.id.clone(), + "invalid_request", + "body is invalid", + )) + } + }; + + Ok((title, body)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DesktopNotificationPermissionAction { + Show, + Request, + Reject, +} + +fn desktop_notification_permission_action( + permission_state: PermissionState, +) -> DesktopNotificationPermissionAction { + match permission_state { + PermissionState::Granted => DesktopNotificationPermissionAction::Show, + PermissionState::Denied => DesktopNotificationPermissionAction::Reject, + PermissionState::Prompt | PermissionState::PromptWithRationale => { + DesktopNotificationPermissionAction::Request + } + } +} + +fn desktop_notification_delivered_to_system_result() -> serde_json::Value { + json!({"action": HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION}) +} + +fn notification_permission_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed( + request.id.clone(), + "host_error", + "notification permission unavailable", + ) +} + +fn notification_delivery_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed( + request.id.clone(), + "host_error", + "notification delivery unavailable", + ) +} + +fn log_desktop_notification_failure(label: &str) -> bool { + eprintln!("desktop notification failed for {label}"); + false +} + +pub(crate) fn show_desktop_local_notification( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (title, body) = match local_notification_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let notification_manager = app.notification(); + let permission_state = notification_manager + .permission_state() + .map_err(|_error| { + log_desktop_notification_failure("permission.state"); + notification_permission_unavailable_response(request) + }); + let permission_state = match permission_state { + Ok(permission_state) => permission_state, + Err(response) => return response, + }; + let mut permission_action = desktop_notification_permission_action(permission_state); + if permission_action == DesktopNotificationPermissionAction::Request { + let requested_state = notification_manager + .request_permission() + .map_err(|_error| { + log_desktop_notification_failure("permission.request"); + notification_permission_unavailable_response(request) + }); + let requested_state = match requested_state { + Ok(requested_state) => requested_state, + Err(response) => return response, + }; + permission_action = desktop_notification_permission_action(requested_state); + } + if permission_action != DesktopNotificationPermissionAction::Show { + return failed( + request.id.clone(), + "host_error", + "notification permission denied", + ); + } + + let mut notification = app.notification().builder().title(title); + if let Some(body) = body { + notification = notification.body(body); + } + + match notification.show() { + Ok(()) => ok( + request.id.clone(), + desktop_notification_delivered_to_system_result(), + ), + Err(_error) => { + log_desktop_notification_failure("delivery.show"); + notification_delivery_unavailable_response(request) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + #[test] + fn local_notification_payload_is_normalized() { + let mut request = request("notification.showLocal"); + request.payload = Some(json!({ + "title": " 生成完成 ", + "body": " 作品已准备好 可以试玩 " + })); + + let (title, body) = local_notification_payload(&request).expect("payload"); + + assert_eq!(title, "生成完成"); + assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩")); + } + + #[test] + fn local_notification_payload_truncates_to_shared_contract_limits() { + let mut request = request("notification.showLocal"); + request.payload = Some(json!({ + "title": "a".repeat(90), + "body": "b".repeat(250) + })); + + let (title, body) = local_notification_payload(&request).expect("payload"); + let expected_body = "b".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH); + + assert_eq!(title, "a".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH)); + assert_eq!(body.as_deref(), Some(expected_body.as_str())); + } + + #[test] + fn local_notification_success_result_reports_system_delivery() { + assert_eq!( + desktop_notification_delivered_to_system_result(), + json!({"action": "delivered_to_system"}) + ); + } + + #[test] + fn notification_permission_unavailable_response_is_stable() { + let request = request("notification.showLocal"); + + let response = notification_permission_unavailable_response(&request); + let error = response.error.expect("error"); + + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "notification permission unavailable"); + } + + #[test] + fn notification_delivery_unavailable_response_is_stable() { + let request = request("notification.showLocal"); + + let response = notification_delivery_unavailable_response(&request); + let error = response.error.expect("error"); + + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "notification delivery unavailable"); + } + + #[test] + fn notification_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_notification_failure("permission.state")); + assert!(!log_desktop_notification_failure("permission.request")); + assert!(!log_desktop_notification_failure("delivery.show")); + } + + #[test] + fn notification_failures_log_stable_label_only() { + assert!(!log_desktop_notification_failure("delivery.show")); + } + + #[test] + fn local_notification_payload_rejects_empty_and_control_text() { + let mut empty = request("notification.showLocal"); + empty.payload = Some(json!({ + "title": " " + })); + + let response = local_notification_payload(&empty).expect_err("empty title"); + + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut control = request("notification.showLocal"); + control.payload = Some(json!({ + "title": "生成\n完成" + })); + + let response = local_notification_payload(&control).expect_err("control title"); + + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + #[test] + fn desktop_notification_permission_state_controls_delivery() { + assert_eq!( + desktop_notification_permission_action(PermissionState::Granted), + DesktopNotificationPermissionAction::Show + ); + assert_eq!( + desktop_notification_permission_action(PermissionState::Denied), + DesktopNotificationPermissionAction::Reject + ); + assert_eq!( + desktop_notification_permission_action(PermissionState::Prompt), + DesktopNotificationPermissionAction::Request + ); + assert_eq!( + desktop_notification_permission_action(PermissionState::PromptWithRationale), + DesktopNotificationPermissionAction::Request + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs new file mode 100644 index 000000000..e3793f4f0 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -0,0 +1,483 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; + +pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; +pub(crate) const HOST_BRIDGE_VERSION: u8 = 1; +pub(crate) const HOST_BRIDGE_METHODS: [&str; 25] = [ + "host.getRuntime", + "appearance.getColorScheme", + "auth.requestLogin", + "payment.request", + "share.setTarget", + "share.open", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.importDocument", + "file.exportImage", + "file.importImage", + "file.captureImage", + "scanner.scanQrCode", + "file.importAudio", + "file.exportAudio", + "haptics.impact", + "notification.showLocal", +]; +pub(crate) const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120; +const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; +const DESKTOP_HOST_BRIDGE_REQUEST_FAILED: &str = "desktop host bridge request failed"; +const HOST_BRIDGE_ERROR_CODES: [&str; 6] = [ + "invalid_request", + "unsupported_method", + "unsupported_capability", + "timeout", + "cancelled", + "host_error", +]; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostBridgeRequest { + pub(crate) bridge: String, + pub(crate) version: u8, + pub(crate) id: String, + pub(crate) method: String, + pub(crate) payload: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostBridgeRuntime { + pub(crate) shell: &'static str, + pub(crate) platform: &'static str, + pub(crate) host_version: &'static str, + pub(crate) bridge_version: u8, + pub(crate) capabilities: Vec<&'static str>, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct HostBridgeError { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostBridgeResponse { + pub(crate) bridge: &'static str, + pub(crate) version: u8, + pub(crate) id: String, + pub(crate) ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct HostBridgeReplayState { + cache: Mutex, +} + +#[derive(Debug, Default)] +struct HostBridgeReplayCache { + order: Vec, + slots: HashMap>, +} + +#[derive(Debug)] +pub(crate) struct HostBridgeReplaySlot { + request_id: String, + response: Mutex>, + ready: Condvar, +} + +#[derive(Debug)] +pub(crate) enum HostBridgeReplayReservation { + Execute(Arc), + Wait(Arc), +} + +impl HostBridgeReplayState { + pub(crate) fn reserve( + &self, + request_id: &str, + ) -> Result { + let mut cache = self.cache.lock().map_err(|_error| { + log_desktop_replay_failure("cache.lock"); + replay_unavailable_response(request_id) + })?; + if let Some(slot) = cache.slots.get(request_id) { + return Ok(HostBridgeReplayReservation::Wait(slot.clone())); + } + + let slot = Arc::new(HostBridgeReplaySlot::new(request_id)); + cache.order.push(request_id.to_string()); + cache.slots.insert(request_id.to_string(), slot.clone()); + while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX { + if let Some(oldest_request_id) = cache.order.first().cloned() { + cache.order.remove(0); + cache.slots.remove(&oldest_request_id); + } + } + + Ok(HostBridgeReplayReservation::Execute(slot)) + } + + pub(crate) fn complete( + &self, + slot: Arc, + response: HostBridgeResponse, + ) -> HostBridgeResponse { + let mut stored_response = match slot.response.lock() { + Ok(response) => response, + Err(_error) => { + log_desktop_replay_failure("slot.complete"); + return replay_unavailable_response(&response.id); + } + }; + *stored_response = Some(response.clone()); + slot.ready.notify_all(); + response + } + + pub(crate) fn wait_for_response(slot: Arc) -> HostBridgeResponse { + let mut stored_response = match slot.response.lock() { + Ok(response) => response, + Err(_error) => { + log_desktop_replay_failure("slot.wait"); + return replay_unavailable_response(&slot.request_id); + } + }; + while stored_response.is_none() { + stored_response = match slot.ready.wait(stored_response) { + Ok(response) => response, + Err(_error) => { + log_desktop_replay_failure("slot.ready"); + return replay_unavailable_response(&slot.request_id); + } + }; + } + + match stored_response.clone() { + Some(response) => response, + None => replay_unavailable_response(&slot.request_id), + } + } +} + +impl HostBridgeReplaySlot { + fn new(request_id: &str) -> Self { + Self { + request_id: request_id.to_string(), + response: Mutex::new(None), + ready: Condvar::new(), + } + } +} + +fn log_desktop_replay_failure(label: &str) -> bool { + eprintln!("desktop host bridge replay failed for {label}"); + false +} + +fn replay_unavailable_response(request_id: &str) -> HostBridgeResponse { + failed( + request_id.to_string(), + "host_error", + DESKTOP_HOST_BRIDGE_REQUEST_FAILED, + ) +} + +pub(crate) fn ok(id: String, result: Value) -> HostBridgeResponse { + HostBridgeResponse { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id, + ok: true, + result: Some(result), + error: None, + } +} + +pub(crate) fn failed( + id: String, + code: &'static str, + message: impl Into, +) -> HostBridgeResponse { + let (code, message) = if HOST_BRIDGE_ERROR_CODES.contains(&code) { + (code, message.into()) + } else { + ("host_error", DESKTOP_HOST_BRIDGE_REQUEST_FAILED.to_string()) + }; + + HostBridgeResponse { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id, + ok: false, + result: None, + error: Some(HostBridgeError { + code, + message, + }), + } +} + +pub(crate) fn has_control_character(value: &str) -> bool { + value.chars().any(|character| { + let code_point = character as u32; + code_point <= 31 || code_point == 127 + }) +} + +pub(crate) fn normalize_request_id(raw_id: &str) -> Option { + let id = raw_id.trim(); + if id.is_empty() + || id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + || has_control_character(id) + { + return None; + } + + Some(id.to_string()) +} + +pub(crate) fn is_host_bridge_method(method: &str) -> bool { + HOST_BRIDGE_METHODS.contains(&method) +} + +pub(crate) fn validate_request(request: &HostBridgeRequest) -> Option { + let Some(request_id) = normalize_request_id(&request.id) else { + return Some(failed( + "invalid".to_string(), + "invalid_request", + "invalid host bridge request id", + )); + }; + + if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION { + return Some(failed( + request_id, + "invalid_request", + "invalid host bridge envelope", + )); + } + + if !is_host_bridge_method(&request.method) { + return Some(failed( + request_id, + "invalid_request", + "invalid host bridge method", + )); + } + + None +} + +pub(crate) fn required_string_payload<'a>( + request: &'a HostBridgeRequest, + field: &'static str, +) -> Result<&'a str, HostBridgeResponse> { + request + .payload + .as_ref() + .and_then(|value| value.get(field)) + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + format!("{} is required", field), + ) + }) +} + +#[cfg(test)] +pub(crate) fn request(method: &str) -> HostBridgeRequest { + HostBridgeRequest { + bridge: HOST_BRIDGE_PROTOCOL.to_string(), + version: HOST_BRIDGE_VERSION, + id: "request-1".to_string(), + method: method.to_string(), + payload: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::thread; + + #[test] + fn invalid_envelope_is_rejected() { + let mut invalid = request("host.getRuntime"); + invalid.bridge = "OtherBridge".to_string(); + + let response = validate_request(&invalid).expect("invalid envelope"); + + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + #[test] + fn invalid_request_id_and_unknown_method_are_rejected() { + for id in ["", "request\n1"] { + let mut invalid = request("share.open"); + invalid.id = id.to_string(); + + let response = validate_request(&invalid).expect("invalid id"); + + assert!(!response.ok); + assert_eq!(response.id, "invalid"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + let mut oversized = request("share.open"); + oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1); + let response = validate_request(&oversized).expect("oversized id"); + assert!(!response.ok); + assert_eq!(response.id, "invalid"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut multibyte_boundary = request("host.getRuntime"); + multibyte_boundary.id = "作".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH); + assert!(validate_request(&multibyte_boundary).is_none()); + + let response = + validate_request(&request("host.runArbitraryCommand")).expect("unknown method"); + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "invalid host bridge method"); + } + + #[test] + fn invalid_error_code_is_normalized_before_response() { + let response = failed( + "request-1".to_string(), + "native_clipboard_failure", + "native clipboard failed", + ); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "desktop host bridge request failed"); + } + + #[test] + fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() { + let replay_state = HostBridgeReplayState::default(); + let mut side_effect_count = 0; + + let first_reservation = replay_state + .reserve("request-1") + .expect("first replay reservation"); + let first_response = match first_reservation { + HostBridgeReplayReservation::Execute(slot) => { + side_effect_count += 1; + replay_state.complete(slot, ok("request-1".to_string(), json!(true))) + } + HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), + }; + let second_response = match replay_state + .reserve("request-1") + .expect("second replay reservation") + { + HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"), + HostBridgeReplayReservation::Wait(slot) => { + HostBridgeReplayState::wait_for_response(slot) + } + }; + + assert_eq!(side_effect_count, 1); + assert_eq!(second_response.ok, first_response.ok); + assert_eq!(second_response.result, first_response.result); + } + + #[test] + fn host_bridge_replay_state_evicts_oldest_response_after_cache_limit() { + let replay_state = HostBridgeReplayState::default(); + + match replay_state + .reserve("request-0") + .expect("initial replay reservation") + { + HostBridgeReplayReservation::Execute(slot) => { + replay_state.complete(slot, ok("request-0".to_string(), json!(0))); + } + HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), + } + + for index in 1..=HOST_BRIDGE_RESPONSE_CACHE_MAX { + let request_id = format!("request-{index}"); + match replay_state.reserve(&request_id).expect("replay reservation") { + HostBridgeReplayReservation::Execute(slot) => { + replay_state.complete(slot, ok(request_id, json!(index))); + } + HostBridgeReplayReservation::Wait(_) => panic!("new request must execute"), + } + } + + match replay_state + .reserve("request-0") + .expect("evicted replay reservation") + { + HostBridgeReplayReservation::Execute(_) => {} + HostBridgeReplayReservation::Wait(_) => { + panic!("oldest request must be evicted after cache limit") + } + } + } + + #[test] + fn host_bridge_replay_state_returns_stable_error_when_cache_lock_is_unavailable() { + let replay_state = Arc::new(HostBridgeReplayState::default()); + let poison_state = replay_state.clone(); + let poison_result = thread::spawn(move || { + let _cache = poison_state.cache.lock().expect("cache lock"); + panic!("poison replay cache"); + }) + .join(); + assert!(poison_result.is_err()); + + let response = replay_state + .reserve("request-1") + .expect_err("poisoned replay cache returns stable response"); + + assert!(!response.ok); + assert_eq!(response.id, "request-1"); + let error = response.error.expect("replay error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, DESKTOP_HOST_BRIDGE_REQUEST_FAILED); + } + + #[test] + fn desktop_host_bridge_replay_logs_stable_label_only() { + assert!(!log_desktop_replay_failure("slot.ready")); + } + + #[test] + fn invalid_string_payload_is_rejected() { + let mut invalid = request("clipboard.writeText"); + invalid.payload = Some(json!({ "text": 123 })); + + let response = required_string_payload(&invalid, "text").expect_err("invalid payload"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "text is required"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs b/apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs new file mode 100644 index 000000000..a04d7ad1b --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs @@ -0,0 +1,117 @@ +use crate::host_bridge::capabilities::capabilities; +use crate::host_bridge::protocol::{ + ok, HostBridgeRequest, HostBridgeResponse, HostBridgeRuntime, HOST_BRIDGE_VERSION, +}; +use crate::shell::webview::desktop_platform; +use serde_json::json; + +pub(crate) fn desktop_runtime() -> HostBridgeRuntime { + HostBridgeRuntime { + shell: "tauri_desktop", + platform: desktop_platform(), + host_version: env!("CARGO_PKG_VERSION"), + bridge_version: HOST_BRIDGE_VERSION, + capabilities: capabilities(), + } +} + +pub(crate) fn desktop_host_bridge_runtime_response( + request: &HostBridgeRequest, +) -> HostBridgeResponse { + ok(request.id.clone(), json!(desktop_runtime())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, to_value}; + + #[test] + fn runtime_response_reports_tauri_shell() { + let result = to_value(desktop_runtime()).expect("runtime result"); + + assert_eq!(result["shell"], "tauri_desktop"); + assert_eq!(result["bridgeVersion"], HOST_BRIDGE_VERSION); + assert_eq!(result["capabilities"], json!(capabilities())); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("appearance.getColorScheme"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("host.events"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.lifecycle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("network.status"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("share.open"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("share.setTarget"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("navigation.openNativePage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.reloadWebView"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.setTitle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.setBadgeCount"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("clipboard.readText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importDocument"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.imageDropped"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("notification.showLocal"))); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs new file mode 100644 index 000000000..dfc2133d0 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs @@ -0,0 +1,386 @@ +use crate::host_bridge::clipboard::{ + clipboard_write_unavailable_response, write_desktop_clipboard_text, +}; +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use crate::shell::webview::WEB_APP_ORIGIN; +use serde_json::json; +use serde_json::Value; +use std::sync::Mutex; +use tauri::Manager; +use tauri::Url; + +#[derive(Debug, Default)] +pub(crate) struct DesktopShareState { + pub(crate) target: Mutex>, +} + +enum DesktopSharePayload { + Empty, + Invalid, + Valid(String), +} + +fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +fn log_desktop_share_failure(label: &str) -> bool { + eprintln!("desktop share failed for {label}"); + false +} + +fn share_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "share unavailable") +} + +fn share_target_payload(value: &Value) -> &Value { + value.get("target").unwrap_or(value) +} + +fn work_detail_url(work: &str) -> String { + let mut url = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin"); + url.set_path("/works/detail"); + url.set_query(None); + url.query_pairs_mut().append_pair("work", work); + url.to_string() +} + +fn normalize_public_share_url(raw_url: &str) -> Option { + if raw_url.starts_with("//") { + return None; + } + + let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; + let url = base_url.join(raw_url).ok()?; + if url.origin() != base_url.origin() { + return None; + } + + Some(url.to_string()) +} + +fn share_text_from_value(value: &Value) -> DesktopSharePayload { + let target = share_target_payload(value); + let payload = target.get("payload").unwrap_or(target); + let title = payload_string(payload, "title"); + let message = payload_string(payload, "message"); + let raw_direct_url = payload_string(payload, "url").or_else(|| payload_string(payload, "href")); + let direct_url = match raw_direct_url { + Some(url) => match normalize_public_share_url(url) { + Some(url) => Some(url), + None => return DesktopSharePayload::Invalid, + }, + None => None, + }; + let work_url = payload_string(payload, "work").map(work_detail_url); + let raw_path = + payload_string(payload, "path").or_else(|| payload_string(payload, "targetPath")); + let path_url = match raw_path { + Some(path) => match normalize_public_share_url(path) { + Some(url) => Some(url), + None => return DesktopSharePayload::Invalid, + }, + None => None, + }; + let resolved_url = direct_url.or(work_url).or(path_url); + let parts = [title, message, resolved_url.as_deref()] + .into_iter() + .flatten() + .collect::>(); + + if parts.is_empty() { + DesktopSharePayload::Empty + } else { + DesktopSharePayload::Valid(parts.join("\n")) + } +} + +pub(crate) fn share_text_from_request( + request: &HostBridgeRequest, + share_state: &DesktopShareState, +) -> Result { + if let Some(payload) = request.payload.as_ref() { + match share_text_from_value(payload) { + DesktopSharePayload::Valid(text) => return Ok(text), + DesktopSharePayload::Invalid => { + return Err(failed( + request.id.clone(), + "invalid_request", + "share target is invalid", + )) + } + DesktopSharePayload::Empty => {} + } + } + + let stored_target = share_state + .target + .lock() + .map_err(|_| { + log_desktop_share_failure("target.lock"); + share_unavailable_response(request) + })? + .clone(); + + match stored_target.as_ref().map(share_text_from_value) { + Some(DesktopSharePayload::Valid(text)) => Ok(text), + Some(DesktopSharePayload::Invalid) => Err(failed( + request.id.clone(), + "invalid_request", + "share target is invalid", + )), + Some(DesktopSharePayload::Empty) | None => Err(failed( + request.id.clone(), + "invalid_request", + "share target is required", + )), + } +} + +pub(crate) fn set_desktop_host_bridge_share_target( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let target = request + .payload + .as_ref() + .and_then(|payload| payload.get("target")); + let Some(target) = target else { + return failed(request.id.clone(), "invalid_request", "target is required"); + }; + match share_text_from_value(target) { + DesktopSharePayload::Valid(_) => {} + DesktopSharePayload::Invalid => { + return failed( + request.id.clone(), + "invalid_request", + "share target is invalid", + ) + } + DesktopSharePayload::Empty => { + return failed( + request.id.clone(), + "invalid_request", + "share target is required", + ) + } + } + let share_state = app.state::(); + + let response = match share_state.target.lock() { + Ok(mut current_target) => { + *current_target = Some(target.clone()); + ok(request.id.clone(), json!(true)) + } + Err(_) => { + log_desktop_share_failure("target.store"); + share_unavailable_response(request) + } + }; + response +} + +pub(crate) fn open_desktop_host_bridge_share( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let share_state = app.state::(); + let share_text = match share_text_from_request(request, &share_state) { + Ok(text) => text, + Err(response) => return response, + }; + + match write_desktop_clipboard_text(app, &share_text) { + Ok(()) => ok( + request.id.clone(), + json!({ + "action": "copied_to_clipboard" + }), + ), + Err(_) => clipboard_write_unavailable_response(request), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + #[test] + fn share_text_uses_direct_share_payload() { + let state = DesktopShareState::default(); + let mut open = request("share.open"); + open.payload = Some(json!({ + "title": "测试作品", + "message": "来玩这个作品", + "url": "https://www.genarrative.world/works/detail?work=PZ-1" + })); + + let text = share_text_from_request(&open, &state).expect("share text"); + + assert_eq!( + text, + "测试作品\n来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" + ); + } + + #[test] + fn share_text_normalizes_public_urls_only() { + let state = DesktopShareState::default(); + let mut direct_path = request("share.open"); + direct_path.payload = Some(json!({ + "title": "测试作品", + "path": "/works/detail?work=PZ-1" + })); + + let path_text = share_text_from_request(&direct_path, &state).expect("path share text"); + + assert_eq!( + path_text, + "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" + ); + + let mut work = request("share.open"); + work.payload = Some(json!({ + "title": "测试作品", + "work": "PZ 1/二" + })); + + let work_text = share_text_from_request(&work, &state).expect("work share text"); + + assert_eq!( + work_text, + "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C" + ); + } + + #[test] + fn share_text_rejects_external_or_unsafe_urls_without_using_cached_target() { + let state = DesktopShareState::default(); + *state.target.lock().expect("share target lock") = Some(json!({ + "payload": { + "title": "缓存作品", + "work": "PZ-1" + } + })); + + for field in ["url", "href", "path", "targetPath"] { + let mut open = request("share.open"); + open.payload = Some(json!({ + "title": "危险作品", + field: if field == "path" { + "//evil.example/works/detail?work=PZ-1" + } else { + "https://evil.example/works/detail?work=PZ-1" + } + })); + + let response = share_text_from_request(&open, &state).expect_err("invalid share url"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "share target is invalid"); + } + + let mut javascript_url = request("share.open"); + javascript_url.payload = Some(json!({ + "title": "危险作品", + "url": "javascript:alert(1)" + })); + + assert!( + !share_text_from_request(&javascript_url, &state) + .expect_err("unsafe share url") + .ok + ); + } + + #[test] + fn share_text_uses_stored_work_target() { + let state = DesktopShareState::default(); + let mut set_target = request("share.setTarget"); + set_target.payload = Some(json!({ + "target": { + "type": "genarrative:share-target", + "payload": { + "work": "PZ-1", + "title": "测试作品" + } + } + })); + let target = set_target + .payload + .as_ref() + .and_then(|payload| payload.get("target")) + .expect("target"); + *state.target.lock().expect("share target lock") = Some(target.clone()); + + let text = share_text_from_request(&request("share.open"), &state).expect("share text"); + + assert_eq!( + text, + "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" + ); + } + + #[test] + fn share_target_payload_must_be_valid_before_cache() { + assert!(matches!( + share_text_from_value(&json!({})), + DesktopSharePayload::Empty + )); + assert!(matches!( + share_text_from_value(&json!({ + "title": "危险作品", + "url": "https://example.com/works/detail?work=PZ-1" + })), + DesktopSharePayload::Invalid + )); + assert!(matches!( + share_text_from_value(&json!({ + "title": "测试作品", + "work": "PZ-1" + })), + DesktopSharePayload::Valid(_) + )); + } + + #[test] + fn share_text_requires_payload_or_stored_target() { + let state = DesktopShareState::default(); + let response = + share_text_from_request(&request("share.open"), &state).expect_err("missing target"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "share target is required"); + } + + #[test] + fn share_unavailable_response_is_stable() { + let response = share_unavailable_response(&request("share.open")); + + assert!(!response.ok); + let error = response.error.expect("share error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "share unavailable"); + } + + #[test] + fn share_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_share_failure("target.lock")); + assert!(!log_desktop_share_failure("target.store")); + } + + #[test] + fn share_failures_log_stable_label_only() { + assert!(!log_desktop_share_failure("target.lock")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/title.rs b/apps/desktop-shell/src-tauri/src/host_bridge/title.rs new file mode 100644 index 000000000..6b834bba9 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/title.rs @@ -0,0 +1,137 @@ +use crate::host_bridge::protocol::{ + failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse, +}; +use serde_json::json; +use tauri::Manager; + +const WINDOW_TITLE_MAX_LENGTH: usize = 80; + +fn normalize_window_title(raw_title: &str) -> Option { + let title = raw_title.trim(); + if title.is_empty() || title.chars().any(char::is_control) { + return None; + } + + Some(title.chars().take(WINDOW_TITLE_MAX_LENGTH).collect()) +} + +fn window_title_from_request( + request: &HostBridgeRequest, +) -> Result { + required_string_payload(request, "title") + .ok() + .and_then(normalize_window_title) + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required")) +} + +pub(crate) fn set_desktop_host_bridge_window_title( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let title = match window_title_from_request(request) { + Ok(title) => title, + Err(response) => return response, + }; + + match app.get_webview_window("main") { + Some(window) => match window.set_title(&title) { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(_error) => { + log_desktop_window_title_failure("set"); + window_title_unavailable_response(request) + } + }, + None => { + log_desktop_window_title_failure("window"); + window_title_unavailable_response(request) + } + } +} + +fn window_title_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "window title unavailable") +} + +fn log_desktop_window_title_failure(label: &str) -> bool { + eprintln!("desktop window title failed for {label}"); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + #[test] + fn window_title_normalization_requires_visible_text() { + assert_eq!( + normalize_window_title(" Genarrative "), + Some("Genarrative".to_string()) + ); + assert_eq!(normalize_window_title(""), None); + assert_eq!(normalize_window_title("Genarrative\nDev"), None); + + let long_title = "甲".repeat(120); + assert_eq!( + normalize_window_title(&long_title) + .expect("truncated title") + .chars() + .count(), + 80 + ); + } + + #[test] + fn window_title_request_rejects_missing_or_invalid_payload() { + for payload in [ + None, + Some(json!({})), + Some(json!({ "title": 1 })), + Some(json!({ "title": " " })), + Some(json!({ "title": "Genarrative\nDev" })), + ] { + let mut request = request("app.setTitle"); + request.payload = payload; + + let response = window_title_from_request(&request).expect_err("invalid title"); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "title is required"); + } + } + + #[test] + fn window_title_request_trims_and_truncates_shared_boundary() { + let mut request = request("app.setTitle"); + request.payload = Some(json!({ + "title": format!(" {} ", "甲".repeat(120)) + })); + + let title = window_title_from_request(&request).expect("window title"); + + assert_eq!(title.chars().count(), WINDOW_TITLE_MAX_LENGTH); + assert!(title.chars().all(|character| character == '甲')); + } + + #[test] + fn window_title_unavailable_response_is_stable() { + let response = window_title_unavailable_response(&request("app.setTitle")); + + assert!(!response.ok); + let error = response.error.expect("window title error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "window title unavailable"); + } + + #[test] + fn window_title_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_window_title_failure("set")); + assert!(!log_desktop_window_title_failure("window")); + } + + #[test] + fn window_title_failures_log_stable_label_only() { + assert!(!log_desktop_window_title_failure("set")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs new file mode 100644 index 000000000..02f9d2116 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -0,0 +1,9 @@ +#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")] + +mod app; +mod host_bridge; +mod shell; + +fn main() { + app::run(); +} diff --git a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs new file mode 100644 index 000000000..ebc4e81f4 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs @@ -0,0 +1,319 @@ +use crate::shell::lifecycle::log_desktop_host_event_result; +use crate::shell::tray::show_main_window; +use crate::shell::webview::{desktop_h5_url_with_host_context, WEB_APP_ORIGIN}; +use tauri::{Manager, Url, WebviewWindow}; +use tauri_plugin_deep_link::DeepLinkExt; + +const DESKTOP_DEEP_LINK_HOSTS: [&str; 2] = ["open", "app"]; + +fn extract_path_from_custom_scheme(url: &Url) -> String { + let host = url.host_str().unwrap_or_default(); + if DESKTOP_DEEP_LINK_HOSTS.contains(&host) { + return format!( + "{}{}{}", + url.path(), + url.query() + .map(|query| format!("?{query}")) + .unwrap_or_default(), + url.fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default(), + ); + } + + format!( + "{}{}{}{}", + if host.is_empty() { "" } else { "/" }, + host, + url.path(), + url.query() + .map(|query| format!("?{query}")) + .unwrap_or_default(), + ) + &url + .fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default() +} + +pub(crate) fn normalize_desktop_deep_link_url(raw_url: &Url) -> Option { + let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; + let target_path = match raw_url.scheme() { + "genarrative" => extract_path_from_custom_scheme(raw_url), + "https" if raw_url.origin() == base_url.origin() => { + format!( + "{}{}{}", + raw_url.path(), + raw_url + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(), + raw_url + .fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default(), + ) + } + _ => return None, + }; + let target_url = base_url.join(&target_path).ok()?; + if target_url.origin() != base_url.origin() { + return None; + } + + desktop_h5_url_with_host_context(target_url) +} + +fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) -> tauri::Result<()> { + let Some(target_url) = normalize_desktop_deep_link_url(url) else { + return Ok(()); + }; + + window.navigate(target_url)?; + show_main_window(window.app_handle()) +} + +fn log_desktop_deep_link_open_result(result: tauri::Result<()>) -> bool { + log_desktop_host_event_result("deep_link.open", result) +} + +fn log_desktop_deep_link_register_result( + result: Result<(), tauri_plugin_deep_link::Error>, +) -> bool { + match result { + Ok(()) => true, + Err(_error) => { + eprintln!("desktop host event failed for deep_link.register"); + false + } + } +} + +fn log_desktop_deep_link_current_result( + result: Result>, tauri_plugin_deep_link::Error>, +) -> Option> { + match result { + Ok(urls) => urls, + Err(_error) => { + eprintln!("desktop host event failed for deep_link.current"); + None + } + } +} + +fn log_desktop_deep_link_window_missing(stage: &str) -> bool { + eprintln!("desktop host event failed for deep_link.window: main window unavailable during {stage}"); + false +} + +pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Result<()> { + let app_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + let Some(window) = app_handle.get_webview_window("main") else { + log_desktop_deep_link_window_missing("open"); + return; + }; + + for url in event.urls() { + log_desktop_deep_link_open_result(open_desktop_deep_link_url(&window, &url)); + } + }); + + let Some(window) = app.get_webview_window("main") else { + log_desktop_deep_link_window_missing("current"); + return Ok(()); + }; + if let Some(urls) = log_desktop_deep_link_current_result(app.deep_link().get_current()) { + for url in urls { + log_desktop_deep_link_open_result(open_desktop_deep_link_url(&window, &url)); + } + } + + Ok(()) +} + +pub(crate) fn register_desktop_deep_link_schemes(app: &tauri::App) -> bool { + log_desktop_deep_link_register_result(app.deep_link().register_all()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn normalized(raw_url: &str) -> Url { + normalize_desktop_deep_link_url(&Url::parse(raw_url).expect("deep link url")) + .expect("normalized deep link url") + } + + #[test] + fn custom_scheme_open_host_maps_to_same_origin_h5_route_with_host_context() { + let url = normalized("genarrative://open/works/detail?work=PZ-1#play"); + + assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN); + assert_eq!(url.path(), "/works/detail"); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "work").unwrap().1, + "PZ-1" + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "clientRuntime") + .unwrap() + .1, + "native_app" + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "hostShell") + .unwrap() + .1, + "tauri_desktop" + ); + assert_eq!(url.fragment(), Some("play")); + } + + #[test] + fn custom_scheme_without_reserved_host_keeps_host_as_first_path_segment() { + let url = normalized("genarrative://works/detail?work=PZ-1"); + + assert_eq!(url.path(), "/works/detail"); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "work").unwrap().1, + "PZ-1" + ); + } + + #[test] + fn same_origin_https_link_keeps_path_and_restores_desktop_host_context() { + let url = + normalized("https://www.genarrative.world/creation/puzzle?clientRuntime=browser#draft"); + + assert_eq!(url.path(), "/creation/puzzle"); + assert_eq!( + url.query_pairs() + .filter(|(key, _)| key == "clientRuntime") + .count(), + 1 + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "clientType") + .unwrap() + .1, + "native_app" + ); + assert_eq!(url.fragment(), Some("draft")); + } + + #[test] + fn external_and_unsafe_links_are_rejected() { + for raw_url in [ + "https://example.com/works/detail?work=PZ-1", + "http://www.genarrative.world/works/detail?work=PZ-1", + "mailto:hi@example.com", + "javascript:alert(1)", + ] { + let url = Url::parse(raw_url).expect("url"); + assert_eq!(normalize_desktop_deep_link_url(&url), None); + } + } + + #[test] + fn desktop_deep_link_config_check_requires_custom_scheme() { + let configured = serde_json::json!({ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["genarrative"] + } + } + } + }); + let missing = serde_json::json!({ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["example"] + } + } + } + }); + + assert_eq!( + configured + .get("plugins") + .and_then(|plugins| plugins.get("deep-link")) + .and_then(|deep_link| deep_link.get("desktop")) + .and_then(|desktop| desktop.get("schemes")) + .and_then(serde_json::Value::as_array) + .and_then(|schemes| schemes.first()) + .and_then(serde_json::Value::as_str), + Some("genarrative") + ); + assert_ne!( + missing + .get("plugins") + .and_then(|plugins| plugins.get("deep-link")) + .and_then(|deep_link| deep_link.get("desktop")) + .and_then(|desktop| desktop.get("schemes")) + .and_then(serde_json::Value::as_array) + .and_then(|schemes| schemes.first()) + .and_then(serde_json::Value::as_str), + Some("genarrative") + ); + } + + #[test] + fn desktop_deep_link_open_result_reports_success_and_failure() { + assert!(log_desktop_deep_link_open_result(Ok(()))); + assert!(!log_desktop_deep_link_open_result(Err( + tauri::Error::AssetNotFound("deep-link".to_string()) + ))); + } + + #[test] + fn desktop_deep_link_register_result_reports_success_and_failure() { + assert!(log_desktop_deep_link_register_result(Ok(()))); + assert!(!log_desktop_deep_link_register_result(Err( + tauri_plugin_deep_link::Error::UnsupportedPlatform + ))); + } + + #[test] + fn desktop_deep_link_register_result_logs_stable_label_only() { + assert!(!log_desktop_deep_link_register_result(Err( + tauri_plugin_deep_link::Error::UnsupportedPlatform + ))); + } + + #[test] + fn desktop_deep_link_current_result_reports_success_empty_and_failure() { + let urls = vec![Url::parse("genarrative://open/works/detail?work=PZ-1").expect("url")]; + assert_eq!( + log_desktop_deep_link_current_result(Ok(Some(urls.clone()))), + Some(urls) + ); + assert_eq!(log_desktop_deep_link_current_result(Ok(None)), None); + assert_eq!( + log_desktop_deep_link_current_result(Err( + tauri_plugin_deep_link::Error::UnsupportedPlatform + )), + None + ); + } + + #[test] + fn desktop_deep_link_current_result_logs_stable_label_only() { + assert_eq!( + log_desktop_deep_link_current_result(Err( + tauri_plugin_deep_link::Error::UnsupportedPlatform + )), + None + ); + } + + #[test] + fn desktop_deep_link_window_missing_reports_failure() { + assert!(!log_desktop_deep_link_window_missing("open")); + assert!(!log_desktop_deep_link_window_missing("current")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/events.rs b/apps/desktop-shell/src-tauri/src/shell/events.rs new file mode 100644 index 000000000..db93ec6d1 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/events.rs @@ -0,0 +1,73 @@ +use crate::host_bridge::protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION}; +use serde_json::{json, Value}; + +const HOST_BRIDGE_EVENTS: [&str; 3] = [ + "app.lifecycle", + "navigation.canGoBack", + "file.imageDropped", +]; + +fn is_host_bridge_event_name(event: &str) -> bool { + HOST_BRIDGE_EVENTS.contains(&event) +} + +pub(crate) fn host_bridge_event_script( + event: &str, + payload: Value, +) -> Result { + if !is_host_bridge_event_name(event) { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown HostBridge event: {}", event), + ))); + } + + let message = json!({ + "bridge": HOST_BRIDGE_PROTOCOL, + "version": HOST_BRIDGE_VERSION, + "event": event, + "payload": payload, + }); + let data = serde_json::to_string(&message)?; + let data_literal = serde_json::to_string(&data)?; + + Ok(format!( + "window.dispatchEvent(new MessageEvent('message', {{ data: {}, origin: window.location.origin, source: window }})); true;", + data_literal + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn host_bridge_event_script_dispatches_lifecycle_message() { + let script = host_bridge_event_script( + "app.lifecycle", + json!({ + "state": "active", + "focused": true, + "nativeState": "focused", + }), + ) + .expect("event script"); + + assert!(script.contains("MessageEvent('message'")); + assert!(script.contains("origin: window.location.origin")); + assert!(script.contains("source: window")); + assert!(script.contains("GenarrativeHostBridge")); + assert!(script.contains("app.lifecycle")); + assert!(script.contains("\\\"state\\\":\\\"active\\\"")); + assert!(script.contains("\\\"focused\\\":true")); + } + + #[test] + fn host_bridge_event_script_rejects_unknown_events() { + let error = + host_bridge_event_script("unknown.event", json!({})).expect_err("unknown event"); + + assert!(error.to_string().contains("unknown HostBridge event")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs new file mode 100644 index 000000000..b0194ba1d --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs @@ -0,0 +1,169 @@ +use crate::host_bridge::file_payloads::{import_image_file_payload, import_image_mime_type}; +use crate::shell::events::host_bridge_event_script; +use crate::shell::lifecycle::log_desktop_host_event_result; +use serde_json::Value; +use std::path::PathBuf; +use tauri::{DragDropEvent, WebviewWindow, WindowEvent}; + +fn first_valid_desktop_image_drop_payload( + paths: &[PathBuf], + position: (i32, i32), +) -> Option { + paths.iter().find_map(|path| { + if !path.is_file() || import_image_mime_type(path).is_none() { + return None; + } + + match import_image_file_payload(path.clone(), "dropped", Some(position)) { + Ok(payload) => Some(payload), + Err(_error) => { + log_desktop_image_drop_payload_failure(); + None + } + } + }) +} + +fn log_desktop_image_drop_payload_failure() -> bool { + eprintln!("desktop host event failed for file.imageDropped.payload"); + false +} + +fn normalize_desktop_drop_position(x: f64, y: f64) -> (i32, i32) { + (x.round().max(0.0) as i32, y.round().max(0.0) as i32) +} + +fn emit_desktop_image_drop_event( + window: &WebviewWindow, + paths: &[PathBuf], + position: (i32, i32), +) -> tauri::Result<()> { + let Some(payload) = first_valid_desktop_image_drop_payload(paths, position) else { + return Ok(()); + }; + let script = + host_bridge_event_script("file.imageDropped", payload).map_err(tauri::Error::Json)?; + + window.eval(script) +} + +pub(crate) fn register_desktop_file_drop_events(window: &WebviewWindow) { + let drop_window = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event { + let drop_position = normalize_desktop_drop_position(position.x, position.y); + log_desktop_host_event_result( + "file.imageDropped", + emit_desktop_image_drop_event(&drop_window, paths, drop_position), + ); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn png_bytes() -> Vec { + vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] + } + + #[test] + fn image_drop_skips_disguised_image_before_valid_image() { + let invalid_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-invalid-{}.png", + std::process::id() + )); + let valid_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-valid-{}.png", + std::process::id() + )); + fs::write(&invalid_path, b"text").expect("write invalid drop image"); + fs::write(&valid_path, png_bytes()).expect("write valid drop image"); + + let payload = first_valid_desktop_image_drop_payload( + &[invalid_path.clone(), valid_path.clone()], + (7, 9), + ) + .expect("valid image drop payload"); + + assert_eq!( + payload["fileName"], + valid_path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["position"], serde_json::json!({ "x": 7, "y": 9 })); + + fs::remove_file(invalid_path).expect("remove invalid drop image"); + fs::remove_file(valid_path).expect("remove valid drop image"); + } + + #[test] + fn image_drop_logs_invalid_candidate_then_uses_next_valid_image() { + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-disguised-{}.png", + std::process::id() + )); + let valid_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-valid-after-error-{}.png", + std::process::id() + )); + fs::write(&disguised_path, b"text").expect("write disguised drop image"); + fs::write(&valid_path, png_bytes()).expect("write valid drop image"); + + let payload = first_valid_desktop_image_drop_payload( + &[disguised_path.clone(), valid_path.clone()], + (11, 13), + ) + .expect("valid image drop payload after invalid candidate"); + + assert_eq!( + payload["fileName"], + valid_path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["position"], serde_json::json!({ "x": 11, "y": 13 })); + + fs::remove_file(disguised_path).expect("remove disguised drop image"); + fs::remove_file(valid_path).expect("remove valid drop image"); + } + + #[test] + fn image_drop_payload_failure_reports_failure() { + assert!(!log_desktop_image_drop_payload_failure()); + } + + #[test] + fn image_drop_payload_failure_logs_stable_label_only() { + assert!(!log_desktop_image_drop_payload_failure()); + } + + #[test] + fn image_drop_without_valid_image_does_not_emit_payload() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-text-{}.txt", + std::process::id() + )); + let directory_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-directory-{}", + std::process::id() + )); + fs::write(&text_path, b"not an image").expect("write text drop file"); + fs::create_dir_all(&directory_path).expect("create drop directory"); + + let payload = first_valid_desktop_image_drop_payload( + &[directory_path.clone(), text_path.clone()], + (3, 5), + ); + + assert_eq!(payload, None); + + fs::remove_file(text_path).expect("remove text drop file"); + fs::remove_dir(directory_path).expect("remove drop directory"); + } + + #[test] + fn desktop_drop_position_is_rounded_and_clamped_to_window_bounds() { + assert_eq!(normalize_desktop_drop_position(12.4, 24.6), (12, 25)); + assert_eq!(normalize_desktop_drop_position(-4.2, -0.6), (0, 0)); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs new file mode 100644 index 000000000..5932e1028 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs @@ -0,0 +1,243 @@ +use crate::shell::events::host_bridge_event_script; +use crate::shell::navigation::register_desktop_navigation_events; +use serde_json::json; +use tauri::webview::PageLoadEvent; +use tauri::{WebviewWindow, WindowEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DesktopLifecyclePayload { + state: &'static str, + focused: bool, + native_state: &'static str, +} + +pub(crate) fn resolve_desktop_lifecycle_payload( + focused: bool, + visible: bool, + minimized: bool, +) -> DesktopLifecyclePayload { + if !visible { + return DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "hidden", + }; + } + + if minimized { + return DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "minimized", + }; + } + + if focused { + DesktopLifecyclePayload { + state: "active", + focused: true, + native_state: "focused", + } + } else { + DesktopLifecyclePayload { + state: "inactive", + focused: false, + native_state: "blurred", + } + } +} + +pub(crate) fn emit_desktop_lifecycle_event( + window: &WebviewWindow, + state: &'static str, + focused: bool, + native_state: &'static str, +) -> tauri::Result<()> { + let script = host_bridge_event_script( + "app.lifecycle", + json!({ + "state": state, + "focused": focused, + "nativeState": native_state, + }), + ) + .map_err(tauri::Error::Json)?; + + window.eval(script) +} + +pub(crate) fn emit_desktop_lifecycle_payload( + window: &WebviewWindow, + payload: DesktopLifecyclePayload, +) -> tauri::Result<()> { + emit_desktop_lifecycle_event(window, payload.state, payload.focused, payload.native_state) +} + +fn resolve_desktop_lifecycle_window_flag( + label: &'static str, + result: tauri::Result, + default_value: bool, +) -> bool { + match result { + Ok(value) => value, + Err(_error) => { + eprintln!("desktop host event failed for app.lifecycle.{label}"); + default_value + } + } +} + +pub(crate) fn emit_current_desktop_lifecycle_event(window: &WebviewWindow) -> tauri::Result<()> { + let visible = + resolve_desktop_lifecycle_window_flag("visible", window.is_visible(), true); + let minimized = + resolve_desktop_lifecycle_window_flag("minimized", window.is_minimized(), false); + let focused = resolve_desktop_lifecycle_window_flag( + "focused", + window.is_focused(), + visible && !minimized, + ); + emit_desktop_lifecycle_payload( + window, + resolve_desktop_lifecycle_payload(focused, visible, minimized), + ) +} + +pub(crate) fn register_desktop_lifecycle_events(window: &WebviewWindow) { + let lifecycle_window = window.clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(_) | WindowEvent::Resized(_)) { + log_desktop_host_event_result( + "app.lifecycle", + emit_current_desktop_lifecycle_event(&lifecycle_window), + ); + } + }); +} + +pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEvent) -> bool { + event == PageLoadEvent::Finished +} + +pub(crate) fn log_desktop_host_event_result( + label: &'static str, + result: tauri::Result<()>, +) -> bool { + match result { + Ok(()) => true, + Err(_error) => { + eprintln!("desktop host event failed for {label}"); + false + } + } +} + +pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { + log_desktop_host_event_result( + "app.lifecycle", + emit_current_desktop_lifecycle_event(window), + ); + log_desktop_host_event_result( + "navigation.canGoBack", + register_desktop_navigation_events(window), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_page_load_replays_state_only_after_finished_load() { + assert!(should_replay_desktop_webview_state_on_page_load( + PageLoadEvent::Finished + )); + assert!(!should_replay_desktop_webview_state_on_page_load( + PageLoadEvent::Started + )); + } + + #[test] + fn desktop_host_event_result_reports_success_and_failure() { + assert!(log_desktop_host_event_result("network", Ok(()))); + assert!(!log_desktop_host_event_result( + "navigation", + Err(tauri::Error::AssetNotFound("navigation".to_string())) + )); + } + + #[test] + fn desktop_host_event_result_logs_stable_label_only() { + assert!(!log_desktop_host_event_result( + "navigation.canGoBack", + Err(tauri::Error::AssetNotFound( + "navigation?token=private".to_string() + )) + )); + } + + #[test] + fn desktop_lifecycle_window_flag_logs_failure_and_uses_default() { + assert!(resolve_desktop_lifecycle_window_flag( + "visible", + Ok(true), + false + )); + assert!(!resolve_desktop_lifecycle_window_flag( + "focused", + Err(tauri::Error::WindowNotFound), + false + )); + } + + #[test] + fn desktop_lifecycle_window_flag_logs_stable_label_only() { + assert!(!resolve_desktop_lifecycle_window_flag( + "focused", + Err(tauri::Error::AssetNotFound( + "window?token=private".to_string() + )), + false + )); + } + + #[test] + fn desktop_lifecycle_payload_maps_hidden_and_minimized_to_background() { + assert_eq!( + resolve_desktop_lifecycle_payload(true, false, false), + DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "hidden", + } + ); + assert_eq!( + resolve_desktop_lifecycle_payload(true, true, true), + DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "minimized", + } + ); + } + + #[test] + fn desktop_lifecycle_payload_maps_visible_focus_state() { + assert_eq!( + resolve_desktop_lifecycle_payload(true, true, false), + DesktopLifecyclePayload { + state: "active", + focused: true, + native_state: "focused", + } + ); + assert_eq!( + resolve_desktop_lifecycle_payload(false, true, false), + DesktopLifecyclePayload { + state: "inactive", + focused: false, + native_state: "blurred", + } + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/mod.rs b/apps/desktop-shell/src-tauri/src/shell/mod.rs new file mode 100644 index 000000000..a7aaabda4 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/mod.rs @@ -0,0 +1,11 @@ +pub(crate) mod deep_link; +mod events; +mod file_drop; +mod lifecycle; +mod navigation; +mod network; +mod runtime; +pub(crate) mod tray; +mod url; +pub(crate) mod webview; +pub(crate) mod window_state; diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs new file mode 100644 index 000000000..cc50e4cb1 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -0,0 +1,436 @@ +use crate::shell::events::host_bridge_event_script; +use crate::shell::lifecycle::log_desktop_host_event_result; +use crate::shell::url::{desktop_h5_url_with_host_context, WEB_APP_ORIGIN}; +use serde_json::{json, Value}; +use tauri::webview::DownloadEvent; +use tauri::{Url, WebviewWindow}; +use tauri_plugin_opener::OpenerExt; + +const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; + +pub(crate) fn normalize_external_url(raw_url: &str) -> Option { + let url = raw_url.trim(); + if url.is_empty() || url.chars().any(char::is_control) { + return None; + } + + let parsed_url = Url::parse(url).ok()?; + let protocol_with_colon = format!("{}:", parsed_url.scheme().to_ascii_lowercase()); + if !EXTERNAL_URL_PROTOCOLS.contains(&protocol_with_colon.as_str()) { + return None; + } + + Some(parsed_url.to_string()) +} + +pub(crate) fn desktop_navigation_can_go_back_payload(can_go_back: bool) -> Value { + json!({ + "canGoBack": can_go_back, + }) +} + +pub(crate) fn desktop_navigation_state_script() -> Result { + let can_go_back_script = host_bridge_event_script( + "navigation.canGoBack", + desktop_navigation_can_go_back_payload(true), + )?; + let cannot_go_back_script = host_bridge_event_script( + "navigation.canGoBack", + desktop_navigation_can_go_back_payload(false), + )?; + + // 中文注释:桌面壳只追踪当前 H5 文档内由应用写入的 history index,不读取平台私有 back stack。 + Ok(format!( + "(() => {{ + if (window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__) {{ + if (typeof window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__ === 'function') {{ + window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__(); + }} + return true; + }} + + const historyIndexKey = '__genarrativeDesktopHistoryIndex'; + const readIndex = (state) => {{ + if (!state || typeof state !== 'object') {{ + return null; + }} + const value = state[historyIndexKey]; + return Number.isInteger(value) && value >= 0 ? value : null; + }}; + const stateWithIndex = (state, index) => {{ + if (state && typeof state === 'object' && !Array.isArray(state)) {{ + return {{ ...state, [historyIndexKey]: index }}; + }} + return {{ [historyIndexKey]: index }}; + }}; + const originalPushState = window.history.pushState.bind(window.history); + const originalReplaceState = window.history.replaceState.bind(window.history); + let currentIndex = readIndex(window.history.state) ?? 0; + const emitCanGoBack = () => {{ {} }}; + const emitCannotGoBack = () => {{ {} }}; + const emitNavigationState = () => {{ + if (currentIndex > 0) {{ + emitCanGoBack(); + }} else {{ + emitCannotGoBack(); + }} + }}; + const replaceCurrentState = () => {{ + try {{ + originalReplaceState(stateWithIndex(window.history.state, currentIndex), '', window.location.href); + }} catch (error) {{ + console.warn('desktop navigation state sync failed'); + }} + }}; + + window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__ = emitNavigationState; + window.history.pushState = (state, title, url) => {{ + const nextIndex = currentIndex + 1; + const result = originalPushState(stateWithIndex(state, nextIndex), title, url); + currentIndex = nextIndex; + emitNavigationState(); + return result; + }}; + window.history.replaceState = (state, title, url) => {{ + const result = originalReplaceState(stateWithIndex(state, currentIndex), title, url); + emitNavigationState(); + return result; + }}; + window.addEventListener('popstate', (event) => {{ + const nextIndex = readIndex(event.state); + currentIndex = nextIndex ?? 0; + if (nextIndex === null) {{ + replaceCurrentState(); + }} + emitNavigationState(); + }}); + window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__ = true; + replaceCurrentState(); + emitNavigationState(); + return true; + }})();", + can_go_back_script, cannot_go_back_script + )) +} + +pub(crate) fn register_desktop_navigation_events(window: &WebviewWindow) -> tauri::Result<()> { + window.eval(desktop_navigation_state_script().map_err(tauri::Error::Json)?) +} + +fn is_desktop_packaged_asset_url(url: &Url) -> bool { + if url.scheme() == "tauri" { + return true; + } + + matches!(url.scheme(), "http" | "https") + && url + .host_str() + .map(|host| host.ends_with(".localhost")) + .unwrap_or(false) +} + +pub(crate) fn should_allow_desktop_webview_navigation(url: &Url) -> bool { + if is_desktop_packaged_asset_url(url) { + return true; + } + + #[cfg(dev)] + if url.scheme() == "http" + && url.host_str() == Some("127.0.0.1") + && url.port_or_known_default() == Some(3000) + { + return true; + } + + if url.scheme() == "https" { + let base_url = Url::parse(WEB_APP_ORIGIN).ok(); + return base_url + .map(|base_url| url.origin() == base_url.origin()) + .unwrap_or(false); + } + + false +} + +pub(crate) fn desktop_external_navigation_url(url: &Url) -> Option { + if should_allow_desktop_webview_navigation(url) { + return None; + } + + normalize_external_url(url.as_str()) +} + +pub(crate) fn open_normalized_desktop_external_url( + app: &tauri::AppHandle, + external_url: String, +) -> tauri::Result<()> { + app.opener() + .open_url(external_url, None::<&str>) + .map_err(|error| tauri::Error::Anyhow(error.into())) +} + +pub(crate) fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) { + let Some(external_url) = desktop_external_navigation_url(url) else { + return; + }; + log_desktop_host_event_result( + "navigation.external", + open_normalized_desktop_external_url(app, external_url), + ); +} + +pub(crate) fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { + match event { + DownloadEvent::Requested { .. } => false, + DownloadEvent::Finished { .. } => true, + _ => false, + } +} + +fn log_desktop_webview_download_blocked(_url: &Url) -> bool { + eprintln!("desktop host event blocked for webview.download"); + false +} + +pub(crate) fn handle_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { + if let DownloadEvent::Requested { url, .. } = event { + log_desktop_webview_download_blocked(url); + } + + should_allow_desktop_webview_download(event) +} + +pub(crate) fn normalize_native_page_url(raw_url: &str) -> Option { + let url = raw_url.trim(); + if url.is_empty() || url.chars().any(char::is_control) { + return None; + } + + let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; + let normalized_url = base_url.join(url).ok()?; + if normalized_url.scheme() != "https" || normalized_url.origin() != base_url.origin() { + return None; + } + + desktop_h5_url_with_host_context(normalized_url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_url_normalization_allows_only_safe_protocols() { + assert_eq!( + normalize_external_url(" https://example.com/path "), + Some("https://example.com/path".to_string()) + ); + assert_eq!( + normalize_external_url("mailto:hi@example.com"), + Some("mailto:hi@example.com".to_string()) + ); + assert_eq!( + normalize_external_url("tel:+12345678"), + Some("tel:+12345678".to_string()) + ); + assert_eq!(normalize_external_url("javascript:alert(1)"), None); + assert_eq!(normalize_external_url("file:///etc/passwd"), None); + assert_eq!(normalize_external_url("http://exa mple.com/path"), None); + assert_eq!(normalize_external_url("https://example.com/\nnext"), None); + assert_eq!(normalize_external_url("/relative/path"), None); + } + + #[test] + fn desktop_webview_navigation_stays_on_packaged_or_same_origin_pages() { + let packaged_url = Url::parse("tauri://localhost/index.html").expect("packaged url"); + assert!(should_allow_desktop_webview_navigation(&packaged_url)); + assert_eq!(desktop_external_navigation_url(&packaged_url), None); + + let windows_packaged_url = + Url::parse("https://tauri.localhost/index.html").expect("windows packaged url"); + assert!(should_allow_desktop_webview_navigation( + &windows_packaged_url + )); + assert_eq!(desktop_external_navigation_url(&windows_packaged_url), None); + + let windows_http_packaged_url = + Url::parse("http://tauri.localhost/index.html").expect("windows http packaged url"); + assert!(should_allow_desktop_webview_navigation( + &windows_http_packaged_url + )); + assert_eq!( + desktop_external_navigation_url(&windows_http_packaged_url), + None + ); + + let same_origin_url = Url::parse("https://www.genarrative.world/works/detail?work=PZ-1") + .expect("same-origin url"); + assert!(should_allow_desktop_webview_navigation(&same_origin_url)); + assert_eq!(desktop_external_navigation_url(&same_origin_url), None); + + #[cfg(dev)] + { + let dev_url = + Url::parse("http://127.0.0.1:3000/works/detail?work=PZ-1").expect("dev url"); + assert!(should_allow_desktop_webview_navigation(&dev_url)); + assert_eq!(desktop_external_navigation_url(&dev_url), None); + } + } + + #[test] + fn desktop_webview_navigation_sends_external_urls_to_system_only() { + let external_url = Url::parse("https://example.com/path").expect("external url"); + assert!(!should_allow_desktop_webview_navigation(&external_url)); + assert_eq!( + desktop_external_navigation_url(&external_url), + Some("https://example.com/path".to_string()) + ); + + let mail_url = Url::parse("mailto:hi@example.com").expect("mail url"); + assert!(!should_allow_desktop_webview_navigation(&mail_url)); + assert_eq!( + desktop_external_navigation_url(&mail_url), + Some("mailto:hi@example.com".to_string()) + ); + + let unsafe_url = Url::parse("javascript:alert(1)").expect("unsafe url"); + assert!(!should_allow_desktop_webview_navigation(&unsafe_url)); + assert_eq!(desktop_external_navigation_url(&unsafe_url), None); + + let file_url = Url::parse("file:///etc/passwd").expect("file url"); + assert!(!should_allow_desktop_webview_navigation(&file_url)); + assert_eq!(desktop_external_navigation_url(&file_url), None); + } + + #[test] + fn desktop_webview_downloads_are_blocked_by_default() { + let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt"); + let url = Url::parse("https://www.genarrative.world/download.txt").expect("download url"); + let requested = DownloadEvent::Requested { + url: url.clone(), + destination: &mut destination, + }; + + assert!(!should_allow_desktop_webview_download(&requested)); + + let finished = DownloadEvent::Finished { + url, + path: None, + success: false, + }; + + assert!(should_allow_desktop_webview_download(&finished)); + } + + #[test] + fn desktop_webview_download_handler_logs_blocked_requests() { + let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt"); + let url = Url::parse("https://www.genarrative.world/download.txt").expect("download url"); + let requested = DownloadEvent::Requested { + url: url.clone(), + destination: &mut destination, + }; + + assert!(!log_desktop_webview_download_blocked(&url)); + assert!(!handle_desktop_webview_download(&requested)); + } + + #[test] + fn desktop_webview_download_blocked_logs_stable_label_only() { + let url = Url::parse( + "https://www.genarrative.world/download.txt?token=private&work=PZ-1", + ) + .expect("download url"); + + assert!(!log_desktop_webview_download_blocked(&url)); + } + + #[test] + fn native_page_url_normalization_allows_same_origin_routes() { + let route = + normalize_native_page_url("/works/detail?work=PZ-1").expect("same-origin route"); + assert_eq!(route.origin().ascii_serialization(), WEB_APP_ORIGIN); + assert_eq!(route.path(), "/works/detail"); + assert_eq!( + route + .query_pairs() + .find(|(key, _)| key == "work") + .unwrap() + .1, + "PZ-1" + ); + assert_eq!( + route + .query_pairs() + .find(|(key, _)| key == "clientRuntime") + .unwrap() + .1, + "native_app" + ); + assert_eq!( + route + .query_pairs() + .find(|(key, _)| key == "hostShell") + .unwrap() + .1, + "tauri_desktop" + ); + assert!(route + .query_pairs() + .any(|(key, value)| key == "hostCapabilities" && value.contains("host.getRuntime"))); + assert!(normalize_native_page_url("works/detail?work=PZ-1") + .expect("relative route") + .query_pairs() + .any(|(key, value)| key == "clientType" && value == "native_app")); + assert!( + normalize_native_page_url("https://www.genarrative.world/works/detail?work=PZ-1") + .expect("absolute same-origin route") + .query_pairs() + .any(|(key, value)| key == "hostShell" && value == "tauri_desktop") + ); + } + + #[test] + fn native_page_url_normalization_rejects_unsafe_routes() { + assert_eq!(normalize_native_page_url("https://example.com/works"), None); + assert_eq!(normalize_native_page_url("//example.com/works"), None); + assert_eq!(normalize_native_page_url("javascript:alert(1)"), None); + assert_eq!( + normalize_native_page_url("https://www.genarrative.world/\nnext"), + None + ); + } + + #[test] + fn desktop_navigation_can_go_back_payload_reports_boolean_state() { + assert_eq!( + desktop_navigation_can_go_back_payload(true), + json!({ + "canGoBack": true, + }) + ); + assert_eq!( + desktop_navigation_can_go_back_payload(false), + json!({ + "canGoBack": false, + }) + ); + } + + #[test] + fn desktop_navigation_state_script_tracks_h5_history_changes() { + let script = desktop_navigation_state_script().expect("navigation state script"); + + assert!(script.contains("navigation.canGoBack")); + assert!(script.contains("window.history.pushState")); + assert!(script.contains("window.history.replaceState")); + assert!(script.contains("window.addEventListener('popstate'")); + assert!(script.contains("__genarrativeDesktopHistoryIndex")); + assert!(script.contains("(currentIndex > 0)")); + assert!(script.contains("\\\"canGoBack\\\":true")); + assert!(script.contains("\\\"canGoBack\\\":false")); + assert!(script.contains("console.warn('desktop navigation state sync failed')")); + assert!(!script.contains("console.warn('desktop navigation state sync failed', error)")); + assert!(script.contains("window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/network.rs b/apps/desktop-shell/src-tauri/src/shell/network.rs new file mode 100644 index 000000000..bb71efb13 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/network.rs @@ -0,0 +1,139 @@ +use crate::shell::url::WEB_APP_ORIGIN; +use serde_json::{json, Value}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; +use tauri::Url; + +pub(crate) const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; + +fn desktop_network_probe_target() -> Option<(String, u16)> { + let url = Url::parse(WEB_APP_ORIGIN).ok()?; + if url.scheme() != "https" { + return None; + } + + Some(( + url.host_str()?.to_string(), + url.port_or_known_default()?, + )) +} + +pub(crate) fn desktop_network_status_payload(is_online: bool) -> Value { + json!({ + "isConnected": is_online, + "isInternetReachable": is_online, + "connectionType": if is_online { "unknown" } else { "none" }, + "nativeType": if is_online { "online" } else { "offline" }, + }) +} + +fn resolve_desktop_network_reachability( + target: Option<(String, u16)>, + timeout: Duration, +) -> Result { + let Some((host, port)) = target else { + return Err("network probe target unavailable".to_string()); + }; + + let addresses = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| format!("resolve {host}:{port} failed: {error}"))?; + let mut resolved_address = false; + let mut last_connect_error: Option = None; + + for address in addresses { + resolved_address = true; + match TcpStream::connect_timeout(&address, timeout) { + Ok(stream) => { + drop(stream); + return Ok(true); + } + Err(error) => { + last_connect_error = Some(error.to_string()); + } + } + } + + if !resolved_address { + return Err(format!("resolve {host}:{port} returned no addresses")); + } + + Err(format!( + "connect {host}:{port} failed: {}", + last_connect_error.unwrap_or_else(|| "unknown connection error".to_string()) + )) +} + +fn log_desktop_network_probe_failure(_reason: &str) { + eprintln!("desktop network reachability probe failed"); +} + +pub(crate) fn resolve_desktop_network_status() -> Value { + let timeout = Duration::from_millis(DESKTOP_NETWORK_CHECK_TIMEOUT_MS); + let is_reachable = + match resolve_desktop_network_reachability(desktop_network_probe_target(), timeout) { + Ok(is_reachable) => is_reachable, + Err(reason) => { + log_desktop_network_probe_failure(&reason); + false + } + }; + + desktop_network_status_payload(is_reachable) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn desktop_network_probe_target_uses_public_web_origin() { + let origin = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin"); + + assert_eq!( + desktop_network_probe_target(), + Some(( + origin.host_str().expect("desktop web host").to_string(), + origin + .port_or_known_default() + .expect("desktop web default port") + )) + ); + } + + #[test] + fn desktop_network_status_payload_reports_reachability() { + assert_eq!( + desktop_network_status_payload(true), + json!({ + "isConnected": true, + "isInternetReachable": true, + "connectionType": "unknown", + "nativeType": "online", + }) + ); + assert_eq!( + desktop_network_status_payload(false), + json!({ + "isConnected": false, + "isInternetReachable": false, + "connectionType": "none", + "nativeType": "offline", + }) + ); + } + + #[test] + fn desktop_network_reachability_reports_missing_probe_target() { + assert_eq!( + resolve_desktop_network_reachability(None, Duration::from_millis(1)), + Err("network probe target unavailable".to_string()) + ); + } + + #[test] + fn desktop_network_probe_failure_logs_stable_label_only() { + log_desktop_network_probe_failure("private network detail"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/runtime.rs b/apps/desktop-shell/src-tauri/src/shell/runtime.rs new file mode 100644 index 000000000..70522b8ab --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/runtime.rs @@ -0,0 +1,32 @@ +use tauri::Theme; + +pub(crate) fn desktop_platform() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "unknown" + } +} + +pub(crate) fn color_scheme_from_theme(theme: Theme) -> &'static str { + match theme { + Theme::Light => "light", + Theme::Dark => "dark", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_scheme_maps_window_theme() { + assert_eq!(color_scheme_from_theme(Theme::Light), "light"); + assert_eq!(color_scheme_from_theme(Theme::Dark), "dark"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/tray.rs b/apps/desktop-shell/src-tauri/src/shell/tray.rs new file mode 100644 index 000000000..7896f123e --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/tray.rs @@ -0,0 +1,210 @@ +use crate::shell::lifecycle::{ + emit_current_desktop_lifecycle_event, emit_desktop_lifecycle_event, + log_desktop_host_event_result, +}; +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{Manager, WebviewWindow, WindowEvent}; + +const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray"; +pub(crate) const TRAY_MENU_SHOW: &str = "show-main-window"; +pub(crate) const TRAY_MENU_RELOAD: &str = "reload-main-window"; +pub(crate) const TRAY_MENU_QUIT: &str = "quit-desktop-shell"; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DesktopTrayAction { + ShowMainWindow, + ReloadMainWindow, + QuitApp, + Ignore, +} + +#[derive(Debug, PartialEq, Eq)] +enum DesktopWindowCloseAction { + HideToTray, + CloseWindow, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DesktopSingleInstanceAction { + ShowMainWindow, +} + +pub(crate) fn resolve_desktop_tray_menu_action(menu_id: &str) -> DesktopTrayAction { + match menu_id { + TRAY_MENU_SHOW => DesktopTrayAction::ShowMainWindow, + TRAY_MENU_RELOAD => DesktopTrayAction::ReloadMainWindow, + TRAY_MENU_QUIT => DesktopTrayAction::QuitApp, + _ => DesktopTrayAction::Ignore, + } +} + +pub(crate) fn resolve_desktop_tray_icon_action( + button: MouseButton, + button_state: MouseButtonState, +) -> DesktopTrayAction { + if button == MouseButton::Left && button_state == MouseButtonState::Up { + DesktopTrayAction::ShowMainWindow + } else { + DesktopTrayAction::Ignore + } +} + +fn resolve_desktop_window_close_action(tray_registered: bool) -> DesktopWindowCloseAction { + if tray_registered { + DesktopWindowCloseAction::HideToTray + } else { + DesktopWindowCloseAction::CloseWindow + } +} + +pub(crate) fn resolve_desktop_single_instance_action() -> DesktopSingleInstanceAction { + DesktopSingleInstanceAction::ShowMainWindow +} + +pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + window.show()?; + window.unminimize()?; + window.set_focus()?; + emit_current_desktop_lifecycle_event(&window)?; + } + + Ok(()) +} + +pub(crate) fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + window.reload()?; + } + + Ok(()) +} + +fn handle_desktop_tray_action(app: &tauri::AppHandle, action: DesktopTrayAction) { + match action { + DesktopTrayAction::ShowMainWindow => { + log_desktop_host_event_result("tray.show", show_main_window(app)); + } + DesktopTrayAction::ReloadMainWindow => { + log_desktop_host_event_result("tray.reload", reload_main_window(app)); + } + DesktopTrayAction::QuitApp => app.exit(0), + DesktopTrayAction::Ignore => {} + } +} + +pub(crate) fn register_desktop_tray(app: &tauri::App) -> tauri::Result<()> { + let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?; + let reload_item = MenuItem::with_id(app, TRAY_MENU_RELOAD, "刷新", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&show_item, &reload_item, &quit_item])?; + let mut tray_builder = TrayIconBuilder::with_id(DESKTOP_TRAY_ID) + .menu(&tray_menu) + .tooltip("Genarrative") + .show_menu_on_left_click(false) + .on_menu_event(|app, event| { + let menu_id = event.id().0.as_str(); + handle_desktop_tray_action(app, resolve_desktop_tray_menu_action(menu_id)); + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button, + button_state, + .. + } = event + { + handle_desktop_tray_action( + tray.app_handle(), + resolve_desktop_tray_icon_action(button, button_state), + ); + } + }); + + if let Some(icon) = app.default_window_icon().cloned() { + tray_builder = tray_builder.icon(icon); + } + + tray_builder.build(app)?; + Ok(()) +} + +pub(crate) fn register_desktop_window_close_events(window: &WebviewWindow, tray_registered: bool) { + let close_window = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + if resolve_desktop_window_close_action(tray_registered) + == DesktopWindowCloseAction::HideToTray + { + api.prevent_close(); + // 中文注释:隐藏 WebView 前先通知 H5 暂停游戏循环和音频,避免隐藏后脚本执行被平台挂起。 + log_desktop_host_event_result( + "tray.close.lifecycle", + emit_desktop_lifecycle_event(&close_window, "background", false, "hidden"), + ); + log_desktop_host_event_result("tray.close.hide", close_window.hide()); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_tray_menu_ids_map_to_real_window_actions() { + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_SHOW), + DesktopTrayAction::ShowMainWindow + ); + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_RELOAD), + DesktopTrayAction::ReloadMainWindow + ); + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_QUIT), + DesktopTrayAction::QuitApp + ); + assert_eq!( + resolve_desktop_tray_menu_action("unknown"), + DesktopTrayAction::Ignore + ); + } + + #[test] + fn desktop_tray_left_click_restores_main_window_only_on_release() { + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Up), + DesktopTrayAction::ShowMainWindow + ); + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Down), + DesktopTrayAction::Ignore + ); + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Right, MouseButtonState::Up), + DesktopTrayAction::Ignore + ); + } + + #[test] + fn desktop_close_hides_to_tray_only_when_tray_is_registered() { + assert_eq!( + resolve_desktop_window_close_action(true), + DesktopWindowCloseAction::HideToTray + ); + assert_eq!( + resolve_desktop_window_close_action(false), + DesktopWindowCloseAction::CloseWindow + ); + } + + #[test] + fn desktop_single_instance_only_restores_existing_window() { + assert_eq!( + resolve_desktop_single_instance_action(), + DesktopSingleInstanceAction::ShowMainWindow + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/url.rs b/apps/desktop-shell/src-tauri/src/shell/url.rs new file mode 100644 index 000000000..8c2daadbf --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/url.rs @@ -0,0 +1,266 @@ +use crate::host_bridge::capabilities::capabilities; +use crate::host_bridge::protocol::HOST_BRIDGE_VERSION; +use crate::shell::runtime::desktop_platform; +use std::path::PathBuf; +use tauri::{Url, WebviewUrl}; + +pub(crate) const WEB_APP_ORIGIN: &str = "https://www.genarrative.world"; +const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [ + "clientRuntime", + "clientType", + "hostShell", + "hostPlatform", + "hostVersion", + "bridgeVersion", + "hostCapabilities", +]; + +fn append_desktop_host_context(url: &mut Url) { + url.query_pairs_mut() + .append_pair("clientRuntime", "native_app") + .append_pair("clientType", "native_app") + .append_pair("hostShell", "tauri_desktop") + .append_pair("hostPlatform", desktop_platform()) + .append_pair("hostVersion", env!("CARGO_PKG_VERSION")) + .append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string()) + .append_pair("hostCapabilities", &capabilities().join(",")); +} + +pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option { + let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; + if target_url.scheme() != "https" || target_url.origin() != base_url.origin() { + return None; + } + + let retained_query_pairs = target_url + .query_pairs() + .filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref())) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + target_url + .query_pairs_mut() + .clear() + .extend_pairs( + retained_query_pairs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ); + append_desktop_host_context(&mut target_url); + + if target_url.origin() != base_url.origin() { + return None; + } + + Some(target_url) +} + +fn append_desktop_host_context_to_url(mut url: Url) -> String { + let retained_query_pairs = url + .query_pairs() + .filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref())) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + url.query_pairs_mut() + .clear() + .extend_pairs( + retained_query_pairs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ); + append_desktop_host_context(&mut url); + + url.to_string() +} + +pub(crate) fn desktop_entry_url_with_host_context(raw_url: &str) -> String { + if let Ok(url) = Url::parse(raw_url) { + return append_desktop_host_context_to_url(url); + } + + let (without_hash, hash) = raw_url + .split_once('#') + .map(|(path, hash)| (path, Some(hash))) + .unwrap_or((raw_url, None)); + let mut parts = without_hash.splitn(2, '?'); + let path = parts.next().unwrap_or_default(); + let query = parts.next(); + let mut pairs = query + .map(|query| { + query + .split('&') + .filter(|pair| { + if pair.is_empty() { + return false; + } + let key = pair.split_once('=').map(|(key, _)| key).unwrap_or(pair); + !HOST_CONTEXT_QUERY_KEYS.contains(&key) + }) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let mut context_url = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin"); + append_desktop_host_context(&mut context_url); + pairs.extend( + context_url + .query_pairs() + .map(|(key, value)| format!("{key}={value}")), + ); + let normalized_url = format!("{path}?{}", pairs.join("&")); + if let Some(hash) = hash { + format!("{normalized_url}#{hash}") + } else { + normalized_url + } +} + +pub(crate) fn desktop_window_config_with_runtime_platform( + mut config: tauri::utils::config::WindowConfig, +) -> tauri::utils::config::WindowConfig { + config.url = match config.url { + WebviewUrl::External(url) => WebviewUrl::External( + Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url), + ), + WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol( + Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url), + ), + WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_host_context( + path.to_string_lossy().as_ref(), + ))), + other => other, + }; + config +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_h5_url_with_host_context_rewrites_runtime_query_once() { + let platform = desktop_platform(); + let url = Url::parse( + "https://www.genarrative.world/creation/puzzle?work=PZ-1&clientRuntime=browser&hostCapabilities=old#draft", + ) + .expect("desktop H5 url"); + let url = desktop_h5_url_with_host_context(url).expect("context url"); + + assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN); + assert_eq!(url.path(), "/creation/puzzle"); + assert_eq!(url.fragment(), Some("draft")); + assert_eq!( + url.query_pairs() + .filter(|(key, _)| key == "clientRuntime") + .count(), + 1 + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "clientRuntime") + .unwrap() + .1, + "native_app" + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "hostPlatform") + .unwrap() + .1, + platform + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "hostVersion") + .unwrap() + .1, + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "hostCapabilities") + .unwrap() + .1, + capabilities().join(",") + ); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "work").unwrap().1, + "PZ-1" + ); + } + + #[test] + fn desktop_h5_url_with_host_context_rejects_non_h5_origin() { + let url = Url::parse("https://example.com/works/detail?work=PZ-1").expect("external url"); + + assert_eq!(desktop_h5_url_with_host_context(url), None); + } + + #[test] + fn desktop_entry_url_adds_host_context_from_plain_entries() { + let platform = desktop_platform(); + + let dev_url = desktop_entry_url_with_host_context("http://127.0.0.1:3000/"); + let dev_url = Url::parse(&dev_url).expect("dev url"); + + assert_eq!( + dev_url + .query_pairs() + .find(|(key, _)| key == "clientRuntime"), + Some(("clientRuntime".into(), "native_app".into())) + ); + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"), + Some(("hostPlatform".into(), platform.into())) + ); + assert_eq!( + dev_url + .query_pairs() + .filter(|(key, _)| key == "hostPlatform") + .count(), + 1 + ); + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostVersion"), + Some(("hostVersion".into(), env!("CARGO_PKG_VERSION").into())) + ); + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "bridgeVersion"), + Some(("bridgeVersion".into(), HOST_BRIDGE_VERSION.to_string().into())) + ); + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostCapabilities"), + Some(("hostCapabilities".into(), capabilities().join(",").into())) + ); + + let packaged_url = desktop_entry_url_with_host_context("index.html#works"); + + assert!(packaged_url.contains("clientRuntime=native_app")); + assert!(packaged_url.contains("clientType=native_app")); + assert!(packaged_url.contains("hostShell=tauri_desktop")); + assert!(packaged_url.contains(&format!("hostPlatform={platform}"))); + assert!(packaged_url.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION")))); + assert!(packaged_url.contains(&format!("bridgeVersion={HOST_BRIDGE_VERSION}"))); + assert!(packaged_url.contains(&format!("hostCapabilities={}", capabilities().join(",")))); + assert!(packaged_url.ends_with("#works")); + } + + #[test] + fn desktop_entry_url_removes_stale_host_context_before_appending_current_context() { + let url = desktop_entry_url_with_host_context( + "index.html?clientRuntime=browser&hostShell=old_shell&hostCapabilities=old&work=PZ-1#works", + ); + let query = url.split_once('?').expect("context query").1; + + assert!(url.contains("work=PZ-1")); + assert!(url.contains("clientRuntime=native_app")); + assert!(url.contains("hostShell=tauri_desktop")); + assert!(url.contains(&format!("hostCapabilities={}", capabilities().join(",")))); + assert_eq!(query.matches("clientRuntime=").count(), 1); + assert_eq!(query.matches("hostShell=").count(), 1); + assert_eq!(query.matches("hostCapabilities=").count(), 1); + assert!(!url.contains("clientRuntime=browser")); + assert!(!url.contains("hostShell=old_shell")); + assert!(!url.contains("hostCapabilities=old")); + assert!(url.ends_with("#works")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs new file mode 100644 index 000000000..369a1b3f7 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -0,0 +1,17 @@ +pub(crate) use crate::shell::file_drop::register_desktop_file_drop_events; +pub(crate) use crate::shell::lifecycle::{ + emit_current_desktop_lifecycle_event, log_desktop_host_event_result, + register_desktop_lifecycle_events, replay_desktop_webview_state, + should_replay_desktop_webview_state_on_page_load, +}; +pub(crate) use crate::shell::navigation::{ + desktop_external_navigation_url, handle_desktop_webview_download, normalize_external_url, + normalize_native_page_url, open_desktop_external_navigation, + open_normalized_desktop_external_url, register_desktop_navigation_events, + should_allow_desktop_webview_navigation, +}; +pub(crate) use crate::shell::network::resolve_desktop_network_status; +pub(crate) use crate::shell::runtime::{color_scheme_from_theme, desktop_platform}; +pub(crate) use crate::shell::url::{ + desktop_h5_url_with_host_context, desktop_window_config_with_runtime_platform, WEB_APP_ORIGIN, +}; diff --git a/apps/desktop-shell/src-tauri/src/shell/window_state.rs b/apps/desktop-shell/src-tauri/src/shell/window_state.rs new file mode 100644 index 000000000..43f254c8b --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/window_state.rs @@ -0,0 +1,30 @@ +use tauri::plugin::TauriPlugin; +use tauri::Runtime; +use tauri_plugin_window_state::StateFlags; + +fn desktop_window_state_flags() -> StateFlags { + StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED +} + +pub(crate) fn desktop_window_state_plugin() -> TauriPlugin { + tauri_plugin_window_state::Builder::default() + .with_state_flags(desktop_window_state_flags()) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_window_state_flags_keep_visible_state_out_of_persistence() { + let flags = desktop_window_state_flags(); + + assert!(flags.contains(StateFlags::SIZE)); + assert!(flags.contains(StateFlags::POSITION)); + assert!(flags.contains(StateFlags::MAXIMIZED)); + assert!(!flags.contains(StateFlags::VISIBLE)); + assert!(!flags.contains(StateFlags::FULLSCREEN)); + assert!(!flags.contains(StateFlags::DECORATIONS)); + } +} diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json new file mode 100644 index 000000000..ccc60c66b --- /dev/null +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Genarrative", + "version": "0.1.0", + "identifier": "world.genarrative.desktop", + "build": { + "beforeDevCommand": "npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port", + "beforeBuildCommand": "npm run typecheck", + "devUrl": "http://127.0.0.1:3000/" + }, + "app": { + "windows": [ + { + "create": false, + "label": "main", + "url": "https://www.genarrative.world/", + "title": "Genarrative", + "width": 1280, + "height": 820, + "minWidth": 960, + "minHeight": 640, + "devtools": false + } + ], + "security": { + "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: https: data: blob:; media-src 'self' asset: https: data: blob:; connect-src 'self' https: wss:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'", + "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* https: data: blob:; media-src 'self' asset: http://127.0.0.1:* https: data: blob:; connect-src 'self' http://127.0.0.1:* https: ws://127.0.0.1:* wss:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" + } + }, + "bundle": { + "active": true, + "targets": "all", + "macOS": { + "infoPlist": "Info.plist" + }, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png" + ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["genarrative"] + } + } + } +} diff --git a/apps/desktop-shell/tsconfig.json b/apps/desktop-shell/tsconfig.json new file mode 100644 index 000000000..fd4197110 --- /dev/null +++ b/apps/desktop-shell/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "strict": true, + "types": ["vite/client"] + }, + "include": [] +} diff --git a/apps/mobile-shell/App.tsx b/apps/mobile-shell/App.tsx new file mode 100644 index 000000000..0cf497498 --- /dev/null +++ b/apps/mobile-shell/App.tsx @@ -0,0 +1,5 @@ +import ShellApp from './src/shell/ShellApp'; + +export default function App() { + return ; +} diff --git a/apps/mobile-shell/app.json b/apps/mobile-shell/app.json new file mode 100644 index 000000000..b666f4948 --- /dev/null +++ b/apps/mobile-shell/app.json @@ -0,0 +1,138 @@ +{ + "expo": { + "name": "Genarrative", + "slug": "genarrative-mobile-shell", + "scheme": "genarrative", + "version": "0.1.0", + "orientation": "default", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "splash": { + "image": "./assets/icon.png", + "resizeMode": "contain", + "backgroundColor": "#fffdf9" + }, + "assetBundlePatterns": [ + "**/*" + ], + "updates": { + "enabled": false + }, + "plugins": [ + [ + "expo-camera", + { + "cameraPermission": "允许 Genarrative 使用相机扫描二维码。", + "microphonePermission": "允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。", + "recordAudioAndroid": true + } + ], + [ + "expo-image-picker", + { + "photosPermission": "允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。", + "cameraPermission": "允许 Genarrative 使用相机拍摄创作素材和参考图。", + "microphonePermission": "允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。" + } + ], + [ + "expo-notifications", + { + "enableBackgroundRemoteNotifications": false + } + ] + ], + "ios": { + "bundleIdentifier": "world.genarrative.mobile", + "buildNumber": "1", + "supportsTablet": true, + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false, + "NSMicrophoneUsageDescription": "允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。", + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": false + } + }, + "associatedDomains": [ + "applinks:www.genarrative.world" + ], + "privacyManifests": { + "NSPrivacyCollectedDataTypes": [], + "NSPrivacyTracking": false, + "NSPrivacyTrackingDomains": [], + "NSPrivacyAccessedAPITypes": [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons": [ + "0A2A.1", + "3B52.1", + "C617.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", + "NSPrivacyAccessedAPITypeReasons": [ + "85F4.1", + "E174.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", + "NSPrivacyAccessedAPITypeReasons": [ + "35F9.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons": [ + "CA92.1" + ] + } + ] + } + }, + "android": { + "package": "world.genarrative.mobile", + "versionCode": 1, + "usesCleartextTraffic": false, + "allowBackup": false, + "softwareKeyboardLayoutMode": "resize", + "permissions": [ + "android.permission.POST_NOTIFICATIONS", + "android.permission.RECORD_AUDIO" + ], + "blockedPermissions": [ + "android.permission.MANAGE_EXTERNAL_STORAGE", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.REQUEST_INSTALL_PACKAGES", + "android.permission.SCHEDULE_EXACT_ALARM", + "android.permission.USE_EXACT_ALARM", + "android.permission.WRITE_EXTERNAL_STORAGE" + ], + "adaptiveIcon": { + "foregroundImage": "./assets/icon.png", + "backgroundColor": "#fffdf9" + }, + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "www.genarrative.world" + } + ], + "category": [ + "BROWSABLE", + "DEFAULT" + ] + } + ] + }, + "extra": { + "genarrativeHostBridgeVersion": 1 + } + } +} diff --git a/apps/mobile-shell/assets/icon.png b/apps/mobile-shell/assets/icon.png new file mode 100644 index 000000000..bb5c6885c Binary files /dev/null and b/apps/mobile-shell/assets/icon.png differ diff --git a/apps/mobile-shell/eas.json b/apps/mobile-shell/eas.json new file mode 100644 index 000000000..83573bc41 --- /dev/null +++ b/apps/mobile-shell/eas.json @@ -0,0 +1,28 @@ +{ + "cli": { + "version": ">= 20.3.0", + "appVersionSource": "local" + }, + "build": { + "production": { + "distribution": "internal", + "channel": "production", + "android": { + "buildType": "apk" + }, + "env": { + "EXPO_NO_DOTENV": "1" + } + }, + "production-simulator": { + "distribution": "internal", + "channel": "production", + "ios": { + "simulator": true + }, + "env": { + "EXPO_NO_DOTENV": "1" + } + } + } +} diff --git a/apps/mobile-shell/package.json b/apps/mobile-shell/package.json new file mode 100644 index 000000000..7a146bc03 --- /dev/null +++ b/apps/mobile-shell/package.json @@ -0,0 +1,44 @@ +{ + "name": "@genarrative/mobile-shell", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "expo/AppEntry.js", + "scripts": { + "dev": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "test": "vitest run -c vitest.config.ts", + "build:android": "eas build --local --profile production --platform android --output ../../build/native/mobile/genarrative-mobile-android.apk", + "build:ios": "eas build --local --profile production-simulator --platform ios --output ../../build/native/mobile/genarrative-mobile-ios-simulator.tar.gz", + "build-artifacts:smoke": "node scripts/check-build-artifacts.mjs", + "build-config:smoke": "node scripts/check-eas-build-config.mjs", + "config:smoke": "node scripts/check-expo-config.mjs", + "export:smoke": "node scripts/check-expo-export.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" + }, + "dependencies": { + "@expo/metro-runtime": "^56.0.15", + "expo": "^56.0.12", + "expo-camera": "56.0.8", + "expo-clipboard": "^56.0.4", + "expo-document-picker": "^56.0.4", + "expo-file-system": "^56.0.8", + "expo-haptics": "^56.0.3", + "expo-image-picker": "^56.0.18", + "expo-linking": "^56.0.14", + "expo-network": "^56.0.5", + "expo-notifications": "^56.0.18", + "expo-sharing": "^56.0.18", + "expo-status-bar": "^56.0.4", + "react": "^19.0.0", + "react-native": "^0.86.0", + "react-native-safe-area-context": "^5.8.0", + "react-native-webview": "^13.16.1" + }, + "devDependencies": { + "eas-cli": "^20.3.0", + "typescript": "~5.8.2", + "vitest": "^0.34.6" + } +} diff --git a/apps/mobile-shell/scripts/check-build-artifacts.mjs b/apps/mobile-shell/scripts/check-build-artifacts.mjs new file mode 100644 index 000000000..fab3c8501 --- /dev/null +++ b/apps/mobile-shell/scripts/check-build-artifacts.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; + +const shellRoot = new URL('../', import.meta.url); +const repoRoot = path.resolve(shellRoot.pathname, '../..'); +const androidArtifactPath = path.join( + repoRoot, + 'build', + 'native', + 'mobile', + 'genarrative-mobile-android.apk', +); +const iosSimulatorArtifactPath = path.join( + repoRoot, + 'build', + 'native', + 'mobile', + 'genarrative-mobile-ios-simulator.tar.gz', +); + +function assertFile(filePath, label, minimumBytes) { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} is missing: ${filePath}`); + } + + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size < minimumBytes) { + throw new Error(`${label} must be a real build artifact`); + } +} + +function readHeader(filePath, length) { + const handle = fs.openSync(filePath, 'r'); + try { + const header = Buffer.alloc(length); + fs.readSync(handle, header, 0, header.length, 0); + return header; + } finally { + fs.closeSync(handle); + } +} + +function assertZipHeader(filePath, label) { + const header = readHeader(filePath, 4); + if ( + header[0] !== 0x50 || + header[1] !== 0x4b || + header[2] !== 0x03 || + header[3] !== 0x04 + ) { + throw new Error(`${label} must be a ZIP based artifact`); + } +} + +function assertGzipHeader(filePath, label) { + const header = readHeader(filePath, 2); + if (header[0] !== 0x1f || header[1] !== 0x8b) { + throw new Error(`${label} must be a gzip compressed tar artifact`); + } +} + +function run(command, args, label) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + }); + if (result.error) { + throw new Error(`${label} failed to start: ${result.error.message}`); + } + if ((result.status ?? 0) !== 0) { + throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim()}`); + } + return result.stdout; +} + +function assertIncludes(source, snippet, label) { + if (!source.includes(snippet)) { + throw new Error(`${label} missing ${snippet}`); + } +} + +assertFile(androidArtifactPath, 'Android mobile shell APK', 1024 * 1024); +assertZipHeader(androidArtifactPath, 'Android mobile shell APK'); +const androidListing = run('unzip', ['-l', androidArtifactPath], 'Android APK listing'); +assertIncludes(androidListing, 'AndroidManifest.xml', 'Android mobile shell APK'); +assertIncludes(androidListing, 'classes.dex', 'Android mobile shell APK'); +assertIncludes(androidListing, 'assets/index.android.bundle', 'Android mobile shell APK'); + +assertFile(iosSimulatorArtifactPath, 'iOS simulator mobile shell archive', 1024 * 1024); +assertGzipHeader(iosSimulatorArtifactPath, 'iOS simulator mobile shell archive'); +const iosListing = run( + 'tar', + ['-tzf', iosSimulatorArtifactPath], + 'iOS simulator archive listing', +); +assertIncludes(iosListing, '.app/Info.plist', 'iOS simulator mobile shell archive'); +assertIncludes(iosListing, '.app/Genarrative', 'iOS simulator mobile shell archive'); +assertIncludes( + iosListing, + '.app/main.jsbundle', + 'iOS simulator mobile shell archive', +); + +console.log('[mobile-shell:build-artifacts] OK'); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs new file mode 100644 index 000000000..19ce5f33e --- /dev/null +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -0,0 +1,3292 @@ +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +import { PNG } from 'pngjs'; + +const appConfigPath = new URL('../app.json', import.meta.url); +const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; +const appPath = new URL('../App.tsx', import.meta.url); +const appSource = fs.readFileSync(appPath, 'utf8'); +const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url); +const shellAppSource = fs.readFileSync(shellAppPath, 'utf8'); +const expoExportSmokePath = new URL( + '../scripts/check-expo-export.mjs', + import.meta.url, +); +const expoExportSmokeSource = fs.readFileSync(expoExportSmokePath, 'utf8'); +const buildArtifactsSmokePath = new URL( + '../scripts/check-build-artifacts.mjs', + import.meta.url, +); +const buildArtifactsSmokeSource = fs.readFileSync( + buildArtifactsSmokePath, + 'utf8', +); +const nativeShellCheckPath = new URL( + '../../../scripts/check-native-shells.mjs', + import.meta.url, +); +const nativeShellCheckSource = fs.readFileSync(nativeShellCheckPath, 'utf8'); +const shellAppTestPath = new URL('../src/shell/ShellApp.test.tsx', import.meta.url); +const shellAppTestSource = fs.readFileSync(shellAppTestPath, 'utf8'); +const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url); +const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8'); +const qrScannerOverlayTestPath = new URL( + '../src/shell/QrScannerOverlay.test.tsx', + import.meta.url, +); +const qrScannerOverlayTestSource = fs.readFileSync( + qrScannerOverlayTestPath, + 'utf8', +); +const appearancePath = new URL('../src/host-bridge/appearance.ts', import.meta.url); +const appearanceSource = fs.readFileSync(appearancePath, 'utf8'); +const appearanceTestPath = new URL( + '../src/host-bridge/appearance.test.ts', + import.meta.url, +); +const appearanceTestSource = fs.readFileSync(appearanceTestPath, 'utf8'); +const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url); +const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); +const bridgeTestPath = new URL('../src/host-bridge/bridge.test.ts', import.meta.url); +const bridgeTestSource = fs.readFileSync(bridgeTestPath, 'utf8'); +const badgePath = new URL('../src/host-bridge/badge.ts', import.meta.url); +const badgeSource = fs.readFileSync(badgePath, 'utf8'); +const badgeTestPath = new URL('../src/host-bridge/badge.test.ts', import.meta.url); +const badgeTestSource = fs.readFileSync(badgeTestPath, 'utf8'); +const capabilitiesPath = new URL( + '../src/host-bridge/capabilities.ts', + import.meta.url, +); +const capabilitiesSource = fs.readFileSync(capabilitiesPath, 'utf8'); +const capabilitiesTestPath = new URL( + '../src/host-bridge/capabilities.test.ts', + import.meta.url, +); +const capabilitiesTestSource = fs.readFileSync(capabilitiesTestPath, 'utf8'); +const clipboardPath = new URL('../src/host-bridge/clipboard.ts', import.meta.url); +const clipboardSource = fs.readFileSync(clipboardPath, 'utf8'); +const clipboardTestPath = new URL( + '../src/host-bridge/clipboard.test.ts', + import.meta.url, +); +const clipboardTestSource = fs.readFileSync(clipboardTestPath, 'utf8'); +const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url); +const dispatchSource = fs.readFileSync(dispatchPath, 'utf8'); +const dispatchTestPath = new URL( + '../src/host-bridge/dispatch.test.ts', + import.meta.url, +); +const dispatchTestSource = fs.readFileSync(dispatchTestPath, 'utf8'); +const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url); +const filesSource = fs.readFileSync(filesPath, 'utf8'); +const filesTestPath = new URL('../src/host-bridge/files.test.ts', import.meta.url); +const filesTestSource = fs.readFileSync(filesTestPath, 'utf8'); +const filePayloadsPath = new URL( + '../src/host-bridge/filePayloads.ts', + import.meta.url, +); +const filePayloadsSource = fs.readFileSync(filePayloadsPath, 'utf8'); +const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url); +const hapticsSource = fs.readFileSync(hapticsPath, 'utf8'); +const hapticsTestPath = new URL( + '../src/host-bridge/haptics.test.ts', + import.meta.url, +); +const hapticsTestSource = fs.readFileSync(hapticsTestPath, 'utf8'); +const hostBridgeNavigationPath = new URL( + '../src/host-bridge/navigation.ts', + import.meta.url, +); +const hostBridgeNavigationSource = fs.readFileSync( + hostBridgeNavigationPath, + 'utf8', +); +const hostBridgeNavigationTestPath = new URL( + '../src/host-bridge/navigation.test.ts', + import.meta.url, +); +const hostBridgeNavigationTestSource = fs.readFileSync( + hostBridgeNavigationTestPath, + 'utf8', +); +const hostBridgeNetworkPath = new URL( + '../src/host-bridge/network.ts', + import.meta.url, +); +const hostBridgeNetworkSource = fs.readFileSync( + hostBridgeNetworkPath, + 'utf8', +); +const hostBridgeNetworkTestPath = new URL( + '../src/host-bridge/network.test.ts', + import.meta.url, +); +const hostBridgeNetworkTestSource = fs.readFileSync( + hostBridgeNetworkTestPath, + 'utf8', +); +const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.meta.url); +const notificationsSource = fs.readFileSync(notificationsPath, 'utf8'); +const notificationsTestPath = new URL( + '../src/host-bridge/notifications.test.ts', + import.meta.url, +); +const notificationsTestSource = fs.readFileSync(notificationsTestPath, 'utf8'); +const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url); +const protocolSource = fs.readFileSync(protocolPath, 'utf8'); +const protocolTestPath = new URL( + '../src/host-bridge/protocol.test.ts', + import.meta.url, +); +const protocolTestSource = fs.readFileSync(protocolTestPath, 'utf8'); +const scannerPath = new URL('../src/host-bridge/scanner.ts', import.meta.url); +const scannerSource = fs.readFileSync(scannerPath, 'utf8'); +const scannerTestPath = new URL( + '../src/host-bridge/scanner.test.ts', + import.meta.url, +); +const scannerTestSource = fs.readFileSync(scannerTestPath, 'utf8'); +const hostBridgeRuntimePath = new URL('../src/host-bridge/runtime.ts', import.meta.url); +const hostBridgeRuntimeSource = fs.readFileSync(hostBridgeRuntimePath, 'utf8'); +const hostBridgeRuntimeTestPath = new URL( + '../src/host-bridge/runtime.test.ts', + import.meta.url, +); +const hostBridgeRuntimeTestSource = fs.readFileSync( + hostBridgeRuntimeTestPath, + 'utf8', +); +const sharePath = new URL('../src/host-bridge/share.ts', import.meta.url); +const shareSource = fs.readFileSync(sharePath, 'utf8'); +const shareTestPath = new URL('../src/host-bridge/share.test.ts', import.meta.url); +const shareTestSource = fs.readFileSync(shareTestPath, 'utf8'); +const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url); +const bridgeSourceFiles = fs + .readdirSync(bridgeDirPath, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.includes('.test.'), + ) + .map((entry) => new URL(entry.name, bridgeDirPath)) + .sort((left, right) => left.pathname.localeCompare(right.pathname)); +const hostBridgeSource = bridgeSourceFiles + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); + +function assertLabelOnlyMobileHostBridgeDiagnostics(source, label, message) { + if (source.includes(`console.warn(\`${message} \${label}\`, error)`)) { + throw new Error(`${label} diagnostics must not log native error objects`); + } + if (!source.includes(`console.warn(\`${message} \${label}\`)`)) { + throw new Error(`${label} diagnostics must use stable label-only logging`); + } +} + +for (const [source, label, message] of [ + [badgeSource, 'mobile app badge', 'mobile app badge failed for'], + [clipboardSource, 'mobile clipboard', 'mobile clipboard failed for'], + [hapticsSource, 'mobile haptics', 'mobile haptics failed for'], + [ + hostBridgeNavigationSource, + 'mobile HostBridge navigation', + 'mobile HostBridge navigation failed for', + ], + [hostBridgeNetworkSource, 'mobile network', 'mobile network failed for'], + [notificationsSource, 'mobile notification', 'mobile notification failed for'], + [shareSource, 'mobile share', 'mobile share failed for'], +]) { + assertLabelOnlyMobileHostBridgeDiagnostics(source, label, message); +} + +function assertSourceDoesNotInclude(source, snippet, message) { + if (source.includes(snippet)) { + throw new Error(message); + } +} + +const urlPath = new URL('../src/shell/url.ts', import.meta.url); +const urlSource = fs.readFileSync(urlPath, 'utf8'); +const urlTestPath = new URL('../src/shell/url.test.ts', import.meta.url); +const urlTestSource = fs.readFileSync(urlTestPath, 'utf8'); +const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url); +const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8'); +const deepLinkTestPath = new URL('../src/shell/deepLink.test.ts', import.meta.url); +const deepLinkTestSource = fs.readFileSync(deepLinkTestPath, 'utf8'); +const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url); +const navigationSource = fs.readFileSync(navigationPath, 'utf8'); +const navigationTestPath = new URL('../src/shell/navigation.test.ts', import.meta.url); +const navigationTestSource = fs.readFileSync(navigationTestPath, 'utf8'); +const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url); +const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8'); +const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url); +const webViewHistorySource = fs.readFileSync(webViewHistoryPath, 'utf8'); +const loadFailurePath = new URL('../src/shell/loadFailure.ts', import.meta.url); +const loadFailureSource = fs.readFileSync(loadFailurePath, 'utf8'); +const loadFailureTestPath = new URL( + '../src/shell/loadFailure.test.ts', + import.meta.url, +); +const loadFailureTestSource = fs.readFileSync(loadFailureTestPath, 'utf8'); +const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url); +const runtimeSource = fs.readFileSync(runtimePath, 'utf8'); +const lifecyclePath = new URL('../src/shell/lifecycle.ts', import.meta.url); +const lifecycleSource = fs.readFileSync(lifecyclePath, 'utf8'); +const lifecycleTestPath = new URL('../src/shell/lifecycle.test.ts', import.meta.url); +const lifecycleTestSource = fs.readFileSync(lifecycleTestPath, 'utf8'); +const safeAreaTestPath = new URL('../src/shell/safeArea.test.ts', import.meta.url); +const safeAreaTestSource = fs.readFileSync(safeAreaTestPath, 'utf8'); + +for (const [source, snippet, message] of [ + [ + shellAppSource, + 'console.warn(`mobile host event failed for ${label}`, error)', + 'mobile shell host event diagnostics must not log native error objects', + ], + [ + shellAppSource, + "console.warn('mobile HostBridge message injection failed', error)", + 'mobile shell HostBridge injection diagnostics must not log native error objects', + ], + [ + shellAppSource, + 'console.warn(`mobile shell navigation failed for ${label}`, error)', + 'mobile shell navigation diagnostics must not log native error objects', + ], + [ + shellAppSource, + "console.warn('mobile shell blocked WebView file download', event)", + 'mobile shell blocked download diagnostics must not log WebView event objects', + ], + [ + shellAppSource, + 'console.warn(`mobile shell deep link failed for ${label}`, error)', + 'mobile shell deep link diagnostics must not log native error objects', + ], + [ + qrScannerOverlaySource, + "console.warn('mobile QR scanner permission request failed', error)", + 'mobile QR scanner diagnostics must not log native error objects', + ], + [ + webViewPolicySource, + "console.warn('mobile navigation state sync failed', error)", + 'mobile WebView history diagnostics must not log native error objects', + ], +]) { + assertSourceDoesNotInclude(source, snippet, message); +} + +const sharedContractPath = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); +const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); +const packagePath = new URL('../package.json', import.meta.url); +const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const easConfigPath = new URL('../eas.json', import.meta.url); +const easConfig = JSON.parse(fs.readFileSync(easConfigPath, 'utf8')); +const rootPackagePath = new URL('../../../package.json', import.meta.url); +const rootPackageConfig = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8')); +const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url); +const rootPackageLock = JSON.parse(fs.readFileSync(rootPackageLockPath, 'utf8')); +const iconPath = new URL('../assets/icon.png', import.meta.url); +const icon = PNG.sync.read(fs.readFileSync(iconPath)); +const brandBackgroundColor = '#fffdf9'; +const androidBuildOutputPath = + '../../build/native/mobile/genarrative-mobile-android.apk'; +const iosSimulatorBuildOutputPath = + '../../build/native/mobile/genarrative-mobile-ios-simulator.tar.gz'; +const productionSourceRoots = [ + new URL('../App.tsx', import.meta.url), + new URL('../app.json', import.meta.url), + new URL('../package.json', import.meta.url), + new URL('../scripts/', import.meta.url), + new URL('../src/', import.meta.url), +]; +const productionFileExtensions = new Set(['.json', '.mjs', '.ts', '.tsx']); +const requiredMobileShellSourceModules = [ + 'env.d.ts', + 'host-bridge/appearance.ts', + 'host-bridge/badge.ts', + 'host-bridge/bridge.ts', + 'host-bridge/capabilities.ts', + 'host-bridge/clipboard.ts', + 'host-bridge/dispatch.ts', + 'host-bridge/filePayloads.ts', + 'host-bridge/files.ts', + 'host-bridge/haptics.ts', + 'host-bridge/navigation.ts', + 'host-bridge/network.ts', + 'host-bridge/notifications.ts', + 'host-bridge/protocol.ts', + 'host-bridge/runtime.ts', + 'host-bridge/scanner.ts', + 'host-bridge/share.ts', + 'shell/QrScannerOverlay.tsx', + 'shell/ShellApp.tsx', + 'shell/deepLink.ts', + 'shell/lifecycle.ts', + 'shell/loadFailure.ts', + 'shell/navigation.ts', + 'shell/network.ts', + 'shell/runtime.ts', + 'shell/safeArea.ts', + 'shell/url.ts', + 'shell/webViewGlobals.d.ts', + 'shell/webViewHistory.ts', + 'shell/webViewPolicy.ts', +]; +const requiredMobileShellScriptModules = [ + 'check-build-artifacts.mjs', + 'check-config.mjs', + 'check-eas-build-config.mjs', + 'check-expo-config.mjs', + 'check-expo-export.mjs', +]; +const productionSourceExcludedDirectories = new Set([ + 'node_modules', + 'test-utils', +]); +const devScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'place' + 'holder', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '占' + '位', + '模' + '拟', + '伪' + '造', + '未' + '实现', + '临' + '时', + '后' + '续', +]; +const blockedMobileChannelDependencies = [ + '@react-native-firebase/analytics', + '@react-native-firebase/app', + '@segment/analytics-react-native', + '@sentry/react-native', + 'amplitude-react-native', + 'expo-application', + 'expo-updates', + 'posthog-react-native', + 'react-native-code-push', +]; +const blockedMobileChannelLockDependencies = blockedMobileChannelDependencies.filter( + (dependency) => dependency !== 'expo-application', +); +const blockedMobileChannelSnippets = [ + '@react-native-firebase/analytics', + '@react-native-firebase/app', + '@segment/analytics-react-native', + '@sentry/react-native', + 'Amplitude.getInstance', + 'Analytics.screen', + 'Analytics.track', + 'CodePush.sync', + 'PostHogProvider', + 'Sentry.init', + 'Updates.checkForUpdateAsync', + 'Updates.fetchUpdateAsync', + 'Updates.reloadAsync', + 'analytics().logEvent', + 'amplitude.init', + 'codePush(', + 'posthog.capture', + 'posthog.init', +]; +const blockedScheduledNotificationSnippets = [ + 'SchedulableTriggerInputTypes', + 'cancelScheduledNotificationAsync', + 'getAllScheduledNotificationsAsync', + 'getNextTriggerDateAsync', + 'seconds:', + 'repeats:', + "type: 'calendar'", + "type: 'daily'", + "type: 'date'", + "type: 'monthly'", + "type: 'timeInterval'", + "type: 'weekly'", + "type: 'yearly'", + 'DateTriggerInput', + 'TimeIntervalTriggerInput', +]; +const blockedAndroidPermissions = [ + 'android.permission.MANAGE_EXTERNAL_STORAGE', + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.RECEIVE_BOOT_COMPLETED', + 'android.permission.REQUEST_INSTALL_PACKAGES', + 'android.permission.SCHEDULE_EXACT_ALARM', + 'android.permission.USE_EXACT_ALARM', + 'android.permission.WRITE_EXTERNAL_STORAGE', +]; +const generatedMobilePaths = [ + 'apps/mobile-shell/.expo', + 'apps/mobile-shell/.expo-export-smoke', +]; + +function extractStringArrayExport(source, exportName, seen = new Set()) { + if (seen.has(exportName)) { + throw new Error(`cyclic string array export ${exportName}`); + } + + const match = source.match( + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\][^;]*;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + const nextSeen = new Set(seen); + nextSeen.add(exportName); + const entries = []; + for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) { + if (entry[1]) { + entries.push(...extractStringArrayExport(source, entry[1], nextSeen)); + } else { + entries.push(entry[2]); + } + } + + return entries; +} + +function extractStringConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*'([^']+)';`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return match[1]; +} + +function extractStringObjectConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Object.fromEntries( + [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ + entry[1], + entry[2], + ]), + ); +} + +function extractNumberConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Number(match[1]); +} + +function extractMobileBridgeHandledMethods(source) { + const match = source.match( + /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + ); + if (!match) { + throw new Error('unable to read mobile shell HostBridge handler methods'); + } + + return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]); +} + +function extractMobileBridgeUnsupportedMethods(source) { + const match = source.match( + /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + ); + if (!match) { + throw new Error('unable to read mobile shell HostBridge unsupported methods'); + } + + const unsupportedMethods = new Set(); + const casePattern = + /case '([^']+)':([\s\S]*?)(?=\n case '|\n default:|\n \})/g; + for (const entry of match[1].matchAll(casePattern)) { + if (entry[2].includes('unsupported(request.method)')) { + unsupportedMethods.add(entry[1]); + } + } + + return [...unsupportedMethods]; +} + +function extractFunctionBody(source, functionName) { + const start = source.indexOf(`function ${functionName}`); + if (start === -1) { + throw new Error(`unable to read function ${functionName}`); + } + + const openBrace = source.indexOf('{', start); + if (openBrace === -1) { + throw new Error(`unable to read function body ${functionName}`); + } + + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + const character = source[index]; + if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(openBrace + 1, index); + } + } + } + + throw new Error(`unable to read complete function body ${functionName}`); +} + +function assertMobileDocumentPickerBoundary( + functionName, + expectedTypeExpression, +) { + const functionBody = extractFunctionBody(hostBridgeSource, functionName); + if (!functionBody.includes('pickMobileDocumentFile(')) { + throw new Error(`mobile shell ${functionName} must use DocumentPicker`); + } + for (const requiredPickerOption of [ + 'copyToCacheDirectory: true', + 'multiple: false', + `type: ${expectedTypeExpression}`, + ]) { + if (!functionBody.includes(requiredPickerOption)) { + throw new Error( + `mobile shell ${functionName} picker options missing ${requiredPickerOption}`, + ); + } + } +} + +function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) { + const dependencySections = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]; + + for (const dependency of blockedMobileChannelDependencies) { + for (const section of dependencySections) { + if (packageJson[section]?.[dependency]) { + throw new Error( + `${packageLabel} must not depend on ${dependency} before the real mobile channel contract exists`, + ); + } + } + } +} + +function assertNoBlockedMobileChannelLockPackages() { + const packageNames = Object.keys(rootPackageLock.packages ?? {}) + .filter((packagePath) => packagePath.startsWith('node_modules/')) + .map((packagePath) => packagePath.replace(/^node_modules\//, '')); + const dependencyNames = Object.keys(rootPackageLock.dependencies ?? {}); + const lockedPackageNames = new Set([...packageNames, ...dependencyNames]); + + for (const dependency of blockedMobileChannelLockDependencies) { + if (lockedPackageNames.has(dependency)) { + throw new Error( + `root package-lock must not resolve ${dependency} before the real mobile channel contract exists`, + ); + } + } +} + +function assertNoBlockedMobileChannelSnippets() { + const sources = [ + ['app.json', JSON.stringify(appConfig)], + ['App.tsx', appSource], + ['ShellApp.tsx', shellAppSource], + ['src/host-bridge', hostBridgeSource], + ['url.ts', urlSource], + ['runtime.ts', runtimeSource], + ]; + + for (const [sourceName, source] of sources) { + for (const snippet of blockedMobileChannelSnippets) { + if (source.includes(snippet)) { + throw new Error( + `mobile shell ${sourceName} must not initialize ${snippet} before the real channel contract exists`, + ); + } + } + } +} + +function assertPackageScript(packageJson, packageLabel, scriptName, expected) { + const actual = packageJson.scripts?.[scriptName]; + if (actual !== expected) { + throw new Error( + `${packageLabel} script ${scriptName} drifted: expected ${expected} but got ${actual}`, + ); + } +} + +function assertPackageDependencyVersion( + packageJson, + packageLabel, + section, + dependency, + expected, +) { + const actual = packageJson[section]?.[dependency]; + if (actual !== expected) { + throw new Error( + `${packageLabel} ${section}.${dependency} drifted: expected ${expected} but got ${actual}`, + ); + } +} + +function assertPackageLockVersion(dependency, expected) { + const actual = rootPackageLock.packages?.[`node_modules/${dependency}`]?.version; + if (actual !== expected) { + throw new Error( + `root package-lock ${dependency} resolved version drifted: expected ${expected} but got ${actual}`, + ); + } +} + +function collectProductionSourceFiles(entry) { + const stats = fs.statSync(entry); + if (stats.isDirectory()) { + const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`); + return fs + .readdirSync(entry, { withFileTypes: true }) + .filter( + (child) => + !child.isDirectory() || + !productionSourceExcludedDirectories.has(child.name), + ) + .flatMap((child) => + collectProductionSourceFiles( + new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory), + ), + ); + } + + const path = entry.pathname; + const extension = path.match(/\.[^.]+$/)?.[0] ?? ''; + if (!productionFileExtensions.has(extension)) { + return []; + } + if (path.includes('.test.')) { + return []; + } + + return [entry]; +} + +function assertNoDevScaffoldTerms(files) { + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const lineStarts = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') { + lineStarts.push(index + 1); + } + } + + for (const term of devScaffoldTerms) { + const matchIndex = source.toLowerCase().indexOf(term.toLowerCase()); + if (matchIndex === -1) { + continue; + } + const line = lineStarts.filter((start) => start <= matchIndex).length; + throw new Error( + `mobile shell production source must not include ${term}: ${file.pathname}:${line}`, + ); + } + } +} + +function assertNoTrackedMobileGeneratedFiles() { + const result = spawnSync('git', ['ls-files', ...generatedMobilePaths], { + cwd: new URL('../../..', import.meta.url), + encoding: 'utf8', + }); + + if (result.error) { + throw new Error( + `unable to check mobile generated files: ${result.error.message}`, + ); + } + if ((result.status ?? 0) !== 0) { + throw new Error( + `unable to check mobile generated files: ${result.stderr.trim()}`, + ); + } + + const trackedGeneratedFiles = result.stdout + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); + if (trackedGeneratedFiles.length > 0) { + throw new Error( + `mobile generated files must stay untracked: ${trackedGeneratedFiles.join(', ')}`, + ); + } +} + +function countAlphaPixels(png) { + let transparent = 0; + let translucent = 0; + let opaque = 0; + for (let index = 3; index < png.data.length; index += 4) { + const alpha = png.data[index]; + if (alpha === 0) { + transparent += 1; + } else if (alpha === 255) { + opaque += 1; + } else { + translucent += 1; + } + } + + return { transparent, translucent, opaque }; +} + +function assertSameList(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some((value, index) => value !== expected[index]) + ) { + throw new Error( + `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, + ); + } +} + +function collectMobileShellSourceRelativePaths(files) { + const sourceRootPath = new URL('../src/', import.meta.url).pathname; + return files + .map((file) => file.pathname.replace(sourceRootPath, '')) + .filter((filePath) => !filePath.includes('.test.')) + .sort(); +} + +assertSameList( + collectMobileShellSourceRelativePaths(collectProductionSourceFiles(new URL('../src/', import.meta.url))), + requiredMobileShellSourceModules, + 'mobile shell source modules', +); +assertSameList( + fs + .readdirSync(new URL('../scripts/', import.meta.url), { + withFileTypes: true, + }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(), + requiredMobileShellScriptModules, + 'mobile shell smoke scripts', +); + +for (const snippet of [ + 'const requiredNativeHostContextTokens = [', + "'native_app'", + "'expo_mobile'", + "'hostCapabilities'", + "'hostVersion'", + "'bridgeVersion'", + 'production bundle must include native host context token', + 'metadata.version !== 0', + 'Object.keys(fileMetadata)', + 'metadata must only include its platform', + 'metadata must include an assets array', + '!bundlePath.startsWith(`_expo/static/js/${platform}/AppEntry-`)', + "!bundlePath.endsWith('.hbc')", +]) { + if (!expoExportSmokeSource.includes(snippet)) { + throw new Error(`mobile shell Expo export smoke must verify host context token ${snippet}`); + } +} + +for (const snippet of [ + 'genarrative-mobile-android.apk', + 'genarrative-mobile-ios-simulator.tar.gz', + 'AndroidManifest.xml', + 'assets/index.android.bundle', + '.app/Info.plist', + '.app/Genarrative', + '.app/main.jsbundle', + '[mobile-shell:build-artifacts] OK', +]) { + if (!buildArtifactsSmokeSource.includes(snippet)) { + throw new Error(`mobile shell build artifact smoke must verify ${snippet}`); + } +} + +for (const snippet of [ + "label: 'mobile-shell-eas-build-config-smoke'", + "args: ['run', 'mobile-shell:build-config']", + "label: 'mobile-shell-expo-config-smoke'", + "args: ['run', 'mobile-shell:config']", + "label: 'mobile-shell-expo-export-smoke'", + "args: ['run', 'mobile-shell:export']", +]) { + if (!nativeShellCheckSource.includes(snippet)) { + throw new Error(`root native shell gate must keep mobile distribution smoke ${snippet}`); + } +} + +for (const excludedDirectory of productionSourceExcludedDirectories) { + if (!nativeShellCheckSource.includes(`'${excludedDirectory}'`)) { + throw new Error( + `root native shell check is missing mobile source exclusion: ${excludedDirectory}`, + ); + } +} + +assertNoDevScaffoldTerms( + productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)), +); +assertNoTrackedMobileGeneratedFiles(); +assertNoBlockedMobileChannelDependencies(packageConfig, 'mobile shell package'); +assertNoBlockedMobileChannelDependencies(rootPackageConfig, 'root H5 package'); +assertNoBlockedMobileChannelLockPackages(); +assertNoBlockedMobileChannelSnippets(); + +for (const [scriptName, expected] of Object.entries({ + dev: 'expo start', + android: 'expo run:android', + ios: 'expo run:ios', + test: 'vitest run -c vitest.config.ts', + 'build:android': `eas build --local --profile production --platform android --output ${androidBuildOutputPath}`, + 'build:ios': `eas build --local --profile production-simulator --platform ios --output ${iosSimulatorBuildOutputPath}`, + 'build-artifacts:smoke': 'node scripts/check-build-artifacts.mjs', + 'build-config:smoke': 'node scripts/check-eas-build-config.mjs', + 'config:smoke': 'node scripts/check-expo-config.mjs', + 'export:smoke': 'node scripts/check-expo-export.mjs', + typecheck: 'tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs', +})) { + assertPackageScript(packageConfig, 'mobile shell package', scriptName, expected); +} + +for (const [scriptName, expected] of Object.entries({ + 'mobile-shell:dev': 'npm --prefix apps/mobile-shell run dev', + 'mobile-shell:typecheck': 'npm --prefix apps/mobile-shell run typecheck', + 'mobile-shell:test': 'npm --prefix apps/mobile-shell run test', + 'mobile-shell:build-config': 'npm --prefix apps/mobile-shell run build-config:smoke', + 'mobile-shell:build:android': 'npm --prefix apps/mobile-shell run build:android', + 'mobile-shell:build:ios': 'npm --prefix apps/mobile-shell run build:ios', + 'mobile-shell:build-artifacts': 'npm --prefix apps/mobile-shell run build-artifacts:smoke', + 'mobile-shell:config': 'npm --prefix apps/mobile-shell run config:smoke', + 'mobile-shell:export': 'npm --prefix apps/mobile-shell run export:smoke', +})) { + assertPackageScript(rootPackageConfig, 'root package', scriptName, expected); +} + +for (const [dependency, expected] of Object.entries({ + '@expo/metro-runtime': '^56.0.15', + expo: '^56.0.12', + 'expo-camera': '56.0.8', + 'expo-clipboard': '^56.0.4', + 'expo-document-picker': '^56.0.4', + 'expo-file-system': '^56.0.8', + 'expo-haptics': '^56.0.3', + 'expo-image-picker': '^56.0.18', + 'expo-linking': '^56.0.14', + 'expo-network': '^56.0.5', + 'expo-notifications': '^56.0.18', + 'expo-sharing': '^56.0.18', + 'expo-status-bar': '^56.0.4', + react: '^19.0.0', + 'react-native': '^0.86.0', + 'react-native-safe-area-context': '^5.8.0', + 'react-native-webview': '^13.16.1', +})) { + assertPackageDependencyVersion( + packageConfig, + 'mobile shell package', + 'dependencies', + dependency, + expected, + ); + assertPackageDependencyVersion( + rootPackageConfig, + 'root package', + 'dependencies', + dependency, + expected, + ); +} + +for (const [dependency, expected] of Object.entries({ + '@expo/metro-runtime': '56.0.15', + expo: '56.0.12', + 'expo-camera': '56.0.8', + 'expo-clipboard': '56.0.4', + 'expo-document-picker': '56.0.4', + 'expo-file-system': '56.0.8', + 'expo-haptics': '56.0.3', + 'expo-image-picker': '56.0.18', + 'expo-linking': '56.0.14', + 'expo-network': '56.0.5', + 'expo-notifications': '56.0.18', + 'expo-sharing': '56.0.18', + 'expo-status-bar': '56.0.4', + react: '19.2.4', + 'react-native': '0.86.0', + 'react-native-safe-area-context': '5.8.0', + 'react-native-webview': '13.16.1', +})) { + assertPackageLockVersion(dependency, expected); +} + +for (const [dependency, expected] of Object.entries({ + 'eas-cli': '^20.3.0', + typescript: '~5.8.2', + vitest: '^0.34.6', +})) { + assertPackageDependencyVersion( + packageConfig, + 'mobile shell package', + 'devDependencies', + dependency, + expected, + ); + assertPackageDependencyVersion( + rootPackageConfig, + 'root package', + 'devDependencies', + dependency, + expected, + ); +} + +assertPackageLockVersion('eas-cli', '20.3.0'); + +for (const [dependency, expected] of Object.entries({ + typescript: '5.8.3', + vitest: '0.34.6', +})) { + assertPackageLockVersion(dependency, expected); +} + +const sharedCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_CAPABILITIES', +); +const sharedMethods = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_METHODS', +); +const sharedEvents = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EVENTS', +); +const sharedBlockedDownloadProtocols = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', +); +const sharedMobileBaseCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', +); +const sharedMobileIosCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', +); +const sharedHostBridgeProtocol = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PROTOCOL', +); +const sharedHostBridgeVersion = extractNumberConstExport( + sharedContractSource, + 'HOST_BRIDGE_VERSION', +); +const sharedPublicWebOrigin = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', +); +const sharedPublicWebUrl = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_URL', +); +const sharedMobileLocalNotificationChannelId = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', +); +const sharedNativeAppQueryKeys = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS', +); +const sharedNativeAppQueryKey = extractStringObjectConstExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY', +); +const sharedNativeAppQuery = extractStringObjectConstExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY', +); +const sharedPublicWebOriginUrl = new URL(sharedPublicWebOrigin); +if (sharedPublicWebOriginUrl.protocol !== 'https:') { + throw new Error('shared HostBridge public web origin must use https for mobile app links'); +} +const sharedPublicWebHost = sharedPublicWebOriginUrl.hostname; +const sharedPublicWebAssociatedDomain = `applinks:${sharedPublicWebHost}`; +const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); +const unsupportedMobileMethods = extractMobileBridgeUnsupportedMethods(dispatchSource); +const mobileCapabilities = sharedMobileBaseCapabilities; +const iosMobileCapabilities = sharedMobileIosCapabilities; +const mobileCapabilitySet = new Set(mobileCapabilities); +const iosMobileCapabilitySet = new Set(iosMobileCapabilities); +const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; +const sharedPayloadBoundaryImports = [ + 'HOST_BRIDGE_AUDIO_MIME_TYPES', + 'HOST_BRIDGE_BADGE_COUNT_MAX', + 'HOST_BRIDGE_DOCUMENT_MIME_TYPES', + 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_IMAGE_MIME_TYPES', + 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT', + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', + 'normalizeHostBridgeImportFileName', + 'normalizeHostBridgeQrCodeValue', + 'HOST_BRIDGE_TEXT_MIME_TYPES', +]; +for (const boundaryImport of sharedPayloadBoundaryImports) { + if (!hostBridgeSource.includes(boundaryImport)) { + throw new Error( + `mobile shell must import shared HostBridge payload boundary ${boundaryImport}`, + ); + } +} +if ( + !hostBridgeSource.includes( + 'HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)', + ) +) { + throw new Error( + 'mobile shell file.exportText must validate MIME against the shared text MIME set', + ); +} + +const forbiddenLocalPayloadBoundaryDeclarations = [ + 'EXPORT_TEXT_MAX_BYTES', + 'IMPORT_TEXT_MAX_BYTES', + 'IMPORT_DOCUMENT_MAX_BYTES', + 'EXPORT_IMAGE_MAX_BYTES', + 'IMPORT_IMAGE_MAX_BYTES', + 'EXPORT_AUDIO_MAX_BYTES', + 'IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_BADGE_COUNT_MAX', + 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH', + 'HOST_BRIDGE_TEXT_MIME_TYPES', + 'HOST_BRIDGE_DOCUMENT_MIME_TYPES', + 'HOST_BRIDGE_IMAGE_MIME_TYPES', + 'HOST_BRIDGE_AUDIO_MIME_TYPES', + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', +]; +for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) { + if (new RegExp(`const ${localBoundary}\\s*=`).test(hostBridgeSource)) { + throw new Error( + `mobile shell must not redeclare HostBridge payload boundary ${localBoundary}`, + ); + } +} + +if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) { + throw new Error('mobile shell must centralize imported file size checks'); +} + +if (!bridgeSource.includes('HOST_BRIDGE_RESPONSE_CACHE_MAX,')) { + throw new Error('mobile shell bridge must import shared HostBridge response cache limit'); +} +if (/const HOST_BRIDGE_RESPONSE_CACHE_MAX\s*=/.test(protocolSource)) { + throw new Error('mobile shell protocol must not redeclare HostBridge response cache limit'); +} +for (const expectedErrorCode of [ + 'invalid_request', + 'unsupported_method', + 'unsupported_capability', + 'timeout', + 'cancelled', + 'host_error', +]) { + if (!protocolSource.includes(`'${expectedErrorCode}'`)) { + throw new Error( + `mobile shell protocol error code allowlist missing ${expectedErrorCode}`, + ); + } +} +if ( + protocolSource.includes('error instanceof Error') || + protocolSource.includes('? error.message') || + protocolSource.includes('error.message\n :') +) { + throw new Error('mobile shell protocol must not expose unknown native Error.message values to H5'); +} +for (const snippet of [ + 'parseRequest', + 'isHostBridgeRequest', + 'normalizeMobileHostBridgeError', + 'unsupported', + 'invalidRequest', + 'ok(request(), { shell: ', + 'failure(request(), invalidRequest(', + "request({ id: 'bad\\u0000id' })", + "request({ id: 'x'.repeat(129) })", + "method: 'unknown.method'", + "code: 'private_error'", + "nativeStack: 'hidden'", + "message: 'mobile host bridge request failed'", +]) { + if (!protocolTestSource.includes(snippet)) { + throw new Error(`mobile shell protocol helper test missing ${snippet}`); + } +} +if ( + !protocolSource.includes('HOST_BRIDGE_ERROR_CODES.has(') || + !protocolSource.includes('mobile host bridge request failed') +) { + throw new Error('mobile shell protocol must normalize non-contract host errors'); +} + +for (const [functionName, readCall] of [ + ['importTextFile', 'readMobileTextFile(file,'], + ['importDocumentFile', 'readMobileBase64File(file,'], + ['importAudioFile', 'readMobileBase64File(file,'], +]) { + const functionBody = extractFunctionBody(hostBridgeSource, functionName); + const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit('); + const readIndex = functionBody.indexOf(readCall); + if (sizeCheckIndex === -1 || readIndex === -1 || sizeCheckIndex > readIndex) { + throw new Error( + `mobile shell ${functionName} must check file size before ${readCall}`, + ); + } +} + +for (const profileSource of [ + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', +]) { + if (!hostBridgeSource.includes(profileSource)) { + throw new Error(`mobile shell must use shared HostBridge profile ${profileSource}`); + } +} + +for (const snippet of [ + 'export const MOBILE_HOST_CAPABILITIES =\n HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES;', + 'export const IOS_MOBILE_HOST_CAPABILITIES =\n HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES;', +]) { + if (!capabilitiesSource.includes(snippet)) { + throw new Error(`mobile shell capability profile must stay directly shared: ${snippet}`); + } +} + +if ( + /export const MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource) || + /export const IOS_MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource) +) { + throw new Error('mobile shell must not redeclare HostBridge capability profiles'); +} + +for (const snippet of [ + 'HOST_BRIDGE_METHODS', + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', + 'const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter(', + '(method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)', + 'test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)', + 'dispatchMobileHostBridgeRequest(request(method))', + "expect(response.error.code).toBe('unsupported_method')", +]) { + if (!dispatchTestSource.includes(snippet)) { + throw new Error( + `mobile shell dispatch tests must derive unsupported method coverage from shared profiles: ${snippet}`, + ); + } +} + +const unknownSharedEvents = sharedEvents.filter( + (eventName) => !sharedCapabilities.includes(eventName), +); +if (unknownSharedEvents.length > 0) { + throw new Error( + `shared HostBridge events must also be capabilities: ${unknownSharedEvents.join(', ')}`, + ); +} + +const unknownHandledMobileMethods = handledMobileMethods.filter( + (method) => !sharedMethods.includes(method), +); +if (unknownHandledMobileMethods.length > 0) { + throw new Error( + `mobile shell handles unknown HostBridge methods: ${unknownHandledMobileMethods.join(', ')}`, + ); +} + +const unknownMobileCapabilities = mobileCapabilities.filter( + (capability) => !sharedCapabilities.includes(capability), +); +if (unknownMobileCapabilities.length > 0) { + throw new Error( + `mobile shell declares unknown HostBridge capabilities: ${unknownMobileCapabilities.join(', ')}`, + ); +} + +const unknownIosMobileCapabilities = iosMobileCapabilities.filter( + (capability) => !sharedCapabilities.includes(capability), +); +if (unknownIosMobileCapabilities.length > 0) { + throw new Error( + `iOS mobile shell declares unknown HostBridge capabilities: ${unknownIosMobileCapabilities.join(', ')}`, + ); +} + +for (const capability of sdkBackedCapabilities) { + if ( + mobileCapabilitySet.has(capability) || + iosMobileCapabilitySet.has(capability) + ) { + throw new Error( + `mobile shell must not declare ${capability} until a real SDK/channel flow is implemented`, + ); + } +} + +const missingMobileMethodHandlers = iosMobileCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + !handledMobileMethods.includes(capability), +); +if (missingMobileMethodHandlers.length > 0) { + throw new Error( + `mobile shell declares request capabilities without HostBridge handlers: ${missingMobileMethodHandlers.join(', ')}`, + ); +} + +const unsupportedMobileCapabilities = iosMobileCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + unsupportedMobileMethods.includes(capability), +); +if (unsupportedMobileCapabilities.length > 0) { + throw new Error( + `mobile shell declares request capabilities backed only by unsupported responses: ${unsupportedMobileCapabilities.join(', ')}`, + ); +} + +const undeclaredMobileMethodHandlers = handledMobileMethods.filter( + (method) => + !iosMobileCapabilitySet.has(method) && !sdkBackedCapabilities.includes(method), +); +if (undeclaredMobileMethodHandlers.length > 0) { + throw new Error( + `mobile shell handles unadvertised HostBridge methods: ${undeclaredMobileMethodHandlers.join(', ')}`, + ); +} + +if (appConfig.scheme !== 'genarrative') { + throw new Error('mobile shell scheme must be genarrative'); +} + +if (appConfig.name !== 'Genarrative') { + throw new Error('mobile shell app name must be Genarrative'); +} + +if (appConfig.slug !== 'genarrative-mobile-shell') { + throw new Error('mobile shell slug must be genarrative-mobile-shell'); +} + +if (appConfig.version !== '0.1.0') { + throw new Error('mobile shell app version must be 0.1.0'); +} + +const mobileHostVersion = extractStringConstExport( + runtimeSource, + 'MOBILE_SHELL_HOST_VERSION_FALLBACK', +); +if (mobileHostVersion !== appConfig.version) { + throw new Error('mobile shell HostBridge fallback host version must match app.json version'); +} + +if (appConfig.orientation !== 'default') { + throw new Error('mobile shell must allow device orientation for landscape-capable H5 games'); +} + +if (packageConfig.version !== appConfig.version) { + throw new Error('mobile shell package version must match app.json version'); +} + +if (appConfig.icon !== './assets/icon.png') { + throw new Error('mobile shell must use the real brand icon asset'); +} + +if (appConfig.userInterfaceStyle !== 'automatic') { + throw new Error('mobile shell must follow the native system appearance setting'); +} + +assertSameList( + appConfig.assetBundlePatterns ?? [], + ['**/*'], + 'mobile shell asset bundle patterns', +); + +if (icon.width < 512 || icon.height < 512) { + throw new Error('mobile shell icon must be a production-size brand asset'); +} + +const iconAlpha = countAlphaPixels(icon); +if (iconAlpha.transparent === 0 || iconAlpha.opaque === 0) { + throw new Error('mobile shell adaptive icon foreground must use the real transparent brand asset'); +} + +if ( + appConfig.splash?.image !== './assets/icon.png' || + appConfig.splash?.resizeMode !== 'contain' || + appConfig.splash?.backgroundColor !== brandBackgroundColor +) { + throw new Error('mobile shell splash must use the real brand icon and brand background'); +} + +if (appConfig.updates?.enabled !== false) { + throw new Error('mobile shell OTA updates must stay disabled until a real release channel exists'); +} + +if (Object.keys(appConfig.updates ?? {}).some((key) => key !== 'enabled')) { + throw new Error('mobile shell must not configure OTA update metadata without a real release channel'); +} + +if ('runtimeVersion' in appConfig) { + throw new Error('mobile shell must not configure runtimeVersion without a real OTA release channel'); +} + +if ('releaseChannel' in appConfig || 'channel' in appConfig) { + throw new Error('mobile shell must not configure an app release channel without a real release process'); +} + +if (easConfig.cli?.version !== '>= 20.3.0' || easConfig.cli?.appVersionSource !== 'local') { + throw new Error('mobile shell EAS builds must pin CLI floor and use local app version fields'); +} + +for (const [label, outputPath, extension] of [ + ['Android', androidBuildOutputPath, '.apk'], + ['iOS simulator', iosSimulatorBuildOutputPath, '.tar.gz'], +]) { + if (!outputPath.startsWith('../../build/native/mobile/')) { + throw new Error(`mobile shell ${label} local build output must stay under root build/native/mobile`); + } + if (!outputPath.endsWith(extension)) { + throw new Error(`mobile shell ${label} local build output must end with ${extension}`); + } +} + +if ( + easConfig.build?.production?.distribution !== 'internal' || + easConfig.build?.production?.channel !== 'production' || + easConfig.build?.production?.android?.buildType !== 'apk' || + easConfig.build?.production?.env?.EXPO_NO_DOTENV !== '1' +) { + throw new Error('mobile shell EAS Android production profile must build an internal APK without local dotenv'); +} + +if ( + easConfig.build?.['production-simulator']?.distribution !== 'internal' || + easConfig.build?.['production-simulator']?.channel !== 'production' || + easConfig.build?.['production-simulator']?.ios?.simulator !== true || + easConfig.build?.['production-simulator']?.env?.EXPO_NO_DOTENV !== '1' +) { + throw new Error('mobile shell EAS iOS production smoke profile must build an internal simulator package without local dotenv'); +} + +for (const [profileName, profile] of Object.entries(easConfig.build ?? {})) { + for (const blockedKey of ['credentialsSource', 'autoIncrement', 'submit', 'releaseChannel']) { + if (blockedKey in profile) { + throw new Error(`mobile shell EAS ${profileName} profile must not configure ${blockedKey}`); + } + } +} + +if ('submit' in easConfig) { + throw new Error('mobile shell EAS config must not include store submit profiles yet'); +} + +assertSameList( + appConfig.ios?.associatedDomains ?? [], + [sharedPublicWebAssociatedDomain], + 'mobile shell iOS associated domains', +); + +if (appConfig.ios?.bundleIdentifier !== 'world.genarrative.mobile') { + throw new Error('mobile shell iOS bundle identifier must be world.genarrative.mobile'); +} + +if (appConfig.ios?.buildNumber !== '1') { + throw new Error('mobile shell iOS build number must start at 1'); +} + +if (appConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption !== false) { + throw new Error('mobile shell iOS encryption export flag must be explicit'); +} + +if ( + appConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads !== false +) { + throw new Error('mobile shell iOS ATS must not allow arbitrary network loads'); +} + +if ( + appConfig.ios?.infoPlist?.NSMicrophoneUsageDescription !== + '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。' +) { + throw new Error('mobile shell iOS microphone permission text must describe same-origin H5 gameplay input'); +} + +const iosPrivacyManifests = appConfig.ios?.privacyManifests; +if (!iosPrivacyManifests) { + throw new Error('mobile shell iOS privacy manifests must be configured'); +} + +if (iosPrivacyManifests.NSPrivacyTracking !== false) { + throw new Error('mobile shell iOS privacy manifest must not enable tracking'); +} + +assertSameList( + iosPrivacyManifests.NSPrivacyCollectedDataTypes ?? [], + [], + 'mobile shell iOS privacy collected data types', +); +assertSameList( + iosPrivacyManifests.NSPrivacyTrackingDomains ?? [], + [], + 'mobile shell iOS privacy tracking domains', +); + +const requiredPrivacyAccessedApiTypes = new Map([ + [ + 'NSPrivacyAccessedAPICategoryFileTimestamp', + ['0A2A.1', '3B52.1', 'C617.1'], + ], + ['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']], + ['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']], + ['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']], +]); +const privacyAccessedApiTypes = + iosPrivacyManifests.NSPrivacyAccessedAPITypes ?? []; +if (privacyAccessedApiTypes.length !== requiredPrivacyAccessedApiTypes.size) { + throw new Error('mobile shell iOS privacy manifest accessed API type count drifted'); +} + +for (const [apiType, reasons] of requiredPrivacyAccessedApiTypes) { + const entry = privacyAccessedApiTypes.find( + (candidate) => candidate.NSPrivacyAccessedAPIType === apiType, + ); + if (!entry) { + throw new Error(`mobile shell iOS privacy manifest missing ${apiType}`); + } + assertSameList( + entry.NSPrivacyAccessedAPITypeReasons ?? [], + reasons, + `mobile shell iOS privacy reasons for ${apiType}`, + ); +} + +if (appConfig.android?.package !== 'world.genarrative.mobile') { + throw new Error('mobile shell Android package must be world.genarrative.mobile'); +} + +if (appConfig.android?.versionCode !== 1) { + throw new Error('mobile shell Android versionCode must start at 1'); +} + +if (appConfig.android?.usesCleartextTraffic !== false) { + throw new Error('mobile shell Android package must disable cleartext traffic'); +} + +if (appConfig.android?.allowBackup !== false) { + throw new Error('mobile shell Android package must disable app data backup'); +} + +if (appConfig.android?.softwareKeyboardLayoutMode !== 'resize') { + throw new Error('mobile shell Android keyboard layout must resize the WebView'); +} + +if ( + !Array.isArray(appConfig.android?.permissions) || + appConfig.android.permissions.length !== 2 || + appConfig.android.permissions[0] !== 'android.permission.POST_NOTIFICATIONS' || + appConfig.android.permissions[1] !== 'android.permission.RECORD_AUDIO' +) { + throw new Error('mobile shell Android package must request only POST_NOTIFICATIONS and RECORD_AUDIO for real local notifications and same-origin H5 microphone gameplay'); +} +if (appConfig.android?.permissions?.includes('android.permission.CAMERA')) { + throw new Error('mobile shell Android CAMERA permission must come from real camera plugins only'); +} + +for (const permission of blockedAndroidPermissions) { + if (!appConfig.android?.blockedPermissions?.includes(permission)) { + throw new Error(`mobile shell Android package must block ${permission}`); + } +} + +for (const permission of blockedAndroidPermissions) { + if (appConfig.android?.permissions?.includes(permission)) { + throw new Error(`mobile shell Android package must not request ${permission}`); + } +} + +if (appConfig.android?.blockedPermissions?.includes('android.permission.RECORD_AUDIO')) { + throw new Error('mobile shell Android package must not block RECORD_AUDIO needed by same-origin H5 microphone gameplay'); +} +if (appConfig.android?.blockedPermissions?.includes('android.permission.POST_NOTIFICATIONS')) { + throw new Error('mobile shell Android package must not block POST_NOTIFICATIONS needed by local notification.showLocal delivery'); +} + +if ( + appConfig.android?.adaptiveIcon?.foregroundImage !== './assets/icon.png' || + appConfig.android?.adaptiveIcon?.backgroundColor !== brandBackgroundColor +) { + throw new Error('mobile shell Android adaptive icon must use the real brand icon and brand background'); +} + +const androidFilters = appConfig.android?.intentFilters ?? []; +if (androidFilters.length !== 1) { + throw new Error('mobile shell Android app link filter must be the only intent filter'); +} + +const [androidFilter] = androidFilters; +if (androidFilter.action !== 'VIEW' || androidFilter.autoVerify !== true) { + throw new Error('mobile shell Android app link filter must be a verified VIEW filter'); +} + +assertSameList( + androidFilter.category ?? [], + ['BROWSABLE', 'DEFAULT'], + 'mobile shell Android app link categories', +); + +const androidFilterData = androidFilter.data ?? []; +if ( + androidFilterData.length !== 1 || + androidFilterData[0]?.scheme !== 'https' || + androidFilterData[0]?.host !== sharedPublicWebHost || + Object.keys(androidFilterData[0] ?? {}).some( + (key) => key !== 'scheme' && key !== 'host', + ) +) { + throw new Error( + `mobile shell Android app link data must only bind ${sharedPublicWebOrigin}`, + ); +} + +if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) { + throw new Error('mobile shell extra HostBridge version must match shared HostBridge version'); +} + +for (const snippet of [ + 'Linking.getInitialURL()', + "Linking.addEventListener('url'", + 'resolveMobileShellUrlFromDeepLink', + 'logMobileShellDeepLinkFailure', + "'initial_url.read'", + "logMobileShellDeepLinkFailure(`${source}.rejected`, url)", + 'configureMobileHostBridgeNavigation', + 'HOST_BRIDGE_PROTOCOL', + 'HOST_BRIDGE_VERSION', + 'shouldAcceptMobileShellHostBridgeMessage', + 'webViewRef.current?.reload()', + 'const reloadCurrentWebView = useCallback(() => {', + 'reloadWebView: reloadCurrentWebView', + 'MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS', + 'MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT', + 'handleWebViewProcessFailure', + 'mobile WebView process failed for', + "handleWebViewProcessFailure('content_process_terminated')", + "handleWebViewProcessFailure('render_process_gone')", + 'normalizeMobileShellLoadFailure', + 'handleWebViewLoadError', + 'handleWebViewHttpError', + 'handleRetryLoadFailure', + 'onError={handleWebViewLoadError}', + 'onHttpError={handleWebViewHttpError}', + 'loadFailurePanel', + 'loadFailureButton', + 'AppState.addEventListener', + 'app.lifecycle', + 'network.statusChanged', + 'getMobileNetworkStatus', + 'subscribeMobileNetworkStatus', + 'nativeCanGoBackRef', + 'h5CanGoBackRef', + 'syncNavigationCanGoBack', + 'resetNavigationCanGoBack', + 'isShellMountedRef', + 'injectHostBridgeMessage', + 'injectHostBridgeEvent', + 'injectLifecycleEvent', + 'injectNetworkStatusEvent', + 'logMobileHostEventFailure', + 'logMobileHostBridgeMessageFailure', + 'try {', + 'if (!isShellMountedRef.current)', + 'logMobileHostEventFailure(event, error)', + 'injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure)', + "console.warn('mobile HostBridge message injection failed')", + "logMobileHostEventFailure('network.statusChanged', error)", + 'handleWebViewLoad', + 'onLoad={handleWebViewLoad}', + 'navigation.canGoBack', + "syncNavigationCanGoBack('h5', historyState.canGoBack)", + "syncNavigationCanGoBack('native', event.canGoBack)", + "webViewRef.current?.injectJavaScript('window.history.back(); true;')", + 'buildHostBridgeMessageScript', + 'parseMobileWebViewHistoryStateMessage', + 'origin: window.location.origin', + 'source: window', + 'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT', + 'shouldBlockMobileWebViewNavigationRequest', + 'logMobileShellDownloadBlocked', + "console.warn('mobile shell blocked WebView file download')", + 'SafeAreaProvider', + 'SafeAreaView', + 'MOBILE_SHELL_SAFE_AREA_EDGES', + 'resolveMobileShellBaseWebUrl', + 'originWhitelist={[allowedWebOrigin]}', + 'javaScriptCanOpenWindowsAutomatically={false}', + 'mixedContentMode="never"', + 'allowFileAccess={false}', + 'allowFileAccessFromFileURLs={false}', + 'allowUniversalAccessFromFileURLs={false}', + 'allowsFullscreenVideo', + 'allowsInlineMediaPlayback', + 'mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"', + 'mediaPlaybackRequiresUserAction', + 'thirdPartyCookiesEnabled={false}', + 'sharedCookiesEnabled={false}', + 'webviewDebuggingEnabled={false}', + 'injectedJavaScriptBeforeContentLoaded={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT}', + 'handleBlockedFileDownload', + 'logMobileShellDownloadBlocked(event)', + 'onFileDownload={handleBlockedFileDownload}', + 'setSupportMultipleWindows={false}', + 'logMobileShellNavigationFailure', + 'mobile shell navigation failed for', + 'external_navigation.open', +]) { + if (!shellAppSource.includes(snippet)) { + throw new Error(`mobile shell ShellApp missing ${snippet}`); + } +} + +if (shellAppSource.includes('catch(() => undefined)')) { + throw new Error('mobile shell ShellApp must not hide async navigation failures'); +} + +if (shellAppSource.includes("console.warn('mobile WebView process failed', {")) { + throw new Error('mobile shell WebView process diagnostics must not log detail objects'); +} + +if (!shellAppTestSource.includes('blocked WebView file downloads are logged for host diagnostics')) { + throw new Error('mobile shell tests must cover blocked WebView file download diagnostics'); +} + +if (!shellAppTestSource.includes('WebView origin whitelist is limited to the resolved H5 origin')) { + throw new Error('mobile shell tests must cover WebView origin whitelist boundaries'); +} + +for (const snippet of [ + "describe('mobile shell safe area'", + "test('protects the WebView from every device edge'", + "test('keeps the edge list fixed for shell layout usage'", + 'expect(MOBILE_SHELL_SAFE_AREA_EDGES).toHaveLength(4)', + "'bottom',", + "'left',", + "'right',", + "'top',", +]) { + if (!safeAreaTestSource.includes(snippet)) { + throw new Error(`mobile shell safe-area tests missing ${snippet}`); + } +} + +for (const snippet of [ + 'lifecyclePayloadFromAppState', + "state === 'active'", + "state === 'background'", + "? 'background'", + ": 'inactive'", + 'focused: state === \'active\'', + 'nativeState: state', +]) { + if (!lifecycleSource.includes(snippet)) { + throw new Error(`mobile shell lifecycle mapper missing ${snippet}`); + } +} + +for (const snippet of [ + "describe('lifecycle'", + "test('把 React Native AppState 映射为统一 HostBridge 生命周期状态'", + "lifecyclePayloadFromAppState('active')", + "lifecyclePayloadFromAppState('background')", + "lifecyclePayloadFromAppState('inactive')", + "lifecyclePayloadFromAppState('unknown')", + "state: 'active'", + "state: 'background'", + "state: 'inactive'", + 'focused: true', + 'focused: false', + "nativeState: 'unknown'", +]) { + if (!lifecycleTestSource.includes(snippet)) { + throw new Error(`mobile shell lifecycle tests missing ${snippet}`); + } +} + +for (const snippet of [ + 'type HostBridgeEventName', + 'const injectHostBridgeMessage = useCallback(', + 'buildHostBridgeMessageScript(message)', + 'onError(error)', + '(event: HostBridgeEventName, payload: unknown)', + 'injectHostBridgeMessage(', + 'bridge: HOST_BRIDGE_PROTOCOL', + 'version: HOST_BRIDGE_VERSION', + 'event', + 'payload', + '(error) => logMobileHostEventFailure(event, error)', + 'injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure)', +]) { + if (!shellAppSource.includes(snippet)) { + throw new Error(`mobile shell HostBridge event injection missing ${snippet}`); + } +} + +for (const eventName of sharedEvents) { + if ( + mobileCapabilitySet.has(eventName) && + !shellAppSource.includes(`'${eventName}'`) + ) { + throw new Error( + `mobile shell advertises HostBridge event ${eventName} but ShellApp does not inject it`, + ); + } +} + +for (const snippet of [ + 'MobileShellLoadFailureInput', + 'normalizeMobileShellLoadFailure', + 'shouldShowLoadFailure', + 'sameDocumentUrl', + 'sanitizeLoadFailureUrl', + 'shouldOpenInMobileShellWebView', + "input.type === 'http'", + "input.type === 'process'", + "title: '页面已停止'", + "title: '网络不可用'", + "retryLabel: '重试'", + "url.pathname !== '/favicon.ico'", +]) { + if (!loadFailureSource.includes(snippet)) { + throw new Error(`mobile shell load failure policy missing ${snippet}`); + } +} + +if ( + loadFailureSource.includes('new URL(input.url, allowedOrigin).toString()') || + loadFailureSource.includes('description ??') || + loadFailureSource.includes('normalizeDescription') +) { + throw new Error( + 'mobile shell load failure panel must not expose full URLs or native descriptions', + ); +} + +for (const snippet of [ + "describe('loadFailure'", + "test('归一化同源 HTTP 加载失败'", + "test('归一化同源原生加载失败并隐藏系统错误描述'", + "test('忽略外域和非页面加载失败'", + "test('只展示当前主页面失败'", + "test('连续 WebView 进程恢复失败时展示同源页面兜底'", + "type: 'process'", + "title: '页面已停止'", + "detail: '服务器暂时没有返回可用页面'", + "detail: '当前页面没有加载成功'", + "detail: '当前页面连续恢复失败'", + "url: 'https://example.com/'", + "url: 'about:blank'", + "url: 'javascript:alert(1)'", + "url: '/favicon.ico'", + "url: 'https://www.genarrative.world/assets/main.js'", + "https://www.genarrative.world/creation/puzzle?sessionId=private#recover", + "url: 'https://www.genarrative.world/works/detail'", +]) { + if (!loadFailureTestSource.includes(snippet)) { + throw new Error(`mobile shell load failure tests missing ${snippet}`); + } +} + +for (const snippet of [ + 'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', + 'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', + 'mobileWebViewBlockedDownloadProtocolMapScript', + 'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', + 'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', + 'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT', + 'shouldBlockMobileWebViewDownloadUrl', + 'shouldBlockMobileWebViewNavigationRequest', + "target.closest('a')", + 'event.stopImmediatePropagation()', + 'window.open = function(url)', + 'HTMLAnchorElement.prototype.click', + 'window.history.pushState = function(state, title, url)', + 'window.history.replaceState = function(state, title, url)', + "window.addEventListener('popstate'", + 'ReactNativeWebView', + 'genarrative.mobile.historyState', + '__genarrativeMobileHistoryIndex', + '__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__', + "console.warn('mobile navigation state sync failed')", +]) { + if (!webViewPolicySource.includes(snippet)) { + throw new Error(`mobile shell WebView policy missing ${snippet}`); + } +} + +if ( + webViewPolicySource.includes('catch (error) {}') || + webViewPolicySource.includes('catch (_error) {}') +) { + throw new Error('mobile shell WebView policy must not hide injected script failures'); +} + +if ( + webViewPolicySource.includes("['blob:'") || + webViewPolicySource.includes("'filesystem:'") +) { + throw new Error( + 'mobile shell WebView blocked download protocols must come from shared contract', + ); +} + +for (const snippet of [ + 'parseMobileWebViewHistoryStateMessage', + 'genarrative.mobile.historyState', + 'typeof candidate.canGoBack !== \'boolean\'', +]) { + if (!webViewHistorySource.includes(snippet)) { + throw new Error(`mobile shell WebView history parser missing ${snippet}`); + } +} + +if (!appSource.includes("import ShellApp from './src/shell/ShellApp';")) { + throw new Error('mobile shell App must import the shell app facade'); +} + +if (!appSource.includes('return ;')) { + throw new Error('mobile shell App must only render the shell app facade'); +} + +if (appSource.includes('./src/host-bridge/')) { + throw new Error('mobile shell App must not import HostBridge directly'); +} + +if ( + shellAppSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||') +) { + throw new Error( + 'mobile shell ShellApp must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL', + ); +} + +for (const snippet of [ + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', + 'HOST_BRIDGE_PUBLIC_WEB_URL', + 'DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL', + 'ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN', + 'LOCAL_DEVELOPMENT_WEB_HOSTS', + "'127.0.0.1'", + "'localhost'", + "'[::1]'", + 'isAllowedMobileShellBaseUrl', +]) { + if (!urlSource.includes(snippet)) { + throw new Error(`mobile shell H5 URL allowlist missing ${snippet}`); + } +} + +for (const localWebOriginDuplicate of [ + `DEFAULT_MOBILE_SHELL_WEB_URL = '${sharedPublicWebUrl}'`, + `ALLOWED_PRODUCTION_WEB_ORIGIN = '${sharedPublicWebOrigin}'`, +]) { + if (urlSource.includes(localWebOriginDuplicate)) { + throw new Error('mobile shell public web origin must come from shared HostBridge contract'); + } +} + +if (!webViewPolicySource.includes('DEFAULT_MOBILE_SHELL_WEB_URL')) { + throw new Error('mobile shell WebView policy must use the shared default H5 URL'); +} + +if (webViewPolicySource.includes(`'${sharedPublicWebUrl}'`)) { + throw new Error('mobile shell WebView policy must not duplicate the public web URL'); +} + +for (const snippet of [ + 'normalizeHostBridgeShareOpenPayload', + 'type HostBridgeRequest', + 'const explicitPayload = normalizeHostBridgeShareOpenPayload(request.payload);', + 'normalizeHostBridgeShareOpenPayload(currentShareTarget)', + 'type ShareOpenPayload', + 'let currentShareTarget: ShareOpenPayload | null = null;', + 'currentShareTarget = normalizedTarget.payload;', + 'setMobileHostBridgeShareTarget', + 'logMobileShareFailure', + 'mobile share failed for', + 'request: HostBridgeRequest', + "throw invalidRequest('target is required')", + 'const normalizedTarget = normalizeHostBridgeShareOpenPayload(target);', + "'share target is invalid'", + "logMobileShareFailure('open.share', error)", + "message: 'share unavailable'", + 'ok(request, true)', + 'resetMobileHostBridgeShareTargetForTest', +]) { + if (!shareSource.includes(snippet)) { + throw new Error(`mobile shell share URL policy missing ${snippet}`); + } +} + +if ( + !dispatchSource.includes('setMobileHostBridgeShareTarget(request)') || + !dispatchSource.includes('openShare(request)') || + dispatchSource.includes('ok(request, await openShare') || + dispatchSource.includes('ok(request, setMobileHostBridgeShareTarget') || + dispatchSource.includes('let currentShareTarget') || + dispatchSource.includes('normalizeHostBridgeShareOpenPayload(currentShareTarget)') +) { + throw new Error('mobile shell share HostBridge methods must delegate to share module'); +} + +for (const snippet of [ + 'resetMobileHostBridgeShareTargetForTest()', + 'uses cached work target when share.open has no explicit payload', + 'stores only the normalized cached share payload', + 'keeps the previous cached target when a new target is missing or invalid', + 'maps native share sheet failures to stable host errors', + "vi.spyOn(console, 'warn')", + 'mobile share failed for open.share', + 'does not fall back to cached target when explicit payload is unsafe', + 'rejects empty share requests before opening native share sheet', + "message: 'share unavailable'", + 'https://www.genarrative.world/works/detail?work=PZ-00000001', + 'javascript:alert(1)', + 'expect(Share.share).not.toHaveBeenCalled()', +]) { + if (!shareTestSource.includes(snippet)) { + throw new Error(`mobile shell share tests missing ${snippet}`); + } +} + +if (shareSource.includes("const WEB_APP_ORIGIN = 'https://www.genarrative.world'")) { + throw new Error('mobile shell share URL policy must reuse the shared web origin'); +} + +for (const snippet of [ + 'buildMobileShellUrl(', + 'MobileShellBaseWebUrlOptions', + 'allowLocalDevelopment?: boolean', + 'Boolean(options.allowLocalDevelopment)', + 'HOST_BRIDGE_NATIVE_APP_QUERY', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY', + 'HOST_BRIDGE_VERSION.toString()', + 'HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime', + 'HOST_BRIDGE_NATIVE_APP_QUERY.clientType', + 'HOST_BRIDGE_NATIVE_APP_QUERY.hostShellExpoMobile', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientType', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostShell', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostPlatform', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostVersion', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.bridgeVersion', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostCapabilities', + ]) { + if (!urlSource.includes(snippet)) { + throw new Error(`mobile shell host-context URL builder missing ${snippet}`); + } +} + +assertSameList( + Object.values(sharedNativeAppQueryKey), + sharedNativeAppQueryKeys, + 'shared native app query key map', +); +if ( + sharedNativeAppQuery.clientRuntime !== 'native_app' || + sharedNativeAppQuery.clientType !== 'native_app' || + sharedNativeAppQuery.hostShellExpoMobile !== 'expo_mobile' +) { + throw new Error('shared native app mobile query values drifted'); +} + +for (const hardcodedHostContextSnippet of [ + "url.searchParams.set('clientRuntime'", + "url.searchParams.set('clientType'", + "url.searchParams.set('hostShell'", + "url.searchParams.set('hostPlatform'", + "url.searchParams.set('hostVersion'", + "url.searchParams.set('bridgeVersion'", + "url.searchParams.set('hostCapabilities'", +]) { + if (urlSource.includes(hardcodedHostContextSnippet)) { + throw new Error( + 'mobile shell host-context URL builder must use shared query key constants', + ); + } +} + +for (const snippet of [ + '附加宿主上下文前会清理旧移动壳 query', + 'clientRuntime=browser&hostShell=old_shell&hostCapabilities=old', + "expect(builtUrl.match(/clientRuntime=/g)).toHaveLength(1)", + "expect(builtUrl.match(/hostShell=/g)).toHaveLength(1)", + "expect(builtUrl.match(/hostCapabilities=/g)).toHaveLength(1)", +]) { + if (!urlTestSource.includes(snippet)) { + throw new Error(`mobile shell URL tests must cover stale host-context cleanup: ${snippet}`); + } +} + +if ( + !shellAppSource.includes('allowLocalDevelopment: __DEV__') || + !shellAppSource.includes('baseWebUrlOptions') || + !deepLinkSource.includes('baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}') || + !deepLinkSource.includes('resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,') +) { + throw new Error('mobile shell must only allow local H5 URLs through explicit development mode'); +} + +for (const snippet of [ + '移动壳基准 URL 默认只接受生产主站', + "expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe(", + '移动壳基准 URL 只在开发模式接受本机入口', + '移动壳 URL 构建默认不会接受本机入口', + '移动壳 URL 构建只在显式开发模式接受本机入口', + '基准 H5 URL 只在显式开发模式保留本机 deep link 入口', + 'allowLocalDevelopment: true', + 'production shell ignores local H5 URL env before opening WebView', + 'development shell allows explicit local H5 URL env', +]) { + if ( + !urlTestSource.includes(snippet) && + !deepLinkTestSource.includes(snippet) && + !shellAppTestSource.includes(snippet) + ) { + throw new Error(`mobile shell local H5 URL tests must include ${snippet}`); + } +} + +for (const snippet of [ + 'MobileShellDeepLinkResolution', + "status: 'default'", + "status: 'mapped'", + "status: 'rejected'", + 'resolveMobileShellUrlFromDeepLink', + 'resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,', + 'resolveTargetPath(rawUrl, webOrigin)', + 'buildMobileShellUrl(\n new URL(targetPath, webOrigin).toString(),\n options,\n baseWebUrlOptions,', +]) { + if (!deepLinkSource.includes(snippet)) { + throw new Error(`mobile shell deep link host-context flow missing ${snippet}`); + } +} + +for (const snippet of [ + 'normalizeHostBridgeExternalUrl', + 'MobileShellExternalNavigator', + 'openMobileShellExternalNavigation', + 'resolveMobileShellWebViewUrl', + 'return normalizeHostBridgeExternalUrl(rawUrl)', + 'navigator.canOpenURL(externalUrl)', + 'navigator.openURL(externalUrl)', + 'return false;', + 'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)', + 'new URL(rawUrl, allowedOrigin).toString()', +]) { + if (!navigationSource.includes(snippet)) { + throw new Error(`mobile shell native-page navigation policy missing ${snippet}`); + } +} + +if ( + !navigationTestSource.includes('WebView 外链原生探测或打开失败时抛给壳层记录') || + !navigationTestSource.includes('native canOpenURL failed') || + !navigationTestSource.includes('native openURL failed') || + !navigationTestSource.includes(").rejects.toThrow('native canOpenURL failed')") || + !navigationTestSource.includes(").rejects.toThrow('native openURL failed')") +) { + throw new Error('mobile shell navigation tests must cover native external open failures'); +} + +if ( + navigationSource.includes('javascript:') || + navigationSource.includes('mailto:') || + navigationSource.includes('tel:+') || + navigationSource.includes("protocol === '") +) { + throw new Error( + 'mobile shell WebView external protocol policy must use shared HostBridge normalizer', + ); +} + +if (shellAppSource.includes('127.0.0.1:3000')) { + throw new Error( + 'mobile shell ShellApp must not hard-code localhost as the default H5 URL', + ); +} + +for (const dependency of [ + 'expo-camera', + 'expo-file-system', + 'expo-document-picker', + 'expo-image-picker', + 'expo-network', + 'expo-notifications', + 'expo-sharing', + 'react-native-safe-area-context', +]) { + if (!packageConfig.dependencies?.[dependency]) { + throw new Error(`mobile shell package missing ${dependency}`); + } +} + +const imagePickerPlugin = appConfig.plugins?.find((plugin) => + Array.isArray(plugin) ? plugin[0] === 'expo-image-picker' : plugin === 'expo-image-picker', +); +if (!imagePickerPlugin) { + throw new Error('mobile shell image picker plugin is missing'); +} + +if (Array.isArray(imagePickerPlugin)) { + const pluginOptions = imagePickerPlugin[1] ?? {}; + if ( + pluginOptions.photosPermission !== + '允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。' + ) { + throw new Error('mobile shell image picker photo permission text must describe selected creative image import'); + } + if ( + pluginOptions.cameraPermission !== + '允许 Genarrative 使用相机拍摄创作素材和参考图。' + ) { + throw new Error('mobile shell image picker camera permission text must describe creative image capture'); + } + if ( + pluginOptions.microphonePermission !== + '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。' + ) { + throw new Error('mobile shell image picker microphone text must describe same-origin H5 gameplay input'); + } +} + +const cameraPlugin = appConfig.plugins?.find((plugin) => + Array.isArray(plugin) ? plugin[0] === 'expo-camera' : plugin === 'expo-camera', +); +if (!cameraPlugin) { + throw new Error('mobile shell camera plugin is missing'); +} + +if (Array.isArray(cameraPlugin)) { + const pluginOptions = cameraPlugin[1] ?? {}; + if ( + pluginOptions.cameraPermission !== + '允许 Genarrative 使用相机扫描二维码。' + ) { + throw new Error('mobile shell camera permission text must describe QR scanning'); + } + if ( + pluginOptions.microphonePermission !== + '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。' + ) { + throw new Error('mobile shell camera microphone text must describe same-origin H5 gameplay input'); + } + if (pluginOptions.recordAudioAndroid !== true) { + throw new Error('mobile shell camera plugin must enable Android record audio for same-origin H5 microphone gameplay'); + } +} + +const notificationsPlugin = appConfig.plugins?.find((plugin) => + Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications', +); +if (!notificationsPlugin) { + throw new Error('mobile shell notifications plugin is missing'); +} + +if ( + appConfig.plugins?.some((plugin) => + Array.isArray(plugin) ? plugin[0] === 'expo-updates' : plugin === 'expo-updates', + ) +) { + throw new Error('mobile shell must not install expo-updates without a real release channel'); +} + +if (Array.isArray(notificationsPlugin)) { + const pluginOptions = notificationsPlugin[1] ?? {}; + if (pluginOptions.enableBackgroundRemoteNotifications !== false) { + throw new Error('mobile shell must not enable background remote notifications'); + } +} + +for (const forbiddenNotificationSnippet of [ + 'getExpoPushTokenAsync', + 'getDevicePushTokenAsync', + 'addPushTokenListener', + 'addNotificationResponseReceivedListener', + ...blockedScheduledNotificationSnippets, +]) { + if (hostBridgeSource.includes(forbiddenNotificationSnippet)) { + throw new Error( + `mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`, + ); + } +} + +for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) { + if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) { + throw new Error( + `mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`, + ); + } +} + +if ( + hostBridgeSource.includes("const LOCAL_NOTIFICATION_CHANNEL_ID = '") || + hostBridgeSource.includes('"genarrative-local"') || + hostBridgeSource.includes("'genarrative-local'") +) { + throw new Error( + 'mobile shell local notification channel id must come from shared contract', + ); +} + +if (sharedMobileLocalNotificationChannelId !== 'genarrative-local') { + throw new Error('shared mobile local notification channel id drifted'); +} + +if (!notificationsSource.includes('HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID')) { + throw new Error('mobile shell must use the shared local notification channel id'); +} + +if ( + !/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test( + notificationsSource, + ) +) { + throw new Error('mobile shell local notifications must stay immediate and channel-only'); +} + +if ( + !dispatchSource.includes('showMobileHostBridgeLocalNotification(request)') || + dispatchSource.includes('ok(request, await showMobileHostBridgeLocalNotification') || + dispatchSource.includes('normalizeHostBridgeLocalNotification') || + dispatchSource.includes("from 'expo-notifications'") || + dispatchSource.includes('Notifications.scheduleNotificationAsync') || + dispatchSource.includes('Notifications.requestPermissionsAsync') || + dispatchSource.includes('Notifications.setNotificationChannelAsync') +) { + throw new Error( + 'mobile shell dispatch must delegate local notification delivery to notifications.ts', + ); +} +for (const notificationSnippet of [ + 'showMobileHostBridgeLocalNotification', + 'type HostBridgeRequest', + 'request: HostBridgeRequest', + 'normalizeHostBridgeLocalNotification(request.payload)', + "invalidRequest('title is required')", + "message: 'notification permission unavailable'", + "message: 'notification delivery unavailable'", + 'logMobileNotificationFailure', + 'mobile notification failed for', + "logMobileNotificationFailure('permission.current', error)", + "logMobileNotificationFailure('permission.request', error)", + "logMobileNotificationFailure('delivery.schedule', error)", + 'showMobileLocalNotification(notification)', + 'ok(request, await showMobileLocalNotification(notification))', + 'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT', +]) { + if (!notificationsSource.includes(notificationSnippet)) { + throw new Error(`mobile shell notifications module is missing ${notificationSnippet}`); + } +} + +for (const snippet of [ + 'rejects delivery when current permission lookup is unavailable', + 'rejects delivery when permission request is unavailable', + 'maps Android channel setup failures to stable delivery errors', + 'maps native notification schedule failures to stable delivery errors', + "message: 'notification permission unavailable'", + "message: 'notification delivery unavailable'", + 'mobile notification failed for permission.current', + 'mobile notification failed for permission.request', + 'mobile notification failed for delivery.schedule', + 'expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()', + 'expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()', +]) { + if (!notificationsTestSource.includes(snippet)) { + throw new Error(`mobile shell notification tests missing ${snippet}`); + } +} + +for (const snippet of [ + 'file.exportText', + 'file.importText', + 'file.importDocument', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'scanner.scanQrCode', + 'file.importAudio', + 'file.exportAudio', + 'clipboard.readText', + 'notification.showLocal', + 'showMobileLocalNotification', + 'network.status', + 'app.reloadWebView', + 'getMobileNetworkStatus', + 'Notifications.scheduleNotificationAsync', + 'Notifications.setNotificationChannelAsync', + 'Notifications.getPermissionsAsync', + 'Notifications.requestPermissionsAsync', + 'Sharing.shareAsync', + 'assertMobileFileSharingAvailable', + 'shareMobileFile', + 'logMobileHostBridgeFileFailure', + 'mobile HostBridge file failed for', + "logMobileHostBridgeFileFailure('sharing.available', error)", + "logMobileHostBridgeFileFailure('sharing.open', error)", + "logMobileHostBridgeFileFailure('document_picker.open', error)", + "logMobileHostBridgeFileFailure('file.read_text', error)", + "logMobileHostBridgeFileFailure('file.read_base64', error)", + "logMobileHostBridgeFileFailure('image.library_permission', error)", + "logMobileHostBridgeFileFailure('image.library_open', error)", + "logMobileHostBridgeFileFailure('image.camera_permission', error)", + "logMobileHostBridgeFileFailure('image.camera_open', error)", + 'pickMobileDocumentFile', + 'readMobileTextFile', + 'readMobileBase64File', + 'DocumentPicker.getDocumentAsync', + 'Clipboard.getStringAsync', + 'ImagePicker.launchImageLibraryAsync', + 'ImagePicker.launchCameraAsync', + 'ImagePicker.requestMediaLibraryPermissionsAsync', + 'ImagePicker.requestCameraPermissionsAsync', + 'scanQrCode', + 'normalizeHostBridgeQrCodeValue', + 'completeQrCodeScan', + 'cancelQrCodeScan', + 'failQrCodeScan', + 'subscribeQrScannerState', + 'MOBILE_DOCUMENT_PICKER_TYPES', + 'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES', + "'audio/*'", + 'File(asset.uri)', + 'file.base64()', + 'detectImageMimeType', + 'detectAudioMimeType', + 'ensureImageBytesMatchMimeType', + 'ensureAudioBytesMatchMimeType', + "'image bytes do not match MIME'", + "'audio bytes do not match MIME'", + 'normalizeExportedImageFileName', + 'normalizeHostBridgeExportFileName', + 'normalizeHostBridgeClipboardText', + 'normalizeHostBridgeExternalUrlPayload', + 'normalizeHostBridgeHapticsImpactStyle', + 'base64Data', + 'isHostBridgeMethod', + 'normalizeHostBridgeRequestId', + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + 'completedHostBridgeResponses', + 'inFlightHostBridgeResponses', + 'resolveMobileHostBridgeResponse', + 'rememberHostBridgeResponse', + 'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,', +]) { + if (!hostBridgeSource.includes(snippet)) { + throw new Error(`mobile shell HostBridge missing ${snippet}`); + } +} + +for (const [functionName, pickerCall] of [ + ['importImageFile', 'ImagePicker.launchImageLibraryAsync({'], + ['captureImageFile', 'ImagePicker.launchCameraAsync({'], +]) { + const functionBody = extractFunctionBody(hostBridgeSource, functionName); + for (const requiredPickerOption of [ + pickerCall, + 'allowsEditing: false', + 'base64: true', + 'exif: false', + "mediaTypes: ['images']", + 'quality: 1', + ]) { + if (!functionBody.includes(requiredPickerOption)) { + throw new Error( + `mobile shell ${functionName} picker options missing ${requiredPickerOption}`, + ); + } + } +} + +assertMobileDocumentPickerBoundary( + 'importTextFile', + "['text/*', 'application/json']", +); +assertMobileDocumentPickerBoundary( + 'importDocumentFile', + 'MOBILE_DOCUMENT_PICKER_TYPES', +); +assertMobileDocumentPickerBoundary( + 'importAudioFile', + 'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES', +); + +const exportImageFileBody = extractFunctionBody(filesSource, 'exportImageFile'); +if ( + !exportImageFileBody.includes( + 'bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + ) +) { + throw new Error('mobile shell image export must reject empty and oversized bytes'); +} + +for (const [functionName, mimeCheckSnippet, label] of [ + [ + 'exportTextFile', + 'HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)', + 'text', + ], + [ + 'exportImageFile', + 'HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType)', + 'image', + ], + [ + 'exportAudioFile', + 'HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType)', + 'audio', + ], +]) { + const functionBody = extractFunctionBody(filesSource, functionName); + const mimeValidationIndex = functionBody.indexOf(mimeCheckSnippet); + const sharingAvailabilityIndex = functionBody.indexOf( + 'await assertMobileFileSharingAvailable()', + ); + if ( + mimeValidationIndex < 0 || + sharingAvailabilityIndex < 0 || + mimeValidationIndex > sharingAvailabilityIndex + ) { + throw new Error( + `mobile shell ${label} export must validate MIME before native sharing checks`, + ); + } +} + +for (const [wrapperName, fileCall] of [ + ['exportMobileHostBridgeTextFile', 'ok(request, await exportTextFile(request.payload))'], + ['importMobileHostBridgeTextFile', 'ok(request, await importTextFile())'], + ['importMobileHostBridgeDocumentFile', 'ok(request, await importDocumentFile())'], + ['exportMobileHostBridgeImageFile', 'ok(request, await exportImageFile(request.payload))'], + ['importMobileHostBridgeImageFile', 'ok(request, await importImageFile())'], + ['captureMobileHostBridgeImageFile', 'ok(request, await captureImageFile())'], + ['exportMobileHostBridgeAudioFile', 'ok(request, await exportAudioFile(request.payload))'], + ['importMobileHostBridgeAudioFile', 'ok(request, await importAudioFile())'], +]) { + const wrapperBody = extractFunctionBody(filesSource, wrapperName); + if (!wrapperBody.includes(fileCall)) { + throw new Error(`mobile shell file HostBridge wrapper missing ${fileCall}`); + } +} + +for (const snippet of [ + 'describe(\'mobile HostBridge file actions\'', + 'exportTextFile({', + 'exportImageFile({', + 'rejects every export before cache writes when system sharing is unavailable', + 'rejects invalid text export MIME before touching native sharing', + 'const exportCases = [', + 'expect(writtenFiles).toHaveLength(0)', + 'importTextFile()', + 'importDocumentFile()', + 'importAudioFile()', + 'importImageFile()', + 'captureImageFile()', + 'exportAudioFile({', + "code: 'unsupported_capability'", + "code: 'cancelled'", + 'maps native sharing availability failures to stable export errors', + 'maps native sharing sheet failures to stable export errors', + 'maps native document picker failures to stable import errors', + 'maps native text file read failures to stable import errors', + 'maps native binary file read failures to stable import errors', + 'maps native audio file read failures to stable import errors', + "message: 'file sharing unavailable'", + "message: 'file sharing failed'", + "message: 'text file picker unavailable'", + "message: 'text file unavailable'", + "message: 'document file unavailable'", + "message: 'audio file unavailable'", + 'rejects image import before picker launch when library permission is denied', + 'rejects image import before picker launch when library permission request fails', + 'maps native image library launch failures to stable import errors', + 'rejects image capture before camera launch when camera permission request fails', + 'maps native camera launch failures to stable capture errors', + 'mobile HostBridge file failed for sharing.available', + 'mobile HostBridge file failed for sharing.open', + 'mobile HostBridge file failed for document_picker.open', + 'mobile HostBridge file failed for file.read_text', + 'mobile HostBridge file failed for file.read_base64', + 'mobile HostBridge file failed for image.library_permission', + 'mobile HostBridge file failed for image.library_open', + 'mobile HostBridge file failed for image.camera_permission', + 'mobile HostBridge file failed for image.camera_open', + "message: 'photo library permission denied'", + "message: 'photo library permission unavailable'", + "message: 'photo library unavailable'", + `expect(imageLibrary${'Mo'}${'ck'}).not.toHaveBeenCalled()`, + "message: 'camera permission denied'", + "message: 'camera permission unavailable'", + "message: 'camera unavailable'", + `expect(camera${'Mo'}${'ck'}).not.toHaveBeenCalled()`, + "mediaTypes: ['images']", + "options: { encoding: 'base64' }", +]) { + if (!filesTestSource.includes(snippet)) { + throw new Error(`mobile shell file action tests missing ${snippet}`); + } +} + +if (filesSource.includes('console.warn(`mobile HostBridge file failed for ${label}`, error)')) { + throw new Error('mobile shell file diagnostics must not log native error objects'); +} + +if (!filesSource.includes('console.warn(`mobile HostBridge file failed for ${label}`)')) { + throw new Error('mobile shell file diagnostics must use stable label-only logging'); +} + +if ( + !dispatchSource.includes('exportMobileHostBridgeTextFile(request)') || + !dispatchSource.includes('importMobileHostBridgeTextFile(request)') || + !dispatchSource.includes('importMobileHostBridgeDocumentFile(request)') || + !dispatchSource.includes('exportMobileHostBridgeImageFile(request)') || + !dispatchSource.includes('importMobileHostBridgeImageFile(request)') || + !dispatchSource.includes('captureMobileHostBridgeImageFile(request)') || + !dispatchSource.includes('exportMobileHostBridgeAudioFile(request)') || + !dispatchSource.includes('importMobileHostBridgeAudioFile(request)') || + dispatchSource.includes('ok(request, await exportTextFile(request.payload))') || + dispatchSource.includes('ok(request, await importTextFile())') || + dispatchSource.includes('ok(request, await importDocumentFile())') || + dispatchSource.includes('ok(request, await exportImageFile(request.payload))') || + dispatchSource.includes('ok(request, await importImageFile())') || + dispatchSource.includes('ok(request, await captureImageFile())') || + dispatchSource.includes('ok(request, await exportAudioFile(request.payload))') || + dispatchSource.includes('ok(request, await importAudioFile())') +) { + throw new Error('mobile shell dispatch must delegate file requests to files.ts'); +} + +if ( + !hostBridgeNavigationSource.includes( + 'const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(', + ) || + !hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(') || + !hostBridgeNavigationSource.includes('externalUrlPayload.url') || + !hostBridgeNavigationSource.includes('logMobileHostBridgeNavigationFailure') +) { + throw new Error( + 'mobile shell app.openExternalUrl must normalize payloads and use the shared external navigation helper', + ); +} +for (const snippet of [ + 'openMobileHostBridgeExternalUrl', + 'openMobileHostBridgeNativePage', + 'reloadMobileHostBridgeWebView', + 'Linking.canOpenURL', + 'Linking.openURL', + 'javascript:alert(1)', + 'external URL cannot be opened', + 'mobile HostBridge navigation failed for external.open', + 'expect(warnSpy).toHaveBeenCalledTimes(2)', + 'converts native external open exceptions to stable host_error', + 'navigation.openNativePage unsupported in mobile shell', + 'app.reloadWebView unsupported in mobile shell', + 'hostCapabilities', +]) { + if (!hostBridgeNavigationTestSource.includes(snippet)) { + throw new Error(`mobile shell HostBridge navigation test missing ${snippet}`); + } +} +if ( + !clipboardSource.includes( + 'const clipboardText = normalizeHostBridgeClipboardText(', + ) || + !clipboardSource.includes('Clipboard.setStringAsync(clipboardText.text)') || + !clipboardSource.includes('rawText = await Clipboard.getStringAsync()') +) { + throw new Error( + 'mobile shell clipboard bridge must normalize text with the shared HostBridge clipboard boundary', + ); +} + +if ( + dispatchSource.includes("from 'expo-clipboard'") || + dispatchSource.includes('Clipboard.setStringAsync') || + dispatchSource.includes('Clipboard.getStringAsync') || + dispatchSource.includes('ClipboardWriteTextPayload') || + !dispatchSource.includes('writeMobileHostBridgeClipboardText(request)') || + !dispatchSource.includes('readMobileHostBridgeClipboardText(request)') || + dispatchSource.includes('ok(request, await writeMobileHostBridgeClipboardText') || + dispatchSource.includes('ok(request, await readMobileHostBridgeClipboardText') +) { + throw new Error( + 'mobile shell dispatch must delegate clipboard IO to clipboard.ts', + ); +} +for (const snippet of [ + 'writeMobileHostBridgeClipboardText', + 'type ClipboardWriteTextPayload', + 'request: HostBridgeRequest', + 'ok(request, true)', + 'writeMobileClipboardText(text)', + 'readMobileHostBridgeClipboardText', + 'ok(request, await readMobileClipboardText())', + 'logMobileClipboardFailure', + 'mobile clipboard failed for', + "logMobileClipboardFailure('write.set_string', error)", + "logMobileClipboardFailure('read.get_string', error)", + "message: 'clipboard write unavailable'", + "message: 'clipboard read unavailable'", +]) { + if (!clipboardSource.includes(snippet)) { + throw new Error(`mobile shell clipboard module is missing ${snippet}`); + } +} +if (clipboardSource.includes('console.warn(`mobile clipboard failed for ${label}`, error)')) { + throw new Error('mobile shell clipboard diagnostics must not log native error objects'); +} +if (!clipboardSource.includes('console.warn(`mobile clipboard failed for ${label}`)')) { + throw new Error('mobile shell clipboard diagnostics must use stable label-only logging'); +} +if (!clipboardTestSource.includes("'猫'.repeat(100000)")) { + throw new Error('mobile shell clipboard tests must cover Unicode truncation'); +} +for (const snippet of [ + 'maps native clipboard write failures to stable host errors', + 'mobile clipboard failed for write.set_string', + "message: 'clipboard write unavailable'", + 'maps native clipboard read failures to stable host errors', + 'mobile clipboard failed for read.get_string', + "message: 'clipboard read unavailable'", + 'rejects unavailable clipboard text without reporting a successful empty value', + `Clipboard.getStringAsync).${'mo'}${'ck'}ResolvedValue(undefined as never)`, + "code: 'host_error'", + "message: 'clipboard text unavailable'", +]) { + if (!clipboardTestSource.includes(snippet)) { + throw new Error(`mobile shell clipboard tests must cover unavailable reads: ${snippet}`); + } +} + +for (const snippet of [ + 'try {', + 'return ok(request, await getMobileNetworkStatus())', + 'logMobileNetworkFailure', + 'mobile network failed for', + "logMobileNetworkFailure('status.query', error)", + 'return failure(request, {', + "code: 'host_error'", + "message: 'network status unavailable'", +]) { + if (!hostBridgeNetworkSource.includes(snippet)) { + throw new Error(`mobile shell network HostBridge must stabilize native failures: ${snippet}`); + } +} +for (const snippet of [ + 'converts native network failures to a stable host_error response', + "new Error('network query failed')", + 'mobile network failed for status.query', + "message: 'network status unavailable'", +]) { + if (!hostBridgeNetworkTestSource.includes(snippet)) { + throw new Error(`mobile shell network tests must cover stable native failure responses: ${snippet}`); + } +} + +if ( + !hapticsSource.includes('normalizeHostBridgeHapticsImpactStyle(rawStyle)') || + !hapticsSource.includes('type HapticsImpactPayload') || + !hapticsSource.includes('type HostBridgeRequest') || + !hapticsSource.includes('runMobileHostBridgeHapticsImpact(') || + !hapticsSource.includes('request: HostBridgeRequest') || + !hapticsSource.includes('(request.payload as HapticsImpactPayload | undefined)?.style') || + !hapticsSource.includes("invalidRequest('haptics impact style must be light, medium, or heavy')") || + !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Heavy') || + !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Medium') || + !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Light') || + !hapticsSource.includes('await Haptics.impactAsync(toExpoImpactStyle(style))') || + !hapticsSource.includes('logMobileHapticsFailure') || + !hapticsSource.includes('mobile haptics failed for') || + !hapticsSource.includes("logMobileHapticsFailure('impact.dispatch', error)") || + !hapticsSource.includes("message: 'haptics impact unavailable'") || + !hapticsSource.includes('ok(request, true)') +) { + throw new Error( + 'mobile shell haptics bridge must normalize impact style with the shared HostBridge boundary', + ); +} + +for (const snippet of [ + 'reports unavailable native feedback with a stable HostBridge error', + 'mobile haptics failed for impact.dispatch', + "message: 'haptics impact unavailable'", +]) { + if (!hapticsTestSource.includes(snippet)) { + throw new Error(`mobile shell haptics tests missing ${snippet}`); + } +} + +if ( + dispatchSource.includes("from 'expo-haptics'") || + dispatchSource.includes('Haptics.impactAsync') || + dispatchSource.includes('Haptics.ImpactFeedbackStyle') || + dispatchSource.includes('HapticsImpactPayload') || + dispatchSource.includes('haptics impact style must be light, medium, or heavy') || + dispatchSource.includes('ok(request, await runMobileHostBridgeHapticsImpact') || + !dispatchSource.includes('runMobileHostBridgeHapticsImpact(request)') +) { + throw new Error( + 'mobile shell dispatch must delegate haptics payload and IO to haptics.ts', + ); +} + +for (const snippet of [ + 'CameraView', + 'Camera.requestCameraPermissionsAsync', + 'type BarcodeScanningResult', + 'onBarcodeScanned', + 'barcodeScannerSettings', + "barcodeTypes: ['qr']", + 'completeQrCodeScan(result.data)', + 'failQrCodeScan', + 'cancelQrCodeScan', + 'subscribeQrScannerState', + 'logQrScannerPermissionFailure(error)', + "console.warn('mobile QR scanner permission request failed')", +]) { + if (!qrScannerOverlaySource.includes(snippet)) { + throw new Error(`mobile shell QR scanner overlay missing ${snippet}`); + } +} + +for (const snippet of [ + 'describe(\'QrScannerOverlay\'', + 'scanQrCode()', + 'requestCameraPermissionsAsync', + "message: 'qr scanner unavailable'", + "message: 'qr scan cancelled'", + "test('logs and rejects the active scan when camera permission request fails'", + "test('ignores late camera permission after the scan is cancelled'", + "'mobile QR scanner permission request failed'", + "barcodeTypes: ['qr']", + "type: 'qr'", + "value: 'https://www.genarrative.world/works/detail?work=PZ-1'", +]) { + if (!qrScannerOverlayTestSource.includes(snippet)) { + throw new Error(`mobile shell QR scanner overlay test missing ${snippet}`); + } +} + +if (!shellAppSource.includes('')) { + throw new Error('mobile shell ShellApp must render the QR scanner overlay'); +} + +for (const snippet of [ + 'scanner.scanQrCode', + 'requestCameraPermissionsAsync', + 'onBarcodeScanned', + 'injectJavaScript', + 'scan-request-1', +]) { + if (!shellAppTestSource.includes(snippet)) { + throw new Error(`mobile shell ShellApp QR scanner test missing ${snippet}`); + } +} + +for (const snippet of [ + "hostBridgeEvent('app.lifecycle')", + "hostBridgeEvent('network.statusChanged')", + "lastHostBridgeEvent('navigation.canGoBack')", + "shellHarness.appStateListeners[0]?.('background')", + 'shellHarness.networkListeners[0]?.({', + 'injectJavaScriptError', + "test('host event injection failures are logged without crashing the shell'", + "new Error('webview injection failed')", + "test('logs HostBridge response injection failures without crashing the shell'", + "new Error('response injection failed')", + 'mobile HostBridge message injection failed', + "test('drops delayed HostBridge responses after shell unmount'", + "id: 'network-request-1'", + 'expectInjectedHostBridgeMessageSource', + "expect(script).toContain('origin: window.location.origin')", + "expect(script).toContain('source: window')", + "type: 'genarrative.mobile.historyState'", + 'mobile host event failed for network.statusChanged', + 'external WebView navigation native failures stay outside the WebView', + "expect(Linking.openURL).toHaveBeenCalledWith(", + "'mobile shell navigation failed for external_navigation.open'", + 'initial deep link read failures are logged without replacing the current WebView URL', + "'mobile shell deep link failed for initial_url.read'", + 'runtime deep link rejections are logged and fall back to a safe WebView URL', + "'mobile shell deep link failed for runtime_url.rejected'", + 'first WebView process failure reloads the current page once', + 'mobile WebView process failed for content_process_terminated', + 'repeated WebView process failures show the load failure panel and retry clears the failure window', + "expect(screen.getByText('页面已停止')).toBeTruthy()", + 'mobile WebView process failed for render_process_gone', +]) { + if (!shellAppTestSource.includes(snippet)) { + throw new Error(`mobile shell ShellApp HostBridge event test missing ${snippet}`); + } +} + +if ( + !dispatchSource.includes('scanMobileHostBridgeQrCode(request)') || + dispatchSource.includes('ok(request, await scanQrCode())') +) { + throw new Error('mobile shell QR scanner HostBridge method must delegate to scanner module'); +} + +for (const scannerSnippet of [ + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', + 'scanMobileHostBridgeQrCode(request: HostBridgeRequest)', + 'ok(request, await scanQrCode())', + 'normalizeHostBridgeQrCodeValue', + "hostBridgeError('timeout', 'qr scan timed out')", + "hostBridgeError('cancelled', 'qr scan cancelled')", + "hostBridgeError('host_error', 'qr scanner unavailable')", + 'clearTimeout(pendingQrScan.timeout)', +]) { + if (!scannerSource.includes(scannerSnippet)) { + throw new Error(`mobile shell QR scanner module missing ${scannerSnippet}`); + } +} +for (const scannerTestSnippet of [ + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', + 'subscribeQrScannerState', + 'scanQrCode', + 'scanMobileHostBridgeQrCode', + 'completeQrCodeScan', + 'cancelQrCodeScan', + 'failQrCodeScan', + 'times out and clears the pending scan with the shared scanner timeout', + 'qr scanner already active', + 'qr scanner unavailable', + 'qr scan timed out', + 'qr scan cancelled', + 'PZ-00000001', +]) { + if (!scannerTestSource.includes(scannerTestSnippet)) { + throw new Error( + `mobile shell QR scanner helper test missing ${scannerTestSnippet}`, + ); + } +} + +const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES"; +if (shellAppSource.includes(capabilityQuerySnippet)) { + throw new Error('mobile shell URL must resolve platform-aware capabilities'); +} + +if (!shellAppSource.includes('capabilities: resolveMobileHostCapabilities()')) { + throw new Error('mobile shell URL must use resolveMobileHostCapabilities()'); +} + +if (!shellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { + throw new Error('mobile shell URL must use the shared mobile shell host version'); +} + +if (!hostBridgeRuntimeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { + throw new Error('mobile shell runtime response must use the shared mobile shell host version'); +} + +for (const runtimeSnippet of [ + 'export function getMobileRuntimePlatform()', + "return Platform.OS === 'ios' ? 'ios' : 'android'", + 'export function getMobileHostBridgeRuntime(): HostBridgeRuntimeResult', + 'const platform = getMobileRuntimePlatform();', + 'platform,', + 'capabilities: resolveMobileHostCapabilities(platform)', + 'getMobileHostBridgeRuntimeResponse(request: HostBridgeRequest)', + 'ok(request, getMobileHostBridgeRuntime())', +]) { + if (!hostBridgeRuntimeSource.includes(runtimeSnippet)) { + throw new Error(`mobile shell runtime response missing ${runtimeSnippet}`); + } +} + +if ( + !dispatchSource.includes('getMobileHostBridgeRuntimeResponse(request)') || + dispatchSource.includes('ok(request, getMobileHostBridgeRuntime') || + dispatchSource.includes('MOBILE_SHELL_HOST_VERSION') || + dispatchSource.includes('HOST_BRIDGE_VERSION') || + dispatchSource.includes('resolveMobileHostCapabilities(') || + dispatchSource.includes('getMobileRuntimePlatform') || + dispatchSource.includes("shell: 'expo_mobile'") +) { + throw new Error('mobile shell dispatch must delegate host.getRuntime to runtime.ts'); +} + +if (hostBridgeRuntimeSource.includes('capabilities: resolveMobileHostCapabilities(),')) { + throw new Error( + 'mobile shell runtime capabilities must use the same normalized platform reported in host.getRuntime', + ); +} +for (const snippet of [ + 'reports iOS runtime with host version, bridge version and iOS capabilities', + 'reports Android runtime without iOS-only app badge capability', + 'wraps runtime metadata in the HostBridge response shape', + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', + 'hostVersion: MOBILE_SHELL_HOST_VERSION', + 'bridgeVersion: HOST_BRIDGE_VERSION', + 'expect(runtime.capabilities).toBe(HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES)', + 'expect(runtime.capabilities).toBe(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES)', + "not.toContain('app.setBadgeCount')", + "expect(getMobileRuntimePlatform()).toBe('android')", +]) { + if (!hostBridgeRuntimeTestSource.includes(snippet)) { + throw new Error(`mobile shell runtime tests missing ${snippet}`); + } +} + +for (const snippet of [ + "import appConfig from '../../app.json'", + 'resolveMobileShellHostVersion(', + 'appConfig.expo?.version', + 'MOBILE_SHELL_HOST_VERSION_FALLBACK', +]) { + if (!runtimeSource.includes(snippet)) { + throw new Error(`mobile shell runtime version source missing ${snippet}`); + } +} + +if (/export const MOBILE_SHELL_HOST_VERSION\s*=\s*'/.test(runtimeSource)) { + throw new Error('mobile shell host version must be resolved from Expo runtime config'); +} + +if ( + shellAppSource.includes("hostVersion: '0.1.0'") || + hostBridgeSource.includes("hostVersion: '0.1.0'") +) { + throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source'); +} + +if (shellAppSource.includes(`bridge: '${sharedHostBridgeProtocol}'`)) { + throw new Error('mobile shell event injection must use HOST_BRIDGE_PROTOCOL'); +} + +if (shellAppSource.includes(`version: ${sharedHostBridgeVersion}`)) { + throw new Error('mobile shell event injection must use HOST_BRIDGE_VERSION'); +} + +if (urlSource.includes(`bridgeVersion', '${sharedHostBridgeVersion}'`)) { + throw new Error('mobile shell URL builder must use HOST_BRIDGE_VERSION'); +} + +for (const capability of sdkBackedCapabilities) { + if ( + shellAppSource.includes(`'${capability}'`) || + shellAppSource.includes(`"${capability}"`) + ) { + throw new Error( + `mobile shell URL must not advertise ${capability} without a real SDK/channel flow`, + ); + } +} + +for (const capability of [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'share.open', + 'share.setTarget', + 'app.lifecycle', + 'network.status', + 'network.statusChanged', + 'navigation.openNativePage', + 'navigation.canGoBack', + 'app.reloadWebView', + 'app.openExternalUrl', + 'clipboard.writeText', + 'clipboard.readText', + 'file.exportText', + 'file.importText', + 'file.importDocument', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'scanner.scanQrCode', + 'file.importAudio', + 'file.exportAudio', + 'haptics.impact', + 'notification.showLocal', +]) { + if (!mobileCapabilitySet.has(capability)) { + throw new Error(`mobile shell capabilities missing ${capability}`); + } +} + +if (!iosMobileCapabilitySet.has('app.setBadgeCount')) { + throw new Error('iOS mobile shell capabilities missing app.setBadgeCount'); +} + +if (mobileCapabilitySet.has('app.setBadgeCount')) { + throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount'); +} +for (const snippet of [ + 'MOBILE_HOST_CAPABILITIES', + 'IOS_MOBILE_HOST_CAPABILITIES', + 'resolveMobileHostCapabilities', + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', + "resolveMobileHostCapabilities('android')", + "resolveMobileHostCapabilities('ios')", + "not.toContain('app.setBadgeCount')", + "not.toContain('auth.requestLogin')", + "not.toContain('payment.request')", +]) { + if (!capabilitiesTestSource.includes(snippet)) { + throw new Error(`mobile shell capability profile test missing ${snippet}`); + } +} +if ( + !dispatchSource.includes('setMobileAppBadgeCount(request)') || + dispatchSource.includes('ok(request, setMobileAppBadgeCount') || + dispatchSource.includes('PushNotificationIOS') || + dispatchSource.includes('normalizeHostBridgeBadgeCount') || + dispatchSource.includes('HOST_BRIDGE_BADGE_COUNT_MAX') +) { + throw new Error('mobile shell badge HostBridge method must delegate to badge module'); +} +for (const snippet of [ + 'HOST_BRIDGE_BADGE_COUNT_MAX', + 'request: HostBridgeRequest', + 'normalizeHostBridgeBadgeCount', + 'Notifications.getPermissionsAsync', + 'Notifications.requestPermissionsAsync', + 'Notifications.setBadgeCountAsync(count)', + 'allowBadge: true', + 'app badge permission denied', + 'app badge update unavailable', + 'app badge count is only supported on iOS mobile shell', + 'logMobileBadgeFailure', + 'mobile app badge failed for', + "logMobileBadgeFailure('permission.current', error)", + "logMobileBadgeFailure('permission.request', error)", + "logMobileBadgeFailure('update.set_count', error)", + "logMobileBadgeFailure(", + "'update.rejected'", +]) { + if (!badgeSource.includes(snippet)) { + throw new Error(`mobile shell badge module is missing ${snippet}`); + } +} +for (const snippet of [ + "test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported'", + "test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功'", + "setPlatformOS('android')", + "request('app.setBadgeCount',", + "expect(expectFailed(unsupported).error.code).toBe('unsupported_capability')", + 'expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled()', +]) { + if (!bridgeTestSource.includes(snippet)) { + throw new Error( + `mobile shell bridge tests must cover Android app.setBadgeCount unsupported semantics: ${snippet}`, + ); + } +} +for (const snippet of [ + 'sets and clears the iOS app badge count through Expo Notifications', + 'requests iOS badge permission before updating when it is missing', + 'rejects denied iOS badge permission before touching the system badge', + 'maps native badge update false results to a stable HostBridge error', + 'maps native badge update rejections to a stable HostBridge error', + 'maps current badge permission lookup failures to a stable HostBridge error', + 'maps badge permission request failures to a stable HostBridge error', + 'mobile app badge failed for permission.current', + 'mobile app badge failed for permission.request', + 'mobile app badge failed for update.set_count', + 'mobile app badge failed for update.rejected', + 'rejects invalid badge counts before touching the system badge', + 'rejects missing badge payload before touching the system badge', + 'returns unsupported on Android before validating payload or touching badge APIs', + 'allowBadge: true', + 'app badge permission denied', + 'HOST_BRIDGE_BADGE_COUNT_MAX + 1', + "setPlatformOS('android')", + 'not.toHaveBeenCalled()', +]) { + if (!badgeTestSource.includes(snippet)) { + throw new Error(`mobile shell badge tests missing ${snippet}`); + } +} + +if ( + !dispatchSource.includes('getMobileHostBridgeAppearanceColorScheme(request)') || + dispatchSource.includes('ok(request, getMobileAppearanceColorScheme())') || + dispatchSource.includes('Appearance.getColorScheme()') || + dispatchSource.includes('normalizeHostBridgeColorScheme') +) { + throw new Error('mobile shell appearance HostBridge method must delegate to appearance module'); +} +for (const snippet of [ + 'Appearance.getColorScheme()', + 'normalizeHostBridgeColorScheme', + 'getMobileAppearanceColorScheme', + 'getMobileHostBridgeAppearanceColorScheme', + 'request: HostBridgeRequest', + 'ok(request, getMobileAppearanceColorScheme())', +]) { + if (!appearanceSource.includes(snippet)) { + throw new Error(`mobile shell appearance module is missing ${snippet}`); + } +} +for (const snippet of [ + 'reads the current light or dark system color scheme', + 'normalizes missing or unknown native color schemes to unknown', + 'wraps the system color scheme in the HostBridge response shape', + `${'mo'}${'ck'}ReturnValue('light')`, + `${'mo'}${'ck'}ReturnValue('dark')`, + 'colorScheme: \'unknown\'', +]) { + if (!appearanceTestSource.includes(snippet)) { + throw new Error(`mobile shell appearance tests missing ${snippet}`); + } +} + +if ( + !dispatchSource.includes('openMobileHostBridgeExternalUrl(request)') || + !dispatchSource.includes('openMobileHostBridgeNativePage(request, navigation)') || + !dispatchSource.includes('reloadMobileHostBridgeWebView(request, navigation)') || + dispatchSource.includes('ok(request, await openMobileHostBridgeExternalUrl') || + dispatchSource.includes('ok(request, reloadMobileHostBridgeWebView') || + dispatchSource.includes('ok(\\n request,\\n openMobileHostBridgeNativePage') || + dispatchSource.includes("from 'expo-linking'") || + dispatchSource.includes('Linking.') || + dispatchSource.includes('openMobileShellExternalNavigation') || + dispatchSource.includes('resolveMobileShellWebViewUrl') || + dispatchSource.includes('normalizeHostBridgeExternalUrlPayload') || + dispatchSource.includes('buildMobileShellUrl') +) { + throw new Error('mobile shell navigation HostBridge methods must delegate to navigation module'); +} +for (const snippet of [ + 'openMobileHostBridgeExternalUrl', + 'request: HostBridgeRequest', + "import * as Linking from 'expo-linking'", + 'openMobileShellExternalNavigation(', + 'externalUrlPayload.url', + "} catch (error) {", + "logMobileHostBridgeNavigationFailure('external.open', error)", + 'opened = false;', + '(request.payload as OpenExternalUrlPayload | undefined)?.url', + 'normalizeHostBridgeExternalUrlPayload', + "message: 'external URL cannot be opened'", + 'openMobileHostBridgeNativePage', + '(request.payload as NavigateNativePagePayload | undefined)?.url', + 'resolveMobileShellWebViewUrl', + 'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,', + 'reloadMobileHostBridgeWebView', + 'ok(request, true)', +]) { + if (!hostBridgeNavigationSource.includes(snippet)) { + throw new Error(`mobile shell navigation module is missing ${snippet}`); + } +} + +if ( + !hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(') || + !hostBridgeNavigationSource.includes('externalUrlPayload.url') +) { + throw new Error( + 'mobile shell HostBridge external URL flow must use the shared external navigation helper', + ); +} + +if ( + !dispatchSource.includes('getMobileHostBridgeNetworkStatus(request)') || + dispatchSource.includes('../shell/network') || + dispatchSource.includes('getMobileNetworkStatus()') || + dispatchSource.includes('ok(request, await getMobileHostBridgeNetworkStatus())') +) { + throw new Error('mobile shell network HostBridge method must delegate to network module'); +} +for (const snippet of [ + 'getMobileHostBridgeNetworkStatus', + 'request: HostBridgeRequest', + 'return ok(request, await getMobileNetworkStatus())', + 'return failure(request, {', + 'getMobileNetworkStatus()', +]) { + if (!hostBridgeNetworkSource.includes(snippet)) { + throw new Error(`mobile shell network module is missing ${snippet}`); + } +} +for (const snippet of [ + 'wraps Expo Network status in the HostBridge response shape', + 'normalizes disconnected network state before wrapping response', + 'converts native network failures to a stable host_error response', + 'connectionType: \'cellular\'', + 'connectionType: \'none\'', + 'network query failed', + 'network status unavailable', +]) { + if (!hostBridgeNetworkTestSource.includes(snippet)) { + throw new Error(`mobile shell network tests missing ${snippet}`); + } +} + +if (!shellAppSource.includes('openMobileShellExternalNavigation(Linking, request.url)')) { + throw new Error( + 'mobile shell WebView external navigation must use the tested external navigation helper', + ); +} + +if ( + shellAppSource.includes('Linking.canOpenURL(externalUrl)') || + shellAppSource.includes('Linking.openURL(externalUrl)') +) { + throw new Error( + 'mobile shell ShellApp must not inline external navigation Link handling', + ); +} diff --git a/apps/mobile-shell/scripts/check-eas-build-config.mjs b/apps/mobile-shell/scripts/check-eas-build-config.mjs new file mode 100644 index 000000000..596eeee94 --- /dev/null +++ b/apps/mobile-shell/scripts/check-eas-build-config.mjs @@ -0,0 +1,155 @@ +import fs from 'node:fs'; +import {spawnSync} from 'node:child_process'; + +const shellRoot = new URL('../', import.meta.url); +const easConfigPath = new URL('eas.json', shellRoot); +const packagePath = new URL('package.json', shellRoot); + +const easConfig = JSON.parse(fs.readFileSync(easConfigPath, 'utf8')); +const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const androidBuildOutputPath = + '../../build/native/mobile/genarrative-mobile-android.apk'; +const iosSimulatorBuildOutputPath = + '../../build/native/mobile/genarrative-mobile-ios-simulator.tar.gz'; + +function assertObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertNoKeys(value, blockedKeys, label) { + for (const key of blockedKeys) { + if (key in value) { + throw new Error(`${label} must not configure ${key}`); + } + } +} + +assertObject(easConfig.cli, 'EAS cli config'); +assertObject(easConfig.build, 'EAS build config'); + +if (easConfig.cli.version !== '>= 20.3.0') { + throw new Error('mobile shell EAS CLI version gate must stay pinned to the checked devDependency floor'); +} + +if (packageConfig.devDependencies?.['eas-cli'] !== '^20.3.0') { + throw new Error('mobile shell package must declare the checked EAS CLI devDependency'); +} + +const easVersionResult = spawnSync(npmCommand, ['exec', 'eas', '--', '--version'], { + cwd: shellRoot, + encoding: 'utf8', +}); + +if (easVersionResult.error) { + throw new Error(`failed to start local EAS CLI version check: ${easVersionResult.error.message}`); +} + +if (easVersionResult.signal) { + throw new Error(`local EAS CLI version check was terminated by signal ${easVersionResult.signal}`); +} + +if ((easVersionResult.status ?? 0) !== 0) { + process.stdout.write(easVersionResult.stdout ?? ''); + process.stderr.write(easVersionResult.stderr ?? ''); + process.exit(easVersionResult.status ?? 1); +} + +if (!(easVersionResult.stdout ?? '').trim().startsWith('eas-cli/20.3.0 ')) { + throw new Error('mobile shell local EAS CLI version drifted'); +} + +if (easConfig.cli.appVersionSource !== 'local') { + throw new Error('mobile shell EAS builds must use local app.json version fields'); +} + +for (const scriptName of ['build:android', 'build:ios', 'build-config:smoke']) { + if (typeof packageConfig.scripts?.[scriptName] !== 'string') { + throw new Error(`mobile shell package missing ${scriptName} script`); + } +} + +if ( + packageConfig.scripts['build:android'] !== + `eas build --local --profile production --platform android --output ${androidBuildOutputPath}` +) { + throw new Error('mobile shell Android package build script drifted'); +} + +if ( + packageConfig.scripts['build:ios'] !== + `eas build --local --profile production-simulator --platform ios --output ${iosSimulatorBuildOutputPath}` +) { + throw new Error('mobile shell iOS package build script drifted'); +} + +for (const [label, outputPath, extension] of [ + ['Android', androidBuildOutputPath, '.apk'], + ['iOS simulator', iosSimulatorBuildOutputPath, '.tar.gz'], +]) { + if (!outputPath.startsWith('../../build/native/mobile/')) { + throw new Error(`mobile shell ${label} local build output must stay under root build/native/mobile`); + } + if (!outputPath.endsWith(extension)) { + throw new Error(`mobile shell ${label} local build output must end with ${extension}`); + } +} + +if (packageConfig.scripts['build-config:smoke'] !== 'node scripts/check-eas-build-config.mjs') { + throw new Error('mobile shell EAS config smoke script drifted'); +} + +const production = easConfig.build.production; +const productionSimulator = easConfig.build['production-simulator']; +assertObject(production, 'EAS Android production profile'); +assertObject(productionSimulator, 'EAS iOS simulator production profile'); + +if (production.distribution !== 'internal' || production.channel !== 'production') { + throw new Error('mobile shell Android production profile must build an internal production package'); +} + +if (production.android?.buildType !== 'apk') { + throw new Error('mobile shell Android production profile must produce an installable APK'); +} + +if (production.env?.EXPO_NO_DOTENV !== '1') { + throw new Error('mobile shell Android production profile must ignore local dotenv files'); +} + +if ( + productionSimulator.distribution !== 'internal' || + productionSimulator.channel !== 'production' +) { + throw new Error('mobile shell iOS simulator production profile must stay internal production'); +} + +if (productionSimulator.ios?.simulator !== true) { + throw new Error('mobile shell iOS production smoke profile must target simulator builds until signing is configured'); +} + +if (productionSimulator.env?.EXPO_NO_DOTENV !== '1') { + throw new Error('mobile shell iOS simulator production profile must ignore local dotenv files'); +} + +for (const [profileName, profile] of Object.entries(easConfig.build)) { + assertObject(profile, `EAS ${profileName} profile`); + assertNoKeys( + profile, + [ + 'credentialsSource', + 'autoIncrement', + 'submit', + 'runtimeVersion', + 'releaseChannel', + ], + `EAS ${profileName} profile`, + ); +} + +if ('submit' in easConfig) { + throw new Error('mobile shell EAS config must not include store submit profiles yet'); +} + +console.log('[mobile-shell:eas-build-config] OK'); diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs new file mode 100644 index 000000000..702d899e0 --- /dev/null +++ b/apps/mobile-shell/scripts/check-expo-config.mjs @@ -0,0 +1,435 @@ +import {spawnSync} from 'node:child_process'; + +import fs from 'node:fs'; + +const appConfigPath = new URL('../app.json', import.meta.url); +const packagePath = new URL('../package.json', import.meta.url); +const configPluginsPackagePath = new URL( + '../../../node_modules/@expo/config-plugins/package.json', + import.meta.url, +); +const expoPrivacyInfoPluginPath = new URL( + '../../../node_modules/@expo/config-plugins/build/ios/PrivacyInfo.js', + import.meta.url, +); +const sharedContractPath = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); +const shellRoot = new URL('../', import.meta.url); + +const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; +const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const configPluginsPackageConfig = JSON.parse( + fs.readFileSync(configPluginsPackagePath, 'utf8'), +); +const expoPrivacyInfoPluginSource = fs.readFileSync( + expoPrivacyInfoPluginPath, + 'utf8', +); +const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +const result = spawnSync( + npmCommand, + ['exec', 'expo', 'config', '--', '--type', 'public', '--json'], + { + cwd: shellRoot, + encoding: 'utf8', + }, +); + +if (result.error) { + throw new Error(`failed to start Expo config smoke: ${result.error.message}`); +} + +if (result.signal) { + throw new Error(`Expo config smoke was terminated by signal ${result.signal}`); +} + +if ((result.status ?? 0) !== 0) { + process.stdout.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + process.exit(result.status ?? 1); +} + +function parseExpoConfigOutput(output) { + const trimmed = output.trim(); + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed); + } + + const jsonStart = output.indexOf('{'); + if (jsonStart === -1) { + throw new Error('Expo config smoke did not print JSON output'); + } + + return JSON.parse(output.slice(jsonStart)); +} + +const expoConfig = parseExpoConfigOutput(result.stdout ?? ''); + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error( + `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, + ); + } +} + +function assertIncludes(list, expected, label) { + if (!Array.isArray(list) || !list.includes(expected)) { + throw new Error(`Expo config ${label} missing ${expected}`); + } +} + +function assertSameList(actual, expected, label) { + if ( + !Array.isArray(actual) || + actual.length !== expected.length || + actual.some((value, index) => value !== expected[index]) + ) { + throw new Error( + `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, + ); + } +} + +function assertSameSet(actual, expected, label) { + if ( + !Array.isArray(actual) || + actual.length !== expected.length || + expected.some((value) => !actual.includes(value)) + ) { + throw new Error( + `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, + ); + } +} + +function assertPrivacyManifest(privacyManifests, label) { + if (!privacyManifests) { + throw new Error(`${label} missing iOS privacy manifest`); + } + + assertEqual( + privacyManifests.NSPrivacyTracking, + false, + `${label} privacy tracking flag`, + ); + assertSameList( + privacyManifests.NSPrivacyCollectedDataTypes ?? [], + [], + `${label} collected data types`, + ); + assertSameList( + privacyManifests.NSPrivacyTrackingDomains ?? [], + [], + `${label} tracking domains`, + ); + + const expectedAccessedApiTypes = new Map([ + [ + 'NSPrivacyAccessedAPICategoryFileTimestamp', + ['0A2A.1', '3B52.1', 'C617.1'], + ], + ['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']], + ['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']], + ['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']], + ]); + const accessedApiTypes = privacyManifests.NSPrivacyAccessedAPITypes ?? []; + if (accessedApiTypes.length !== expectedAccessedApiTypes.size) { + throw new Error(`${label} accessed API type count drifted`); + } + + for (const [apiType, reasons] of expectedAccessedApiTypes) { + const entry = accessedApiTypes.find( + (candidate) => candidate.NSPrivacyAccessedAPIType === apiType, + ); + if (!entry) { + throw new Error(`${label} missing ${apiType}`); + } + assertSameList( + entry.NSPrivacyAccessedAPITypeReasons ?? [], + reasons, + `${label} reasons for ${apiType}`, + ); + } +} + +function findPlugin(name) { + return expoConfig.plugins?.find((plugin) => + Array.isArray(plugin) ? plugin[0] === name : plugin === name, + ); +} + +function extractNumberConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Number(match[1]); +} + +function extractStringConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*'([^']+)';`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return match[1]; +} + +const sharedHostBridgeVersion = extractNumberConstExport( + sharedContractSource, + 'HOST_BRIDGE_VERSION', +); +const sharedPublicWebOrigin = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', +); +const sharedPublicWebOriginUrl = new URL(sharedPublicWebOrigin); +if (sharedPublicWebOriginUrl.protocol !== 'https:') { + throw new Error('shared HostBridge public web origin must use https for mobile app links'); +} +const sharedPublicWebHost = sharedPublicWebOriginUrl.hostname; +const sharedPublicWebAssociatedDomain = `applinks:${sharedPublicWebHost}`; + +assertEqual(expoConfig.name, 'Genarrative', 'name'); +assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug'); +assertEqual(expoConfig.scheme, 'genarrative', 'scheme'); +assertEqual(expoConfig.version, packageConfig.version, 'version'); +assertEqual(expoConfig.version, appConfig.version, 'app version'); +assertEqual(expoConfig.orientation, 'default', 'orientation'); +assertEqual(expoConfig.userInterfaceStyle, 'automatic', 'userInterfaceStyle'); +assertSameList(expoConfig.assetBundlePatterns, ['**/*'], 'asset bundle patterns'); +assertEqual(expoConfig.icon, './assets/icon.png', 'icon'); +assertEqual(expoConfig.splash?.image, './assets/icon.png', 'splash image'); +assertEqual(expoConfig.splash?.resizeMode, 'contain', 'splash resize mode'); +assertEqual(expoConfig.splash?.backgroundColor, '#fffdf9', 'splash background'); +assertEqual(expoConfig.updates?.enabled, false, 'OTA updates enabled flag'); +if (Object.keys(expoConfig.updates ?? {}).some((key) => key !== 'enabled')) { + throw new Error('Expo config OTA update metadata must stay empty'); +} +if ('runtimeVersion' in expoConfig) { + throw new Error('Expo config runtimeVersion must not be set without a real OTA release channel'); +} +if ('releaseChannel' in expoConfig || 'channel' in expoConfig) { + throw new Error('Expo config release channel must not be set without a real release process'); +} +assertEqual( + expoConfig.extra?.genarrativeHostBridgeVersion, + sharedHostBridgeVersion, + 'HostBridge version', +); + +assertEqual( + expoConfig.ios?.bundleIdentifier, + 'world.genarrative.mobile', + 'iOS bundle identifier', +); +assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number'); +assertSameList( + expoConfig.ios?.associatedDomains, + [sharedPublicWebAssociatedDomain], + 'iOS associated domains', +); +assertEqual( + expoConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption, + false, + 'iOS encryption export flag', +); +assertEqual( + expoConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads, + false, + 'iOS ATS arbitrary loads', +); +assertEqual( + expoConfig.ios?.infoPlist?.NSMicrophoneUsageDescription, + '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', + 'iOS microphone permission text', +); +assertPrivacyManifest(appConfig.ios?.privacyManifests, 'Expo source config'); + +const configPluginsMajor = Number( + String(configPluginsPackageConfig.version).split('.')[0], +); +if (!Number.isFinite(configPluginsMajor) || configPluginsMajor < 50) { + throw new Error('Expo config plugins must support built-in iOS privacy manifests'); +} +for (const snippet of [ + 'function withPrivacyInfo(config)', + 'config.ios?.privacyManifests', + 'setPrivacyInfo(projectConfig, privacyManifests)', + 'PrivacyInfo.xcprivacy', + 'mergePrivacyInfo(existing, privacyManifests)', +]) { + if (!expoPrivacyInfoPluginSource.includes(snippet)) { + throw new Error(`Expo config plugins PrivacyInfo support missing ${snippet}`); + } +} + +assertEqual( + expoConfig.android?.package, + 'world.genarrative.mobile', + 'Android package', +); +assertEqual(expoConfig.android?.versionCode, 1, 'Android versionCode'); +assertEqual( + expoConfig.android?.usesCleartextTraffic, + false, + 'Android cleartext traffic', +); +assertEqual(expoConfig.android?.allowBackup, false, 'Android backup flag'); +assertEqual( + expoConfig.android?.softwareKeyboardLayoutMode, + 'resize', + 'Android software keyboard layout mode', +); +assertSameSet( + expoConfig.android?.permissions ?? [], + [ + 'android.permission.CAMERA', + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.RECORD_AUDIO', + ], + 'Android explicit permissions', +); +if (appConfig.android?.permissions?.includes('android.permission.CAMERA')) { + throw new Error('Expo source config must not hard-code Android CAMERA permission'); +} +assertIncludes( + expoConfig.android?.blockedPermissions, + 'android.permission.MANAGE_EXTERNAL_STORAGE', + 'Android external download blocked permissions', +); +assertIncludes( + expoConfig.android?.blockedPermissions, + 'android.permission.READ_EXTERNAL_STORAGE', + 'Android external download blocked permissions', +); +if (expoConfig.android?.blockedPermissions?.includes('android.permission.RECORD_AUDIO')) { + throw new Error('Expo config Android permissions must not block RECORD_AUDIO needed by same-origin H5 microphone gameplay'); +} +if (expoConfig.android?.blockedPermissions?.includes('android.permission.POST_NOTIFICATIONS')) { + throw new Error('Expo config Android permissions must not block POST_NOTIFICATIONS needed by local notification.showLocal delivery'); +} +assertIncludes( + expoConfig.android?.blockedPermissions, + 'android.permission.REQUEST_INSTALL_PACKAGES', + 'Android external install blocked permissions', +); +assertIncludes( + expoConfig.android?.blockedPermissions, + 'android.permission.WRITE_EXTERNAL_STORAGE', + 'Android external download blocked permissions', +); +for (const permission of [ + 'android.permission.RECEIVE_BOOT_COMPLETED', + 'android.permission.SCHEDULE_EXACT_ALARM', + 'android.permission.USE_EXACT_ALARM', +]) { + assertIncludes( + expoConfig.android?.blockedPermissions, + permission, + 'Android background notification blocked permissions', + ); +} +assertEqual( + expoConfig.android?.adaptiveIcon?.foregroundImage, + './assets/icon.png', + 'Android adaptive icon foreground', +); +assertEqual( + expoConfig.android?.adaptiveIcon?.backgroundColor, + '#fffdf9', + 'Android adaptive icon background', +); + +const appLinkFilters = expoConfig.android?.intentFilters ?? []; +if (appLinkFilters.length !== 1) { + throw new Error('Expo config Android app link filter must be the only intent filter'); +} + +const [appLinkFilter] = appLinkFilters; +assertEqual(appLinkFilter.action, 'VIEW', 'Android app link action'); +assertEqual(appLinkFilter.autoVerify, true, 'Android app link autoVerify'); +assertSameList( + appLinkFilter.category, + ['BROWSABLE', 'DEFAULT'], + 'Android app link categories', +); +if ( + !Array.isArray(appLinkFilter.data) || + appLinkFilter.data.length !== 1 || + appLinkFilter.data[0]?.scheme !== 'https' || + appLinkFilter.data[0]?.host !== sharedPublicWebHost || + Object.keys(appLinkFilter.data[0] ?? {}).some( + (key) => key !== 'scheme' && key !== 'host', + ) +) { + throw new Error( + `Expo config Android app link data must only bind ${sharedPublicWebOrigin}`, + ); +} + +const imagePickerPlugin = findPlugin('expo-image-picker'); +if (!Array.isArray(imagePickerPlugin)) { + throw new Error('Expo config image picker plugin is missing options'); +} +assertEqual( + imagePickerPlugin[1]?.photosPermission, + '允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。', + 'image picker photo permission text', +); +assertEqual( + imagePickerPlugin[1]?.cameraPermission, + '允许 Genarrative 使用相机拍摄创作素材和参考图。', + 'image picker camera permission text', +); +assertEqual( + imagePickerPlugin[1]?.microphonePermission, + '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', + 'image picker microphone permission', +); + +const cameraPlugin = findPlugin('expo-camera'); +if (!Array.isArray(cameraPlugin)) { + throw new Error('Expo config camera plugin is missing options'); +} +assertEqual( + cameraPlugin[1]?.cameraPermission, + '允许 Genarrative 使用相机扫描二维码。', + 'camera permission text', +); +assertEqual( + cameraPlugin[1]?.microphonePermission, + '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', + 'camera microphone permission', +); +assertEqual( + cameraPlugin[1]?.recordAudioAndroid, + true, + 'camera Android record audio flag', +); + +const notificationsPlugin = findPlugin('expo-notifications'); +if (!Array.isArray(notificationsPlugin)) { + throw new Error('Expo config notifications plugin is missing options'); +} +assertEqual( + notificationsPlugin[1]?.enableBackgroundRemoteNotifications, + false, + 'background remote notifications', +); + +if (findPlugin('expo-updates')) { + throw new Error('Expo config must not include expo-updates without a real release channel'); +} + +console.log('[mobile-shell:expo-config] OK'); diff --git a/apps/mobile-shell/scripts/check-expo-export.mjs b/apps/mobile-shell/scripts/check-expo-export.mjs new file mode 100644 index 000000000..a4907e8b6 --- /dev/null +++ b/apps/mobile-shell/scripts/check-expo-export.mjs @@ -0,0 +1,179 @@ +import {spawnSync} from 'node:child_process'; + +import fs from 'node:fs'; + +const shellRoot = new URL('../', import.meta.url); +const outputRoot = new URL('../.expo-export-smoke/', import.meta.url); +const hostBridgeContractUrl = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const platforms = ['android', 'ios']; +const blockedDevelopmentWebUrlPatterns = [ + /http:\\?\/\\?\/localhost(?::\d+)?/u, + /http:\\?\/\\?\/127\.0\.0\.1(?::\d+)?/u, + /http:\\?\/\\?\/\[::1\](?::\d+)?/u, +]; + +function readHostBridgePublicWebUrl() { + const contractSource = fs.readFileSync(hostBridgeContractUrl, 'utf8'); + const publicWebUrlMatch = contractSource.match( + /HOST_BRIDGE_PUBLIC_WEB_URL\s*=\s*'([^']+)'/u, + ); + const publicWebOriginMatch = contractSource.match( + /HOST_BRIDGE_PUBLIC_WEB_ORIGIN\s*=\s*'([^']+)'/u, + ); + + if (!publicWebUrlMatch || !publicWebOriginMatch) { + throw new Error('HostBridge public web URL contract is missing'); + } + + const publicWebUrl = publicWebUrlMatch[1]; + const publicWebOrigin = publicWebOriginMatch[1]; + if (new URL(publicWebUrl).origin !== publicWebOrigin) { + throw new Error( + 'HostBridge public web URL and origin contract must point to the same origin', + ); + } + + return publicWebUrl; +} + +const expectedPublicWebUrl = readHostBridgePublicWebUrl(); +const requiredNativeHostContextTokens = [ + 'native_app', + 'expo_mobile', + 'hostCapabilities', + 'hostVersion', + 'bridgeVersion', +]; + +function runExpoExport(platform) { + const outputDir = `.expo-export-smoke/${platform}`; + const result = spawnSync( + npmCommand, + [ + 'exec', + 'expo', + 'export', + '--', + '--platform', + platform, + '--output-dir', + outputDir, + ], + { + cwd: shellRoot, + encoding: 'utf8', + stdio: 'pipe', + }, + ); + + if (result.error) { + throw new Error( + `failed to start Expo ${platform} export smoke: ${result.error.message}`, + ); + } + + if (result.signal) { + throw new Error( + `Expo ${platform} export smoke was terminated by signal ${result.signal}`, + ); + } + + if ((result.status ?? 0) !== 0) { + process.stdout.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + process.exit(result.status ?? 1); + } +} + +function readMetadata(platform) { + const metadataPath = new URL(`${platform}/metadata.json`, outputRoot); + if (!fs.existsSync(metadataPath)) { + throw new Error(`Expo ${platform} export did not produce metadata.json`); + } + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (metadata.version !== 0) { + throw new Error(`Expo ${platform} export metadata version drifted`); + } + + if (metadata.bundler !== 'metro') { + throw new Error(`Expo ${platform} export must use the Metro bundler`); + } + + const fileMetadata = metadata.fileMetadata ?? {}; + const metadataPlatforms = Object.keys(fileMetadata); + if (metadataPlatforms.length !== 1 || metadataPlatforms[0] !== platform) { + throw new Error(`Expo ${platform} export metadata must only include its platform`); + } + + const platformMetadata = fileMetadata[platform]; + if (!Array.isArray(platformMetadata?.assets)) { + throw new Error(`Expo ${platform} export metadata must include an assets array`); + } + + const bundlePath = platformMetadata?.bundle; + if (typeof bundlePath !== 'string' || bundlePath.length === 0) { + throw new Error(`Expo ${platform} export metadata is missing bundle path`); + } + + return bundlePath; +} + +function assertBundle(platform, bundlePath) { + const bundleFile = new URL(`${platform}/${bundlePath}`, outputRoot); + if (!fs.existsSync(bundleFile)) { + throw new Error(`Expo ${platform} bundle is missing: ${bundlePath}`); + } + + const stats = fs.statSync(bundleFile); + if (stats.size < 100_000) { + throw new Error(`Expo ${platform} bundle is unexpectedly small`); + } + + if ( + !bundlePath.startsWith(`_expo/static/js/${platform}/AppEntry-`) || + !bundlePath.endsWith('.hbc') + ) { + throw new Error(`Expo ${platform} bundle path does not target AppEntry`); + } + + const bundleSource = fs.readFileSync(bundleFile, 'utf8'); + if (!bundleSource.includes(expectedPublicWebUrl)) { + throw new Error( + `Expo ${platform} production bundle must include the shared public web URL`, + ); + } + + for (const token of requiredNativeHostContextTokens) { + if (!bundleSource.includes(token)) { + throw new Error( + `Expo ${platform} production bundle must include native host context token ${token}`, + ); + } + } + + for (const blockedPattern of blockedDevelopmentWebUrlPatterns) { + if (blockedPattern.test(bundleSource)) { + throw new Error( + `Expo ${platform} production bundle must not include a local development H5 URL`, + ); + } + } +} + +fs.rmSync(outputRoot, {recursive: true, force: true}); + +try { + for (const platform of platforms) { + runExpoExport(platform); + assertBundle(platform, readMetadata(platform)); + } + + console.log('[mobile-shell:expo-export] OK'); +} finally { + fs.rmSync(outputRoot, {recursive: true, force: true}); +} diff --git a/apps/mobile-shell/src/env.d.ts b/apps/mobile-shell/src/env.d.ts new file mode 100644 index 000000000..d74e8e3c4 --- /dev/null +++ b/apps/mobile-shell/src/env.d.ts @@ -0,0 +1,5 @@ +declare const process: { + env: { + EXPO_PUBLIC_GENARRATIVE_WEB_URL?: string; + }; +}; diff --git a/apps/mobile-shell/src/host-bridge/appearance.test.ts b/apps/mobile-shell/src/host-bridge/appearance.test.ts new file mode 100644 index 000000000..dcfa2bb38 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/appearance.test.ts @@ -0,0 +1,73 @@ +import { Appearance } from 'react-native'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + getMobileAppearanceColorScheme, + getMobileHostBridgeAppearanceColorScheme, +} from './appearance'; + +vi.mock('react-native', () => ({ + Appearance: { + getColorScheme: vi.fn(), + }, +})); + +function request(): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'appearance-request', + method: 'appearance.getColorScheme', + }; +} + +beforeEach(() => { + vi.mocked(Appearance.getColorScheme).mockReset(); +}); + +describe('mobile appearance helpers', () => { + test('reads the current light or dark system color scheme', () => { + vi.mocked(Appearance.getColorScheme).mockReturnValue('light'); + expect(getMobileAppearanceColorScheme()).toEqual({ + colorScheme: 'light', + }); + + vi.mocked(Appearance.getColorScheme).mockReturnValue('dark'); + expect(getMobileAppearanceColorScheme()).toEqual({ + colorScheme: 'dark', + }); + }); + + test('normalizes missing or unknown native color schemes to unknown', () => { + vi.mocked(Appearance.getColorScheme).mockReturnValue(null); + expect(getMobileAppearanceColorScheme()).toEqual({ + colorScheme: 'unknown', + }); + + vi.mocked(Appearance.getColorScheme).mockReturnValue( + 'unspecified' as ReturnType, + ); + expect(getMobileAppearanceColorScheme()).toEqual({ + colorScheme: 'unknown', + }); + }); + + test('wraps the system color scheme in the HostBridge response shape', () => { + vi.mocked(Appearance.getColorScheme).mockReturnValue('dark'); + + expect(getMobileHostBridgeAppearanceColorScheme(request())).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'appearance-request', + ok: true, + result: { + colorScheme: 'dark', + }, + }); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/appearance.ts b/apps/mobile-shell/src/host-bridge/appearance.ts new file mode 100644 index 000000000..0bf9ee8d9 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/appearance.ts @@ -0,0 +1,19 @@ +import { Appearance } from 'react-native'; + +import { + type HostBridgeRequest, + normalizeHostBridgeColorScheme, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { ok } from './protocol'; + +export function getMobileAppearanceColorScheme() { + return { + colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()), + }; +} + +export function getMobileHostBridgeAppearanceColorScheme( + request: HostBridgeRequest, +) { + return ok(request, getMobileAppearanceColorScheme()); +} diff --git a/apps/mobile-shell/src/host-bridge/badge.test.ts b/apps/mobile-shell/src/host-bridge/badge.test.ts new file mode 100644 index 000000000..e76bd2a76 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/badge.test.ts @@ -0,0 +1,305 @@ +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_BADGE_COUNT_MAX, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { setMobileAppBadgeCount } from './badge'; + +type NotificationPermissionStatus = + Awaited>; + +const BADGE_GRANTED_PERMISSION = { + status: 'granted', + granted: true, + canAskAgain: true, + expires: 'never', + ios: { + status: 2, + allowsBadge: true, + }, +} as unknown as NotificationPermissionStatus; + +const BADGE_DENIED_PERMISSION = { + status: 'denied', + granted: false, + canAskAgain: false, + expires: 'never', + ios: { + status: 1, + allowsBadge: false, + }, +} as unknown as NotificationPermissionStatus; + +vi.mock('expo-notifications', () => ({ + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + setBadgeCountAsync: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})); + +function request(payload?: unknown): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'badge-request', + method: 'app.setBadgeCount', + payload, + }; +} + +function setPlatformOS(os: 'ios' | 'android') { + (Platform as { OS: 'ios' | 'android' }).OS = os; +} + +beforeEach(() => { + setPlatformOS('ios'); + vi.mocked(Notifications.getPermissionsAsync).mockReset(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_GRANTED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockReset(); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + BADGE_GRANTED_PERMISSION, + ); + vi.mocked(Notifications.setBadgeCountAsync).mockReset(); + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true); +}); + +describe('mobile app badge helper', () => { + test('sets and clears the iOS app badge count through Expo Notifications', async () => { + expect( + await setMobileAppBadgeCount( + request({ + count: 12, + }), + ), + ).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'badge-request', + ok: true, + result: true, + }); + expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(12); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + + expect( + await setMobileAppBadgeCount( + request({ + count: 0, + }), + ), + ).toMatchObject({ + ok: true, + result: true, + }); + expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(0); + }); + + test('requests iOS badge permission before updating when it is missing', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + BADGE_GRANTED_PERMISSION, + ); + + await expect( + setMobileAppBadgeCount( + request({ + count: 3, + }), + ), + ).resolves.toMatchObject({ + ok: true, + result: true, + }); + + expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ + ios: { + allowAlert: false, + allowBadge: true, + allowSound: false, + }, + }); + expect(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(3); + }); + + test('rejects denied iOS badge permission before touching the system badge', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge permission denied', + }); + + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + }); + + test('maps current badge permission lookup failures to a stable HostBridge error', async () => { + const error = new Error('native badge permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge permission unavailable', + }); + + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for permission.current', + ); + warnSpy.mockRestore(); + }); + + test('maps badge permission request failures to a stable HostBridge error', async () => { + const error = new Error('native badge permission request failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(error); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge permission unavailable', + }); + + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for permission.request', + ); + warnSpy.mockRestore(); + }); + + test('maps native badge update false results to a stable HostBridge error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge update unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.rejected', + ); + warnSpy.mockRestore(); + }); + + test('maps native badge update rejections to a stable HostBridge error', async () => { + const error = new Error('native badge failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(error); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge update unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.set_count', + ); + warnSpy.mockRestore(); + }); + + test('rejects invalid badge counts before touching the system badge', async () => { + for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) { + await expect( + setMobileAppBadgeCount( + request({ + count, + }), + ), + ).rejects.toThrowError( + `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + ); + } + + expect( + Notifications.setBadgeCountAsync, + ).not.toHaveBeenCalled(); + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('rejects missing badge payload before touching the system badge', async () => { + await expect(setMobileAppBadgeCount(request({}))).rejects.toThrowError( + `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + ); + + expect( + Notifications.setBadgeCountAsync, + ).not.toHaveBeenCalled(); + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('returns unsupported on Android before validating payload or touching badge APIs', async () => { + setPlatformOS('android'); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toThrowError('app badge count is only supported on iOS mobile shell'); + + await expect( + setMobileAppBadgeCount( + request({ + count: HOST_BRIDGE_BADGE_COUNT_MAX + 1, + }), + ), + ).rejects.toThrowError('app badge count is only supported on iOS mobile shell'); + + expect( + Notifications.setBadgeCountAsync, + ).not.toHaveBeenCalled(); + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/badge.ts b/apps/mobile-shell/src/host-bridge/badge.ts new file mode 100644 index 000000000..a535bbe2d --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/badge.ts @@ -0,0 +1,103 @@ +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +import { + HOST_BRIDGE_BADGE_COUNT_MAX, + type HostBridgeError, + type HostBridgeRequest, + normalizeHostBridgeBadgeCount, + type SetBadgeCountPayload, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest, ok } from './protocol'; + +type NotificationPermissionStatus = Awaited< + ReturnType +>; + +function hasBadgePermission(permission: NotificationPermissionStatus) { + return permission.ios?.allowsBadge === true; +} + +function logMobileBadgeFailure(label: string, _error: unknown) { + console.warn(`mobile app badge failed for ${label}`); +} + +async function ensureMobileBadgePermission() { + let currentPermission: NotificationPermissionStatus; + try { + currentPermission = await Notifications.getPermissionsAsync(); + } catch (error) { + logMobileBadgeFailure('permission.current', error); + throw { + code: 'host_error', + message: 'app badge permission unavailable', + } satisfies HostBridgeError; + } + if (hasBadgePermission(currentPermission)) { + return; + } + + let requestedPermission: NotificationPermissionStatus; + try { + requestedPermission = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: false, + allowBadge: true, + allowSound: false, + }, + }); + } catch (error) { + logMobileBadgeFailure('permission.request', error); + throw { + code: 'host_error', + message: 'app badge permission unavailable', + } satisfies HostBridgeError; + } + if (!hasBadgePermission(requestedPermission)) { + throw { + code: 'host_error', + message: 'app badge permission denied', + } satisfies HostBridgeError; + } +} + +export async function setMobileAppBadgeCount(request: HostBridgeRequest) { + if (Platform.OS !== 'ios') { + throw { + code: 'unsupported_capability', + message: 'app badge count is only supported on iOS mobile shell', + } satisfies HostBridgeError; + } + + const count = normalizeHostBridgeBadgeCount( + (request.payload as SetBadgeCountPayload | undefined)?.count, + ); + if (count === null) { + throw invalidRequest( + `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + ); + } + + await ensureMobileBadgePermission(); + let updated = false; + try { + updated = await Notifications.setBadgeCountAsync(count); + } catch (error) { + logMobileBadgeFailure('update.set_count', error); + throw { + code: 'host_error', + message: 'app badge update unavailable', + } satisfies HostBridgeError; + } + if (!updated) { + logMobileBadgeFailure( + 'update.rejected', + new Error('setBadgeCountAsync returned false'), + ); + throw { + code: 'host_error', + message: 'app badge update unavailable', + } satisfies HostBridgeError; + } + return ok(request, true); +} diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts new file mode 100644 index 000000000..53726666f --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -0,0 +1,2111 @@ +import * as Clipboard from 'expo-clipboard'; +import * as DocumentPicker from 'expo-document-picker'; +import * as Haptics from 'expo-haptics'; +import * as ImagePicker from 'expo-image-picker'; +import * as Linking from 'expo-linking'; +import * as Network from 'expo-network'; +import * as Notifications from 'expo-notifications'; +import * as Sharing from 'expo-sharing'; +import { + Appearance, + Platform, + Share, +} from 'react-native'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_BADGE_COUNT_MAX, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_METHODS, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_RESPONSE_CACHE_MAX, + HOST_BRIDGE_VERSION, + type HostBridgeMethod, + type HostBridgeRequest, + type HostBridgeResponse, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import type { MobileShellUrlOptions } from '../shell/url'; +import { + configureMobileHostBridgeNavigation, + handleMobileHostBridgeMessage, + resetMobileHostBridgeForTest, +} from './bridge'; +import { + cancelQrCodeScan, + completeQrCodeScan, + failQrCodeScan, +} from './scanner'; + +type NotificationPermissionStatus = + Awaited>; + +const GRANTED_NOTIFICATION_PERMISSION = { + status: 'granted', + granted: true, + canAskAgain: true, + expires: 'never', + ios: { + status: 2, + allowsBadge: true, + }, +} as NotificationPermissionStatus; + +const DENIED_NOTIFICATION_PERMISSION = { + status: 'denied', + granted: false, + canAskAgain: false, + expires: 'never', + ios: { + status: 1, + allowsBadge: false, + }, +} as NotificationPermissionStatus; + +let requestSequence = 0; + +const TEST_MOBILE_URL_OPTIONS: MobileShellUrlOptions = { + platform: 'ios', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime', 'share.open'], +}; +const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter( + (method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method), +); + +function encodeBytes(bytes: readonly number[]) { + return Buffer.from(bytes).toString('base64'); +} + +const PNG_BASE64 = encodeBytes([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, +]); +const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]); +const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString( + 'base64', +); +const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString( + 'base64', +); +const MP4_AUDIO_BASE64 = encodeBytes([ + 0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20, +]); +const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]); +const DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64'); + +vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +const fileTexts = vi.hoisted(() => new Map()); +const fileBase64Data = vi.hoisted(() => new Map()); +const fileSizes = vi.hoisted(() => new Map()); +const fileTextReads = vi.hoisted(() => [] as string[]); +const fileBase64Reads = vi.hoisted(() => [] as string[]); + +const writtenFiles = vi.hoisted( + () => + [] as { + uri: string; + content: string; + options?: { encoding?: 'utf8' | 'base64' }; + }[], +); + +vi.mock('expo-file-system', () => ({ + Paths: { + cache: 'file:///cache/', + }, + File: class MockFile { + uri: string; + + constructor(base: string, fileName?: string) { + this.uri = + typeof fileName === 'string' ? `file:///cache/${fileName}` : base; + } + + write(content: string, options?: { encoding?: 'utf8' | 'base64' }) { + writtenFiles.push({ + uri: this.uri, + content, + options, + }); + } + + get size() { + return fileSizes.get(this.uri) ?? null; + } + + text() { + fileTextReads.push(this.uri); + return Promise.resolve(fileTexts.get(this.uri) ?? ''); + } + + base64() { + fileBase64Reads.push(this.uri); + return Promise.resolve(fileBase64Data.get(this.uri) ?? ''); + } + }, +})); + +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: vi.fn(), +})); + +vi.mock('expo-haptics', () => ({ + ImpactFeedbackStyle: { + Heavy: 'heavy', + Light: 'light', + Medium: 'medium', + }, + impactAsync: vi.fn(), +})); + +vi.mock('expo-image-picker', () => ({ + PermissionStatus: { + DENIED: 'denied', + GRANTED: 'granted', + }, + launchCameraAsync: vi.fn(), + launchImageLibraryAsync: vi.fn(), + requestCameraPermissionsAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), +})); + +vi.mock('expo-linking', () => ({ + canOpenURL: vi.fn(), + openURL: vi.fn(), +})); + +vi.mock('expo-network', () => ({ + getNetworkStateAsync: vi.fn(async () => ({ + type: 'WIFI', + isConnected: true, + isInternetReachable: true, + })), + NetworkStateType: { + CELLULAR: 'CELLULAR', + ETHERNET: 'ETHERNET', + NONE: 'NONE', + WIFI: 'WIFI', + }, +})); + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { + DEFAULT: 'default', + }, + IosAuthorizationStatus: { + PROVISIONAL: 'provisional', + }, + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + setBadgeCountAsync: vi.fn(), + setNotificationChannelAsync: vi.fn(), + setNotificationHandler: vi.fn(), +})); + +vi.mock('expo-sharing', () => ({ + isAvailableAsync: vi.fn(async () => true), + shareAsync: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Appearance: { + getColorScheme: vi.fn(() => 'light'), + }, + Platform: { + OS: 'ios', + }, + Share: { + share: vi.fn(), + }, +})); + +function request( + method: HostBridgeMethod, + payload?: unknown, +): HostBridgeRequest { + requestSequence += 1; + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: `request-${requestSequence}`, + method, + payload, + }; +} + +async function send(requestValue: HostBridgeRequest) { + const responses: HostBridgeResponse[] = []; + + await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) => + responses.push(response), + ); + + const response = responses[0]; + if (!response) { + throw new Error('host bridge response missing'); + } + + return response; +} + +async function sendRaw(requestValue: unknown) { + const responses: HostBridgeResponse[] = []; + + await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) => + responses.push(response), + ); + + const response = responses[0]; + if (!response) { + throw new Error('host bridge response missing'); + } + + return response; +} + +function expectOk(response: HostBridgeResponse) { + if (!response.ok) { + throw new Error('expected ok host bridge response'); + } + + return response; +} + +function expectFailed(response: HostBridgeResponse) { + if (response.ok) { + throw new Error('expected failed host bridge response'); + } + + return response; +} + +function setPlatformOS(os: 'ios' | 'android') { + (Platform as { OS: 'ios' | 'android' }).OS = os; +} + +afterEach(() => { + requestSequence = 0; + vi.mocked(Appearance.getColorScheme).mockReset(); + vi.mocked(Appearance.getColorScheme).mockReturnValue('light'); + vi.mocked(Haptics.impactAsync).mockReset(); + vi.mocked(Clipboard.getStringAsync).mockReset(); + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); + vi.mocked(Clipboard.setStringAsync).mockReset(); + vi.mocked(DocumentPicker.getDocumentAsync).mockReset(); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + vi.mocked(ImagePicker.launchImageLibraryAsync).mockReset(); + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + vi.mocked(ImagePicker.launchCameraAsync).mockReset(); + vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockReset(); + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockReset(); + vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + vi.mocked(Linking.openURL).mockReset(); + vi.mocked(Linking.canOpenURL).mockReset(); + vi.mocked(Linking.canOpenURL).mockResolvedValue(true); + vi.mocked(Network.getNetworkStateAsync).mockReset(); + vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ + type: Network.NetworkStateType.WIFI, + isConnected: true, + isInternetReachable: true, + }); + vi.mocked(Notifications.getPermissionsAsync).mockReset(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockReset(); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.scheduleNotificationAsync).mockReset(); + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue( + 'notification-1', + ); + vi.mocked(Notifications.setNotificationChannelAsync).mockReset(); + vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null); + vi.mocked(Notifications.setBadgeCountAsync).mockReset(); + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true); + setPlatformOS('ios'); + vi.mocked(Sharing.isAvailableAsync).mockReset(); + vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true); + vi.mocked(Sharing.shareAsync).mockReset(); + vi.mocked(Share.share).mockReset(); + fileTexts.clear(); + fileBase64Data.clear(); + fileSizes.clear(); + fileTextReads.length = 0; + fileBase64Reads.length = 0; + writtenFiles.length = 0; + resetMobileHostBridgeForTest(); +}); + +describe('handleMobileHostBridgeMessage', () => { + test('runtime 能力清单声明移动壳支持受控 WebView 导航', async () => { + const response = await send(request('host.getRuntime')); + + const okResponse = expectOk(response); + + expect(okResponse.result).toMatchObject({ + shell: 'expo_mobile', + platform: 'ios', + }); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('navigation.openNativePage'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.exportText'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importText'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importDocument'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importImage'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.captureImage'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('scanner.scanQrCode'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importAudio'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toEqual( + expect.arrayContaining([ + 'appearance.getColorScheme', + 'host.events', + 'app.lifecycle', + 'app.reloadWebView', + 'network.status', + 'network.statusChanged', + 'navigation.canGoBack', + 'app.setBadgeCount', + 'clipboard.readText', + 'notification.showLocal', + ]), + ); + }); + + test('Android runtime 不声明 iOS 角标能力', async () => { + setPlatformOS('android'); + + const response = await send(request('host.getRuntime')); + + const okResponse = expectOk(response); + expect(okResponse.result).toMatchObject({ + shell: 'expo_mobile', + platform: 'android', + }); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).not.toContain('app.setBadgeCount'); + }); + + test('appearance.getColorScheme 返回系统配色模式', async () => { + vi.mocked(Appearance.getColorScheme).mockReturnValue('dark'); + + const response = await send(request('appearance.getColorScheme')); + + expect(expectOk(response).result).toEqual({ + colorScheme: 'dark', + }); + }); + + test('appearance.getColorScheme 归一化未知系统配色', async () => { + vi.mocked(Appearance.getColorScheme).mockReturnValue('unspecified'); + + const response = await send(request('appearance.getColorScheme')); + + expect(expectOk(response).result).toEqual({ + colorScheme: 'unknown', + }); + }); + + test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => { + const openWebViewUrl = vi.fn(); + configureMobileHostBridgeNavigation({ + allowedOrigin: 'https://www.genarrative.world', + urlOptions: TEST_MOBILE_URL_OPTIONS, + openWebViewUrl, + reloadWebView: vi.fn(), + }); + + const response = await send( + request('navigation.openNativePage', { + url: '/works/detail?work=PZ-1', + }), + ); + + expect(expectOk(response).result).toBe(true); + const openedUrl = new URL(openWebViewUrl.mock.calls[0]?.[0] as string); + expect(openedUrl.origin).toBe('https://www.genarrative.world'); + expect(openedUrl.pathname).toBe('/works/detail'); + expect(openedUrl.searchParams.get('work')).toBe('PZ-1'); + expect(openedUrl.searchParams.get('clientRuntime')).toBe('native_app'); + expect(openedUrl.searchParams.get('hostShell')).toBe('expo_mobile'); + expect(openedUrl.searchParams.get('hostPlatform')).toBe('ios'); + expect(openedUrl.searchParams.get('hostVersion')).toBe('0.1.0'); + expect(openedUrl.searchParams.get('bridgeVersion')).toBe('1'); + expect(openedUrl.searchParams.get('hostCapabilities')).toBe( + 'host.getRuntime,share.open', + ); + }); + + test('navigation.openNativePage 拒绝外域目标', async () => { + configureMobileHostBridgeNavigation({ + allowedOrigin: 'https://www.genarrative.world', + urlOptions: TEST_MOBILE_URL_OPTIONS, + openWebViewUrl: vi.fn(), + reloadWebView: vi.fn(), + }); + + const response = await send( + request('navigation.openNativePage', { + url: 'https://example.com/works/detail?work=PZ-1', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + }); + + test('未配置 WebView 导航器时明确返回 unsupported', async () => { + const response = await send( + request('navigation.openNativePage', { + url: '/works/detail?work=PZ-1', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_method'); + }); + + test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)( + '%s 未被移动壳声明时明确返回 unsupported', + async (method) => { + const response = await send(request(method)); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_method'); + expect(failedResponse.error.message).toContain(method); + }, + ); + + test('拒绝非法 request id 和未知 method', async () => { + const invalidId = await sendRaw({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request\n1', + method: 'share.open', + }); + const unknownMethod = await sendRaw({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request-unknown', + method: 'host.runArbitraryCommand', + }); + + expect(expectFailed(invalidId).error).toEqual({ + code: 'invalid_request', + message: 'invalid host bridge request', + }); + expect(expectFailed(unknownMethod).error).toEqual({ + code: 'invalid_request', + message: 'invalid host bridge request', + }); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('app.reloadWebView 刷新移动壳当前 WebView', async () => { + const reloadWebView = vi.fn(); + configureMobileHostBridgeNavigation({ + allowedOrigin: 'https://www.genarrative.world', + urlOptions: TEST_MOBILE_URL_OPTIONS, + openWebViewUrl: vi.fn(), + reloadWebView, + }); + + const response = await send(request('app.reloadWebView')); + + expect(expectOk(response).result).toBe(true); + expect(reloadWebView).toHaveBeenCalledTimes(1); + }); + + test('app.openExternalUrl 只打开允许的外链协议', async () => { + const response = await send( + request('app.openExternalUrl', { + url: ' https://example.com/path ', + }), + ); + + expect(expectOk(response).result).toBe(true); + expect(Linking.canOpenURL).toHaveBeenCalledWith('https://example.com/path'); + expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path'); + }); + + test('app.openExternalUrl 在系统不可打开时返回 host_error', async () => { + vi.mocked(Linking.canOpenURL).mockResolvedValue(false); + + const response = await send( + request('app.openExternalUrl', { + url: 'tel:+12345678', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('host_error'); + expect(failedResponse.error.message).toBe('external URL cannot be opened'); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + test('原生异常对象不会透传非协议错误码', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = { + code: 'native_badge_failure', + message: 'native badge failed', + nativeStackIOS: ['private native frame'], + }; + try { + vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({ + ...nativeError, + }); + + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error).toEqual({ + code: 'host_error', + message: 'app badge update unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.set_count', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + test('app.openExternalUrl 原生异常返回稳定 host_error', async () => { + vi.mocked(Linking.canOpenURL).mockRejectedValueOnce({ + code: 'native_linking_failure', + message: 'native linking failed', + nativeStackIOS: ['private native frame'], + }); + + const response = await send( + request('app.openExternalUrl', { + url: 'https://example.com/path', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error).toEqual({ + code: 'host_error', + message: 'external URL cannot be opened', + }); + }); + + test('app.openExternalUrl 拒绝危险协议', async () => { + const response = await send( + request('app.openExternalUrl', { + url: 'javascript:alert(1)', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + expect(Linking.canOpenURL).not.toHaveBeenCalled(); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + test('app.openExternalUrl 拒绝控制字符', async () => { + const response = await send( + request('app.openExternalUrl', { + url: 'https://example.com/\nnext', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + expect(Linking.canOpenURL).not.toHaveBeenCalled(); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + test('重复 HostBridge request id 回放首次结果且不重复触发系统动作', async () => { + const duplicateRequest = request('share.open', { + title: '测试作品', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }); + + const [firstResponse, secondResponse] = await Promise.all([ + send(duplicateRequest), + send(duplicateRequest), + ]); + const replayedResponse = await send(duplicateRequest); + + expectOk(firstResponse); + expect(secondResponse).toEqual(firstResponse); + expect(replayedResponse).toEqual(firstResponse); + expect(Share.share).toHaveBeenCalledTimes(1); + }); + + test('HostBridge request id 回放缓存超过共享上限后淘汰最早结果', async () => { + const firstRequest = request('clipboard.writeText', { + text: 'first', + }); + + await send(firstRequest); + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1); + + for (let index = 0; index < HOST_BRIDGE_RESPONSE_CACHE_MAX; index += 1) { + await send( + request('clipboard.writeText', { + text: `cache-${index}`, + }), + ); + } + + await send(firstRequest); + + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes( + HOST_BRIDGE_RESPONSE_CACHE_MAX + 2, + ); + }); + + test('network.status 返回 Expo Network 真实状态', async () => { + vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ + type: Network.NetworkStateType.CELLULAR, + isConnected: true, + isInternetReachable: false, + }); + + const response = await send(request('network.status')); + + expect(expectOk(response).result).toEqual({ + isConnected: true, + isInternetReachable: false, + connectionType: 'cellular', + nativeType: 'CELLULAR', + }); + }); + + test('clipboard.readText 读取 Expo 系统剪贴板文本', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); + + const response = await send(request('clipboard.readText')); + + expect(expectOk(response).result).toEqual({ + text: '作品号 PZ-1', + }); + expect(Clipboard.getStringAsync).toHaveBeenCalled(); + }); + + test('clipboard.writeText 写入前按共享上限截断文本', async () => { + const response = await send( + request('clipboard.writeText', { + text: 'a'.repeat(100010), + }), + ); + + expect(expectOk(response).result).toBe(true); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('a'.repeat(100000)); + + vi.mocked(Clipboard.setStringAsync).mockClear(); + + const unicodeResponse = await send( + request('clipboard.writeText', { + text: '猫'.repeat(100010), + }), + ); + + expect(expectOk(unicodeResponse).result).toBe(true); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('猫'.repeat(100000)); + }); + + test('clipboard.readText 读取失败时返回 host_error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('clipboard unavailable'); + vi.mocked(Clipboard.getStringAsync).mockRejectedValue( + nativeError, + ); + + try { + const response = await send(request('clipboard.readText')); + + const failedResponse = expectFailed(response); + expect(failedResponse.error.code).toBe('host_error'); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile clipboard failed for read.get_string', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + test('haptics.impact 调起 Expo 触觉反馈', async () => { + const response = await send( + request('haptics.impact', { + style: 'heavy', + }), + ); + + expect(expectOk(response).result).toBe(true); + expect(Haptics.impactAsync).toHaveBeenCalledWith( + Haptics.ImpactFeedbackStyle.Heavy, + ); + }); + + test('haptics.impact 缺省为轻触并拒绝未知强度', async () => { + const defaultResponse = await send(request('haptics.impact')); + + expectOk(defaultResponse); + expect(Haptics.impactAsync).toHaveBeenCalledWith( + Haptics.ImpactFeedbackStyle.Light, + ); + + vi.mocked(Haptics.impactAsync).mockClear(); + + const invalidResponse = await send( + request('haptics.impact', { + style: 'rigid', + }), + ); + + expect(expectFailed(invalidResponse).error.code).toBe('invalid_request'); + expect(Haptics.impactAsync).not.toHaveBeenCalled(); + }); + + test('notification.showLocal 调起 Expo 本地通知', async () => { + const response = await send( + request('notification.showLocal', { + title: ' 生成完成 ', + body: ' 作品已准备好 可以试玩 ', + }), + ); + + expect(expectOk(response).result).toEqual({ + action: 'delivered_to_system', + }); + expect(Notifications.getPermissionsAsync).toHaveBeenCalled(); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ + content: { + title: '生成完成', + body: '作品已准备好 可以试玩', + }, + trigger: null, + }); + }); + + test('notification.showLocal 在 Android 使用固定通知 channel', async () => { + setPlatformOS('android'); + + const response = await send( + request('notification.showLocal', { + title: '生成完成', + }), + ); + + expectOk(response); + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + { + name: 'Genarrative', + importance: Notifications.AndroidImportance.DEFAULT, + }, + ); + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ + content: { + title: '生成完成', + }, + trigger: { + channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + }, + }); + }); + + test('notification.showLocal 拒绝权限和非法 payload', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + + const denied = await send( + request('notification.showLocal', { + title: '生成完成', + }), + ); + + expect(expectFailed(denied).error.code).toBe('host_error'); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + + const invalid = await send( + request('notification.showLocal', { + title: '生成\n完成', + }), + ); + + expect(expectFailed(invalid).error.code).toBe('invalid_request'); + }); + + test('app.setBadgeCount 在 iOS 调起系统角标能力', async () => { + const response = await send( + request('app.setBadgeCount', { + count: 12, + }), + ); + + expectOk(response); + expect( + Notifications.setBadgeCountAsync, + ).toHaveBeenCalledWith(12); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('app.setBadgeCount 在 iOS 角标权限缺失时请求 badge 权限', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + + const response = await send( + request('app.setBadgeCount', { + count: 2, + }), + ); + + expectOk(response); + expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ + ios: { + allowAlert: false, + allowBadge: true, + allowSound: false, + }, + }); + expect( + Notifications.setBadgeCountAsync, + ).toHaveBeenCalledWith(2); + }); + + test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); + + expect(expectFailed(response).error).toEqual({ + code: 'host_error', + message: 'app badge permission denied', + }); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + }); + + test('app.setBadgeCount 在 iOS 系统拒绝设置时不返回成功', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); + + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); + + expect(expectFailed(response).error).toEqual({ + code: 'host_error', + message: 'app badge update unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.rejected', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => { + const invalid = await send( + request('app.setBadgeCount', { + count: HOST_BRIDGE_BADGE_COUNT_MAX + 1, + }), + ); + + const invalidError = expectFailed(invalid).error; + expect(invalidError.code).toBe('invalid_request'); + expect(invalidError.message).toContain(String(HOST_BRIDGE_BADGE_COUNT_MAX)); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + + setPlatformOS('android'); + const unsupported = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); + + expect(expectFailed(unsupported).error.code).toBe('unsupported_capability'); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + }); + + test('share.open 使用直接分享 payload 调起系统分享', async () => { + const response = await send( + request('share.open', { + title: '测试作品', + message: '来玩这个作品', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }), + ); + + expectOk(response); + expect(Share.share).toHaveBeenCalledWith({ + title: '测试作品', + message: + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }); + }); + + test('share.open 使用缓存作品目标生成作品详情链接', async () => { + expectOk( + await send( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: '暖灯猫街', + message: '来玩这个作品', + work: 'PZ-00000001', + }, + }, + }), + ), + ); + + const response = await send(request('share.open')); + + expectOk(response); + expect(Share.share).toHaveBeenCalledWith({ + title: '暖灯猫街', + message: + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + }); + }); + + test('share.setTarget 拒绝缺少或无效目标且不清空已有目标', async () => { + expectOk( + await send( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: '暖灯猫街', + work: 'PZ-00000001', + }, + }, + }), + ), + ); + + const invalid = await send(request('share.setTarget', {})); + + expect(expectFailed(invalid).error.message).toBe('target is required'); + + const empty = await send(request('share.setTarget', { target: {} })); + + expect(expectFailed(empty).error.message).toBe('share target is required'); + + const unsafe = await send( + request('share.setTarget', { + target: { + title: '危险作品', + url: 'https://example.com/works/detail?work=PZ-1', + }, + }), + ); + + expect(expectFailed(unsafe).error.message).toBe('share target is invalid'); + + expectOk(await send(request('share.open'))); + expect(Share.share).toHaveBeenCalledWith({ + title: '暖灯猫街', + message: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + }); + }); + + test('share.open 只把同源路径归一为公开主站分享 URL', async () => { + const response = await send( + request('share.open', { + title: '测试作品', + path: '/works/detail?work=PZ-2#play', + }), + ); + + expectOk(response); + expect(Share.share).toHaveBeenCalledWith({ + title: '测试作品', + message: 'https://www.genarrative.world/works/detail?work=PZ-2#play', + url: 'https://www.genarrative.world/works/detail?work=PZ-2#play', + }); + }); + + test.each([ + 'https://example.com/works/detail?work=PZ-1', + '//example.com/works/detail?work=PZ-1', + '//www.genarrative.world/works/detail?work=PZ-1', + 'javascript:alert(1)', + ])('share.open 拒绝非公开主站 URL:%s', async (url) => { + const response = await send( + request('share.open', { + title: '测试作品', + url, + }), + ); + + expect(expectFailed(response).error.code).toBe('invalid_request'); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('share.open 带非法 URL 时不会回退到缓存分享目标', async () => { + expectOk( + await send( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: '暖灯猫街', + work: 'PZ-00000001', + }, + }, + }), + ), + ); + + vi.mocked(Share.share).mockClear(); + + const response = await send( + request('share.open', { + title: '测试作品', + url: 'https://example.com/works/detail?work=PZ-1', + }), + ); + + expect(expectFailed(response).error.code).toBe('invalid_request'); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('share.open 拒绝指向外域的 targetPath', async () => { + const response = await send( + request('share.open', { + title: '测试作品', + targetPath: '//example.com/works/detail?work=PZ-1', + }), + ); + + expect(expectFailed(response).error.code).toBe('invalid_request'); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('share.open 拒绝协议相对的同源 targetPath', async () => { + const response = await send( + request('share.open', { + title: '测试作品', + targetPath: '//www.genarrative.world/works/detail?work=PZ-1', + }), + ); + + expect(expectFailed(response).error.code).toBe('invalid_request'); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('share.open 没有可分享内容时拒绝请求', async () => { + const response = await send(request('share.open', {})); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('file.exportText 写入缓存文件并调起系统分享', async () => { + const response = await send( + request('file.exportText', { + fileName: ' ../作品:记录?.txt ', + content: '暖灯猫街', + mimeType: 'text/markdown', + }), + ); + + const okResponse = expectOk(response); + + expect(okResponse.result).toEqual({ + action: 'saved', + fileName: '作品-记录-.txt', + bytes: 12, + }); + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/作品-记录-.txt', + content: '暖灯猫街', + options: undefined, + }, + ]); + expect(Sharing.shareAsync).toHaveBeenCalledWith( + 'file:///cache/作品-记录-.txt', + { + mimeType: 'text/markdown', + UTI: 'public.plain-text', + dialogTitle: '作品-记录-.txt', + }, + ); + }); + + test('file.exportText 在系统分享不可用时明确返回 unsupported capability', async () => { + vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false); + + const response = await send( + request('file.exportText', { + fileName: '作品记录.txt', + content: 'content', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_capability'); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.exportText 拒绝超出上限的文本内容', async () => { + const response = await send( + request('file.exportText', { + fileName: '作品记录.txt', + content: 'a'.repeat(5 * 1024 * 1024 + 1), + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.exportText 拒绝非文本 MIME', async () => { + const response = await send( + request('file.exportText', { + fileName: '作品记录.txt', + content: '暖灯猫街', + mimeType: 'image/png', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.importText 调起系统文档选择器并返回受控文本数据', async () => { + fileTexts.set('file:///private/mobile/story.md', '暖灯猫街'); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.md', + name: ' ../剧情:草稿?.md ', + mimeType: 'text/markdown', + size: 12, + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importText')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '剧情-草稿-.md', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ + copyToCacheDirectory: true, + multiple: false, + type: ['text/*', 'application/json'], + }); + }); + + test('file.importText 在系统选择结果缺少 size 时先用文件大小门禁', async () => { + fileTexts.set('file:///private/mobile/story.md', '暖灯猫街'); + fileSizes.set('file:///private/mobile/story.md', 12); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.md', + name: 'story.md', + mimeType: 'text/markdown', + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importText')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: 'story.md', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }); + expect(fileTextReads).toEqual(['file:///private/mobile/story.md']); + + fileSizes.set('file:///private/mobile/story.md', 5 * 1024 * 1024 + 1); + fileTextReads.length = 0; + + const oversized = await send(request('file.importText')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(fileTextReads).toEqual([]); + }); + + test('file.importText 取消选择时返回 cancelled', async () => { + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const response = await send(request('file.importText')); + + expect(expectFailed(response).error.code).toBe('cancelled'); + }); + + test('file.importText 拒绝非法 MIME 与超限文本', async () => { + fileTexts.set('file:///private/mobile/story.png', '暖灯猫街'); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.png', + name: 'story.png', + mimeType: 'image/png', + size: 12, + lastModified: 1, + }, + ], + }); + + const unsupportedMime = await send(request('file.importText')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + fileTexts.set('file:///private/mobile/story.txt', 'a'.repeat(5 * 1024 * 1024 + 1)); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.txt', + name: 'story.txt', + mimeType: 'text/plain', + size: 5 * 1024 * 1024 + 1, + lastModified: 1, + }, + ], + }); + + const oversized = await send(request('file.importText')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + }); + + test('file.importDocument 调起系统文档选择器并返回受控 DOCX 数据', async () => { + fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/world.docx', + name: ' ../世界:设定?.docx ', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: 8, + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importDocument')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '世界-设定-.docx', + base64Data: DOCX_BASE64, + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ + copyToCacheDirectory: true, + multiple: false, + type: [ + 'text/*', + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/json', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + }); + }); + + test('file.importDocument 在系统选择结果缺少 size 时先用文件大小门禁', async () => { + fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64); + fileSizes.set('file:///private/mobile/world.docx', 8); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/world.docx', + name: 'world.docx', + mimeType: 'application/octet-stream', + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importDocument')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: 'world.docx', + base64Data: DOCX_BASE64, + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }); + expect(fileBase64Reads).toEqual(['file:///private/mobile/world.docx']); + + fileSizes.set('file:///private/mobile/world.docx', 5 * 1024 * 1024 + 1); + fileBase64Reads.length = 0; + + const oversized = await send(request('file.importDocument')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(fileBase64Reads).toEqual([]); + }); + + test('file.importDocument 取消选择并拒绝非法 MIME 与超限文档', async () => { + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const cancelled = await send(request('file.importDocument')); + + expect(expectFailed(cancelled).error.code).toBe('cancelled'); + + fileBase64Data.set('file:///private/mobile/story.png', DOCX_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.png', + name: 'story.png', + mimeType: 'image/png', + size: 8, + lastModified: 1, + }, + ], + }); + + const unsupportedMime = await send(request('file.importDocument')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/world.docx', + name: 'world.docx', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: 5 * 1024 * 1024 + 1, + lastModified: 1, + }, + ], + }); + + const oversized = await send(request('file.importDocument')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + }); + + test('file.exportImage 写入缓存图片并调起系统分享', async () => { + const response = await send( + request('file.exportImage', { + fileName: ' ../分享:卡? ', + base64Data: PNG_BASE64, + mimeType: 'image/png', + }), + ); + + const okResponse = expectOk(response); + + expect(okResponse.result).toEqual({ + action: 'saved', + fileName: '分享-卡-.png', + bytes: 12, + }); + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/分享-卡-.png', + content: PNG_BASE64, + options: { encoding: 'base64' }, + }, + ]); + expect(Sharing.shareAsync).toHaveBeenCalledWith( + 'file:///cache/分享-卡-.png', + { + mimeType: 'image/png', + UTI: 'public.png', + dialogTitle: '分享-卡-.png', + }, + ); + + const jpegResponse = await send( + request('file.exportImage', { + fileName: '分享卡.png', + base64Data: JPEG_BASE64, + mimeType: 'image/jpeg', + }), + ); + + expect(expectOk(jpegResponse).result).toEqual({ + action: 'saved', + fileName: '分享卡.png.jpg', + bytes: 7, + }); + expect(writtenFiles.at(-1)).toEqual({ + uri: 'file:///cache/分享卡.png.jpg', + content: JPEG_BASE64, + options: { encoding: 'base64' }, + }); + }); + + test('file.exportImage 拒绝非图片 MIME、空内容与超限内容', async () => { + const unsupportedMime = await send( + request('file.exportImage', { + fileName: '分享卡.txt', + base64Data: PNG_BASE64, + mimeType: 'text/plain', + }), + ); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + const emptyImage = await send( + request('file.exportImage', { + fileName: '分享卡.png', + base64Data: '', + mimeType: 'image/png', + }), + ); + + expect(expectFailed(emptyImage).error.code).toBe('invalid_request'); + + const oversized = await send( + request('file.exportImage', { + fileName: '分享卡.png', + base64Data: `${'A'.repeat(7 * 1024 * 1024)}`, + mimeType: 'image/png', + }), + ); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + const mismatched = await send( + request('file.exportImage', { + fileName: '分享卡.png', + base64Data: JPEG_BASE64, + mimeType: 'image/png', + }), + ); + + expect(expectFailed(mismatched).error.message).toBe( + 'image bytes do not match MIME', + ); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.exportAudio 写入缓存音频并调起系统分享', async () => { + const response = await send( + request('file.exportAudio', { + fileName: ' ../敲击:音效?.wav ', + base64Data: WAV_BASE64, + mimeType: 'audio/wav', + }), + ); + + const okResponse = expectOk(response); + + expect(okResponse.result).toEqual({ + action: 'saved', + fileName: '敲击-音效-.wav', + bytes: 12, + }); + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/敲击-音效-.wav', + content: WAV_BASE64, + options: { encoding: 'base64' }, + }, + ]); + expect(Sharing.shareAsync).toHaveBeenCalledWith( + 'file:///cache/敲击-音效-.wav', + { + mimeType: 'audio/wav', + UTI: 'public.audio', + dialogTitle: '敲击-音效-.wav', + }, + ); + }); + + test('file.exportAudio 在系统分享不可用时明确返回 unsupported capability', async () => { + vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false); + + const response = await send( + request('file.exportAudio', { + fileName: 'hit.wav', + base64Data: WAV_BASE64, + mimeType: 'audio/wav', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_capability'); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.exportAudio 拒绝非法 MIME、空内容与超限内容', async () => { + const unsupportedMime = await send( + request('file.exportAudio', { + fileName: 'hit.txt', + base64Data: WAV_BASE64, + mimeType: 'text/plain', + }), + ); + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + const emptyAudio = await send( + request('file.exportAudio', { + fileName: 'hit.wav', + base64Data: '', + mimeType: 'audio/wav', + }), + ); + expect(expectFailed(emptyAudio).error.code).toBe('invalid_request'); + + const oversized = await send( + request('file.exportAudio', { + fileName: 'hit.webm', + base64Data: 'A'.repeat(28 * 1024 * 1024), + mimeType: 'audio/webm', + }), + ); + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + const mismatched = await send( + request('file.exportAudio', { + fileName: 'hit.wav', + base64Data: MP3_BASE64, + mimeType: 'audio/wav', + }), + ); + expect(expectFailed(mismatched).error.message).toBe( + 'audio bytes do not match MIME', + ); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.importImage 调起系统相册并返回受控图片数据', async () => { + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.png', + width: 120, + height: 80, + type: 'image', + fileName: ' ../参考:图?.png ', + fileSize: 12, + base64: PNG_BASE64, + mimeType: 'image/png', + }, + ], + }); + + const response = await send(request('file.importImage')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '参考-图-.png', + base64Data: PNG_BASE64, + mimeType: 'image/png', + bytes: 12, + }); + expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled(); + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({ + allowsEditing: false, + allowsMultipleSelection: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + }); + + test('file.importImage 取消选择时返回 cancelled', async () => { + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const response = await send(request('file.importImage')); + + const failedResponse = expectFailed(response); + expect(failedResponse.error.code).toBe('cancelled'); + }); + + test('file.importImage 拒绝权限、非法 MIME 和超限图片', async () => { + vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue( + { + status: ImagePicker.PermissionStatus.DENIED, + granted: false, + canAskAgain: false, + expires: 'never', + }, + ); + + const denied = await send(request('file.importImage')); + + expect(expectFailed(denied).error.code).toBe('host_error'); + expect(ImagePicker.launchImageLibraryAsync).not.toHaveBeenCalled(); + + vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue( + { + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }, + ); + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.gif', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.gif', + fileSize: 5, + base64: PNG_BASE64, + mimeType: 'image/gif', + }, + ], + }); + + const unsupportedMime = await send(request('file.importImage')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.png', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.png', + fileSize: 10 * 1024 * 1024 + 1, + base64: PNG_BASE64, + mimeType: 'image/png', + }, + ], + }); + + const oversized = await send(request('file.importImage')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.png', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.png', + fileSize: 7, + base64: JPEG_BASE64, + mimeType: 'image/png', + }, + ], + }); + + const mismatched = await send(request('file.importImage')); + + expect(expectFailed(mismatched).error.message).toBe( + 'image bytes do not match MIME', + ); + }); + + test('file.captureImage 调起系统相机并返回受控图片数据', async () => { + vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/camera.jpg', + width: 120, + height: 80, + type: 'image', + fileName: null, + fileSize: 7, + base64: JPEG_BASE64, + mimeType: 'image/jpeg', + }, + ], + }); + + const response = await send(request('file.captureImage')); + + expect(expectOk(response).result).toEqual({ + action: 'captured', + fileName: 'genarrative-import.jpg', + base64Data: JPEG_BASE64, + mimeType: 'image/jpeg', + bytes: 7, + }); + expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled(); + expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ + allowsEditing: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + }); + + test('file.captureImage 拒绝权限和取消拍摄', async () => { + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.DENIED, + granted: false, + canAskAgain: false, + expires: 'never', + }); + + const denied = await send(request('file.captureImage')); + + expect(expectFailed(denied).error.code).toBe('host_error'); + expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled(); + + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const cancelled = await send(request('file.captureImage')); + + expect(expectFailed(cancelled).error.code).toBe('cancelled'); + }); + + test('scanner.scanQrCode 等待扫码 overlay 返回真实结果', async () => { + const pendingResponse = send(request('scanner.scanQrCode')); + + expect(completeQrCodeScan(' https://www.genarrative.world/w/PZ-1 ')).toBe( + true, + ); + + expect(expectOk(await pendingResponse).result).toEqual({ + value: 'https://www.genarrative.world/w/PZ-1', + format: 'qr_code', + }); + }); + + test('scanner.scanQrCode 取消和扫描失败返回明确错误', async () => { + const cancelledResponse = send(request('scanner.scanQrCode')); + cancelQrCodeScan(); + + expect(expectFailed(await cancelledResponse).error).toEqual({ + code: 'cancelled', + message: 'qr scan cancelled', + }); + + const deniedResponse = send(request('scanner.scanQrCode')); + failQrCodeScan(); + + expect(expectFailed(await deniedResponse).error).toEqual({ + code: 'host_error', + message: 'qr scanner unavailable', + }); + }); + + test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => { + fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: ' ../敲击:音效?.webm ', + mimeType: 'audio/webm', + size: 6, + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importAudio')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '敲击-音效-.webm', + base64Data: WEBM_BASE64, + mimeType: 'audio/webm', + bytes: 6, + }); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ + copyToCacheDirectory: true, + multiple: false, + type: [ + 'audio/*', + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', + ], + }); + }); + + test('file.importAudio 在系统选择结果缺少 size 时先用文件大小门禁', async () => { + fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64); + fileSizes.set('file:///private/mobile/hit.webm', 6); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: 'hit.webm', + mimeType: 'audio/webm', + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importAudio')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: 'hit.webm', + base64Data: WEBM_BASE64, + mimeType: 'audio/webm', + bytes: 6, + }); + expect(fileBase64Reads).toEqual(['file:///private/mobile/hit.webm']); + + fileSizes.set('file:///private/mobile/hit.webm', 20 * 1024 * 1024 + 1); + fileBase64Reads.length = 0; + + const oversized = await send(request('file.importAudio')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(fileBase64Reads).toEqual([]); + }); + + test('file.importAudio 允许系统 MIME 缺失时按扩展名和 bytes 校验导入', async () => { + fileBase64Data.set('file:///private/mobile/hit.m4a', MP3_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.m4a', + name: 'hit.m4a', + mimeType: 'application/octet-stream', + size: 10, + lastModified: 1, + }, + ], + }); + + const mismatched = await send(request('file.importAudio')); + + expect(expectFailed(mismatched).error.message).toBe( + 'audio bytes do not match MIME', + ); + + fileBase64Data.set('file:///private/mobile/hit.m4a', MP4_AUDIO_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.m4a', + name: 'hit.m4a', + mimeType: 'application/octet-stream', + size: 12, + lastModified: 1, + }, + ], + }); + + const imported = await send(request('file.importAudio')); + + expect(expectOk(imported).result).toEqual({ + action: 'selected', + fileName: 'hit.m4a', + base64Data: MP4_AUDIO_BASE64, + mimeType: 'audio/mp4', + bytes: 12, + }); + }); + + test('file.importAudio 取消选择并拒绝非法 MIME 与超限音频', async () => { + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const cancelled = await send(request('file.importAudio')); + + expect(expectFailed(cancelled).error.code).toBe('cancelled'); + + fileBase64Data.set('file:///private/mobile/hit.txt', WEBM_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.txt', + name: 'hit.txt', + mimeType: 'text/plain', + size: 5, + lastModified: 1, + }, + ], + }); + + const unsupportedMime = await send(request('file.importAudio')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: 'hit.webm', + mimeType: 'audio/webm', + size: 20 * 1024 * 1024 + 1, + lastModified: 1, + }, + ], + }); + + const oversized = await send(request('file.importAudio')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + fileBase64Data.set('file:///private/mobile/hit.webm', MP3_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: 'hit.webm', + mimeType: 'audio/webm', + size: 10, + lastModified: 1, + }, + ], + }); + + const mismatched = await send(request('file.importAudio')); + + expect(expectFailed(mismatched).error.message).toBe( + 'audio bytes do not match MIME', + ); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/bridge.ts b/apps/mobile-shell/src/host-bridge/bridge.ts new file mode 100644 index 000000000..609033e78 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/bridge.ts @@ -0,0 +1,92 @@ +import { + HOST_BRIDGE_RESPONSE_CACHE_MAX, + type HostBridgeRequest, + type HostBridgeResponse, + normalizeHostBridgeRequestId, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + dispatchMobileHostBridgeRequest, + resetMobileHostBridgeDispatchForTest, +} from './dispatch'; +import { + failure, + invalidRequest, + isHostBridgeRequest, + normalizeMobileHostBridgeError, + parseRequest, +} from './protocol'; + +export { + IOS_MOBILE_HOST_CAPABILITIES, + MOBILE_HOST_CAPABILITIES, + resolveMobileHostCapabilities, +} from './capabilities'; +export { configureMobileHostBridgeNavigation } from './dispatch'; + +const completedHostBridgeResponses = new Map(); +const inFlightHostBridgeResponses = new Map< + string, + Promise +>(); + +function rememberHostBridgeResponse(response: HostBridgeResponse) { + completedHostBridgeResponses.set(response.id, response); + if (completedHostBridgeResponses.size > HOST_BRIDGE_RESPONSE_CACHE_MAX) { + const oldestRequestId = completedHostBridgeResponses.keys().next().value; + if (oldestRequestId) { + completedHostBridgeResponses.delete(oldestRequestId); + } + } + + return response; +} + +async function resolveMobileHostBridgeResponse(request: HostBridgeRequest) { + const completedResponse = completedHostBridgeResponses.get(request.id); + if (completedResponse) { + return completedResponse; + } + + const inFlightResponse = inFlightHostBridgeResponses.get(request.id); + if (inFlightResponse) { + return await inFlightResponse; + } + + const responsePromise = dispatchMobileHostBridgeRequest(request) + .catch((error: unknown) => + failure(request, normalizeMobileHostBridgeError(error)), + ) + .then(rememberHostBridgeResponse); + inFlightHostBridgeResponses.set(request.id, responsePromise); + + try { + return await responsePromise; + } finally { + inFlightHostBridgeResponses.delete(request.id); + } +} + +export async function handleMobileHostBridgeMessage( + rawMessage: string, + sendResponse: (response: HostBridgeResponse) => void, +) { + const parsed = parseRequest(rawMessage); + if (!isHostBridgeRequest(parsed)) { + sendResponse( + failure( + { id: 'invalid' }, + invalidRequest('invalid host bridge request'), + ), + ); + return; + } + + parsed.id = normalizeHostBridgeRequestId(parsed.id) ?? parsed.id; + sendResponse(await resolveMobileHostBridgeResponse(parsed)); +} + +export function resetMobileHostBridgeForTest() { + resetMobileHostBridgeDispatchForTest(); + completedHostBridgeResponses.clear(); + inFlightHostBridgeResponses.clear(); +} diff --git a/apps/mobile-shell/src/host-bridge/capabilities.test.ts b/apps/mobile-shell/src/host-bridge/capabilities.test.ts new file mode 100644 index 000000000..10e970166 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/capabilities.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + IOS_MOBILE_HOST_CAPABILITIES, + MOBILE_HOST_CAPABILITIES, + resolveMobileHostCapabilities, +} from './capabilities'; + +vi.mock('react-native', () => ({ + Platform: { + OS: 'android', + }, +})); + +describe('mobile HostBridge capability profile', () => { + test('uses the shared Expo mobile capability profiles directly', () => { + expect(MOBILE_HOST_CAPABILITIES).toBe( + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + ); + expect(IOS_MOBILE_HOST_CAPABILITIES).toBe( + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + ); + }); + + test('keeps Android on the base profile without iOS-only badge support', () => { + expect(resolveMobileHostCapabilities('android')).toBe( + MOBILE_HOST_CAPABILITIES, + ); + expect(resolveMobileHostCapabilities('android')).not.toContain('app.setBadgeCount'); + }); + + test('adds only real iOS extra capabilities on iOS', () => { + expect(resolveMobileHostCapabilities('ios')).toBe( + IOS_MOBILE_HOST_CAPABILITIES, + ); + expect(resolveMobileHostCapabilities('ios')).toEqual([ + ...MOBILE_HOST_CAPABILITIES, + 'app.setBadgeCount', + ]); + }); + + test('does not advertise SDK-backed capabilities before real native flows exist', () => { + for (const profile of [ + MOBILE_HOST_CAPABILITIES, + IOS_MOBILE_HOST_CAPABILITIES, + ]) { + expect(profile).not.toContain('auth.requestLogin'); + expect(profile).not.toContain('payment.request'); + } + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/capabilities.ts b/apps/mobile-shell/src/host-bridge/capabilities.ts new file mode 100644 index 000000000..383e850fa --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/capabilities.ts @@ -0,0 +1,18 @@ +import { Platform } from 'react-native'; + +import { + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, +} from '../../../../packages/shared/src/contracts/hostBridge'; + +export const MOBILE_HOST_CAPABILITIES = + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES; + +export const IOS_MOBILE_HOST_CAPABILITIES = + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES; + +export function resolveMobileHostCapabilities(platform = Platform.OS) { + return platform === 'ios' + ? IOS_MOBILE_HOST_CAPABILITIES + : MOBILE_HOST_CAPABILITIES; +} diff --git a/apps/mobile-shell/src/host-bridge/clipboard.test.ts b/apps/mobile-shell/src/host-bridge/clipboard.test.ts new file mode 100644 index 000000000..d0f667df8 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/clipboard.test.ts @@ -0,0 +1,188 @@ +import * as Clipboard from 'expo-clipboard'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + readMobileClipboardText, + readMobileHostBridgeClipboardText, + writeMobileClipboardText, + writeMobileHostBridgeClipboardText, +} from './clipboard'; + +vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +function request( + method: 'clipboard.writeText' | 'clipboard.readText', + payload?: unknown, +): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: `${method}-request`, + method, + payload, + }; +} + +beforeEach(() => { + vi.mocked(Clipboard.getStringAsync).mockReset(); + vi.mocked(Clipboard.setStringAsync).mockReset(); +}); + +describe('mobile clipboard helpers', () => { + test('writes normalized text to the Expo clipboard', async () => { + await expect(writeMobileClipboardText('作品号 PZ-1')).resolves.toBe(true); + + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('作品号 PZ-1'); + }); + + test('truncates long text before writing to the Expo clipboard', async () => { + await expect(writeMobileClipboardText('猫'.repeat(100010))).resolves.toBe( + true, + ); + + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('猫'.repeat(100000)); + }); + + test('writes empty text as a valid pure-text clipboard payload', async () => { + await expect(writeMobileClipboardText('')).resolves.toBe(true); + + expect(Clipboard.setStringAsync).toHaveBeenCalledWith(''); + }); + + test('rejects non-string write text without touching the Expo clipboard', async () => { + await expect(writeMobileClipboardText(undefined)).resolves.toBe(false); + + expect(Clipboard.setStringAsync).not.toHaveBeenCalled(); + }); + + test('maps native clipboard write failures to stable host errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('native clipboard write failed'); + vi.mocked(Clipboard.setStringAsync).mockRejectedValueOnce( + nativeError, + ); + + try { + await expect(writeMobileClipboardText('作品号 PZ-1')).rejects.toMatchObject({ + code: 'host_error', + message: 'clipboard write unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile clipboard failed for write.set_string', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + test('wraps HostBridge clipboard write responses', async () => { + const response = await writeMobileHostBridgeClipboardText( + request('clipboard.writeText', { + text: '作品号 PZ-1', + }), + ); + + expect(response).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'clipboard.writeText-request', + ok: true, + result: true, + }); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('作品号 PZ-1'); + }); + + test('wraps invalid HostBridge clipboard write payloads', async () => { + await expect( + writeMobileHostBridgeClipboardText( + request('clipboard.writeText', { + text: undefined, + }), + ), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'text is required', + }); + + expect(Clipboard.setStringAsync).not.toHaveBeenCalled(); + }); + + test('reads and normalizes pure text from the Expo clipboard', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); + + await expect(readMobileClipboardText()).resolves.toEqual({ + text: '作品号 PZ-1', + }); + }); + + test('truncates long clipboard reads at the shared text boundary', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('猫'.repeat(100010)); + + await expect(readMobileClipboardText()).resolves.toEqual({ + text: '猫'.repeat(100000), + }); + }); + + test('reads empty clipboard text as valid pure text', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue(''); + + await expect(readMobileClipboardText()).resolves.toEqual({ + text: '', + }); + }); + + test('rejects unavailable clipboard text without reporting a successful empty value', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue(undefined as never); + + await expect(readMobileClipboardText()).rejects.toMatchObject({ + code: 'host_error', + message: 'clipboard text unavailable', + }); + }); + + test('maps native clipboard read failures to stable host errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('native clipboard read failed'); + vi.mocked(Clipboard.getStringAsync).mockRejectedValueOnce( + nativeError, + ); + + try { + await expect(readMobileClipboardText()).rejects.toMatchObject({ + code: 'host_error', + message: 'clipboard read unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile clipboard failed for read.get_string', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + test('wraps HostBridge clipboard read responses', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); + + const response = await readMobileHostBridgeClipboardText( + request('clipboard.readText'), + ); + + expect(response).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'clipboard.readText-request', + ok: true, + result: { + text: '作品号 PZ-1', + }, + }); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/clipboard.ts b/apps/mobile-shell/src/host-bridge/clipboard.ts new file mode 100644 index 000000000..f822f0def --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/clipboard.ts @@ -0,0 +1,72 @@ +import * as Clipboard from 'expo-clipboard'; + +import { + type ClipboardReadTextResult, + type ClipboardWriteTextPayload, + type HostBridgeError, + type HostBridgeRequest, + normalizeHostBridgeClipboardText, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest, ok } from './protocol'; + +function logMobileClipboardFailure(label: string, _error: unknown) { + console.warn(`mobile clipboard failed for ${label}`); +} + +export async function writeMobileClipboardText(rawText: unknown) { + const clipboardText = normalizeHostBridgeClipboardText(rawText); + if (!clipboardText) { + return false; + } + + try { + await Clipboard.setStringAsync(clipboardText.text); + } catch (error) { + logMobileClipboardFailure('write.set_string', error); + throw { + code: 'host_error', + message: 'clipboard write unavailable', + } satisfies HostBridgeError; + } + return true; +} + +export async function writeMobileHostBridgeClipboardText( + request: HostBridgeRequest, +) { + const text = (request.payload as ClipboardWriteTextPayload | undefined)?.text; + if (!(await writeMobileClipboardText(text))) { + throw invalidRequest('text is required'); + } + + return ok(request, true); +} + +export async function readMobileHostBridgeClipboardText( + request: HostBridgeRequest, +) { + return ok(request, await readMobileClipboardText()); +} + +export async function readMobileClipboardText(): Promise { + let rawText: unknown; + try { + rawText = await Clipboard.getStringAsync(); + } catch (error) { + logMobileClipboardFailure('read.get_string', error); + throw { + code: 'host_error', + message: 'clipboard read unavailable', + } satisfies HostBridgeError; + } + + const result = normalizeHostBridgeClipboardText(rawText); + if (!result) { + throw { + code: 'host_error', + message: 'clipboard text unavailable', + } satisfies HostBridgeError; + } + + return result; +} diff --git a/apps/mobile-shell/src/host-bridge/dispatch.test.ts b/apps/mobile-shell/src/host-bridge/dispatch.test.ts new file mode 100644 index 000000000..0bcb69ead --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/dispatch.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_METHODS, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeMethod, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + dispatchMobileHostBridgeRequest, + resetMobileHostBridgeDispatchForTest, +} from './dispatch'; + +vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: vi.fn(), +})); + +vi.mock('expo-file-system', () => ({ + File: class TestFile {}, + Paths: { + cache: 'file:///cache/', + }, +})); + +vi.mock('expo-haptics', () => ({ + ImpactFeedbackStyle: { + Heavy: 'heavy', + Light: 'light', + Medium: 'medium', + }, + impactAsync: vi.fn(), +})); + +vi.mock('expo-image-picker', () => ({ + PermissionStatus: { + DENIED: 'denied', + GRANTED: 'granted', + }, + launchCameraAsync: vi.fn(), + launchImageLibraryAsync: vi.fn(), + requestCameraPermissionsAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), +})); + +vi.mock('expo-linking', () => ({ + canOpenURL: vi.fn(), + openURL: vi.fn(), +})); + +vi.mock('expo-network', () => ({ + getNetworkStateAsync: vi.fn(), + NetworkStateType: { + CELLULAR: 'CELLULAR', + ETHERNET: 'ETHERNET', + NONE: 'NONE', + WIFI: 'WIFI', + }, +})); + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { + DEFAULT: 'default', + }, + getPermissionsAsync: vi.fn(), + IosAuthorizationStatus: { + PROVISIONAL: 3, + }, + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + setNotificationChannelAsync: vi.fn(), + setNotificationHandler: vi.fn(), +})); + +vi.mock('expo-sharing', () => ({ + isAvailableAsync: vi.fn(), + shareAsync: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Appearance: { + getColorScheme: vi.fn(), + }, + Platform: { + OS: 'android', + }, +})); + +function request(method: HostBridgeMethod): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: `dispatch-${method}`, + method, + }; +} + +const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter( + (method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method), +); + +describe('mobile HostBridge dispatch', () => { + test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)( + '%s 未被移动壳声明时明确返回 unsupported', + async (method) => { + resetMobileHostBridgeDispatchForTest(); + + const response = await dispatchMobileHostBridgeRequest(request(method)); + + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error(`${method} unexpectedly succeeded`); + } + expect(response.error.code).toBe('unsupported_method'); + expect(response.error.message).toContain(method); + }, + ); +}); diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts new file mode 100644 index 000000000..9245caad2 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -0,0 +1,107 @@ +import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + captureMobileHostBridgeImageFile, + exportMobileHostBridgeAudioFile, + exportMobileHostBridgeImageFile, + exportMobileHostBridgeTextFile, + importMobileHostBridgeAudioFile, + importMobileHostBridgeDocumentFile, + importMobileHostBridgeImageFile, + importMobileHostBridgeTextFile, +} from './files'; +import { + failure, + type MobileHostBridgeNavigation, + unsupported, +} from './protocol'; +import { resetQrScannerForTest, scanMobileHostBridgeQrCode } from './scanner'; +import { + openShare, + resetMobileHostBridgeShareTargetForTest, + setMobileHostBridgeShareTarget, +} from './share'; +import { showMobileHostBridgeLocalNotification } from './notifications'; +import { + readMobileHostBridgeClipboardText, + writeMobileHostBridgeClipboardText, +} from './clipboard'; +import { runMobileHostBridgeHapticsImpact } from './haptics'; +import { setMobileAppBadgeCount } from './badge'; +import { getMobileHostBridgeAppearanceColorScheme } from './appearance'; +import { + openMobileHostBridgeExternalUrl, + openMobileHostBridgeNativePage, + reloadMobileHostBridgeWebView, +} from './navigation'; +import { getMobileHostBridgeNetworkStatus } from './network'; +import { getMobileHostBridgeRuntimeResponse } from './runtime'; + +let navigation: MobileHostBridgeNavigation | null = null; + +export function configureMobileHostBridgeNavigation( + nextNavigation: MobileHostBridgeNavigation | null, +) { + navigation = nextNavigation; +} + +export async function dispatchMobileHostBridgeRequest( + request: HostBridgeRequest, +) { + switch (request.method) { + case 'host.getRuntime': + return getMobileHostBridgeRuntimeResponse(request); + case 'appearance.getColorScheme': + return getMobileHostBridgeAppearanceColorScheme(request); + case 'app.openExternalUrl': + return openMobileHostBridgeExternalUrl(request); + case 'app.reloadWebView': + return reloadMobileHostBridgeWebView(request, navigation); + case 'network.status': + return getMobileHostBridgeNetworkStatus(request); + case 'clipboard.writeText': + return writeMobileHostBridgeClipboardText(request); + case 'clipboard.readText': + return readMobileHostBridgeClipboardText(request); + case 'file.exportText': + return exportMobileHostBridgeTextFile(request); + case 'file.importText': + return importMobileHostBridgeTextFile(request); + case 'file.importDocument': + return importMobileHostBridgeDocumentFile(request); + case 'file.exportImage': + return exportMobileHostBridgeImageFile(request); + case 'file.importImage': + return importMobileHostBridgeImageFile(request); + case 'file.captureImage': + return captureMobileHostBridgeImageFile(request); + case 'scanner.scanQrCode': + return scanMobileHostBridgeQrCode(request); + case 'file.importAudio': + return importMobileHostBridgeAudioFile(request); + case 'file.exportAudio': + return exportMobileHostBridgeAudioFile(request); + case 'haptics.impact': + return runMobileHostBridgeHapticsImpact(request); + case 'notification.showLocal': + return showMobileHostBridgeLocalNotification(request); + case 'app.setBadgeCount': + return setMobileAppBadgeCount(request); + case 'share.open': + return openShare(request); + case 'share.setTarget': + return setMobileHostBridgeShareTarget(request); + case 'navigation.openNativePage': + return openMobileHostBridgeNativePage(request, navigation); + case 'auth.requestLogin': + case 'payment.request': + return failure(request, unsupported(request.method)); + default: + return failure(request, unsupported(request.method)); + } +} + +export function resetMobileHostBridgeDispatchForTest() { + navigation = null; + resetMobileHostBridgeShareTargetForTest(); + resetQrScannerForTest(); +} diff --git a/apps/mobile-shell/src/host-bridge/filePayloads.test.ts b/apps/mobile-shell/src/host-bridge/filePayloads.test.ts new file mode 100644 index 000000000..81a850215 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/filePayloads.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from 'vitest'; + +import { HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + assertImportedFileSizeWithinLimit, + base64DecodedByteLength, + ensureAudioBytesMatchMimeType, + ensureImageBytesMatchMimeType, + imagePickerResultToImportPayload, + normalizedBase64Data, + normalizeExportedAudioFileName, + normalizeExportedImageFileName, + normalizeImportedAudioMimeType, + normalizeImportedDocumentMimeType, + normalizeImportedTextMimeType, + utf8ByteLength, +} from './filePayloads'; + +function encodeBytes(bytes: readonly number[]) { + return Buffer.from(bytes).toString('base64'); +} + +const PNG_BASE64 = encodeBytes([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, +]); +const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]); +const WEBP_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WEBP', 'binary').toString( + 'base64', +); +const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString( + 'base64', +); +const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString( + 'base64', +); +const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]); + +describe('mobile file payload helpers', () => { + test('base64 and UTF-8 byte boundaries are deterministic', () => { + expect(utf8ByteLength('泥巴AI')).toBe(Buffer.byteLength('泥巴AI')); + expect(normalizedBase64Data(` ${PNG_BASE64} `)).toBe(PNG_BASE64); + expect(normalizedBase64Data('not base64')).toBeNull(); + expect(base64DecodedByteLength(PNG_BASE64)).toBe(12); + }); + + test('normalizes imported text and document MIME types from MIME or extension', () => { + expect(normalizeImportedTextMimeType('TEXT/MARKDOWN', 'story.md')).toBe( + 'text/markdown', + ); + expect(normalizeImportedTextMimeType(undefined, 'world.json')).toBe( + 'application/json', + ); + expect(normalizeImportedDocumentMimeType(undefined, 'brief.docx')).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + expect(normalizeImportedDocumentMimeType('application/pdf', 'brief.pdf')).toBeNull(); + }); + + test('normalizes audio MIME types from MIME or extension', () => { + expect(normalizeImportedAudioMimeType('AUDIO/WEBM', 'voice.bin')).toBe( + 'audio/webm', + ); + expect(normalizeImportedAudioMimeType(undefined, 'voice.mp3')).toBe( + 'audio/mpeg', + ); + expect(normalizeImportedAudioMimeType(undefined, 'voice.flac')).toBeNull(); + }); + + test('normalizes exported image and audio file extensions', () => { + expect(normalizeExportedImageFileName('卡片', 'image/jpeg')).toBe('卡片.jpg'); + expect(normalizeExportedImageFileName('卡片.webp', 'image/webp')).toBe( + '卡片.webp', + ); + expect(normalizeExportedAudioFileName('声浪', 'audio/mpeg')).toBe('声浪.mp3'); + expect(normalizeExportedAudioFileName('声浪.webm', 'audio/webm')).toBe( + '声浪.webm', + ); + }); + + test('validates image and audio bytes against declared MIME', () => { + expect(() => ensureImageBytesMatchMimeType(PNG_BASE64, 'image/png')).not.toThrow(); + expect(() => ensureImageBytesMatchMimeType(JPEG_BASE64, 'image/jpeg')).not.toThrow(); + expect(() => ensureImageBytesMatchMimeType(WEBP_BASE64, 'image/webp')).not.toThrow(); + expect(() => ensureImageBytesMatchMimeType(JPEG_BASE64, 'image/png')).toThrow( + 'image bytes do not match MIME', + ); + + expect(() => ensureAudioBytesMatchMimeType(MP3_BASE64, 'audio/mpeg')).not.toThrow(); + expect(() => ensureAudioBytesMatchMimeType(WAV_BASE64, 'audio/wav')).not.toThrow(); + expect(() => ensureAudioBytesMatchMimeType(WEBM_BASE64, 'audio/webm')).not.toThrow(); + expect(() => ensureAudioBytesMatchMimeType(MP3_BASE64, 'audio/wav')).toThrow( + 'audio bytes do not match MIME', + ); + }); + + test('checks imported file size from picker metadata before file fallback', () => { + expect(() => + assertImportedFileSizeWithinLimit(12, { size: undefined }, 20, 'too large'), + ).not.toThrow(); + expect(() => + assertImportedFileSizeWithinLimit(undefined, { size: 12 }, 20, 'too large'), + ).not.toThrow(); + expect(() => + assertImportedFileSizeWithinLimit(21, { size: 12 }, 20, 'too large'), + ).toThrow('too large'); + expect(() => + assertImportedFileSizeWithinLimit(undefined, { size: undefined }, 20, 'too large'), + ).toThrow('too large'); + }); + + test('maps ImagePicker results to HostBridge import payloads', () => { + const payload = imagePickerResultToImportPayload( + { + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图', + width: 120, + height: 80, + type: 'image', + fileName: '参考图', + fileSize: 12, + base64: PNG_BASE64, + mimeType: 'image/png', + }, + ], + }, + 'selected', + ); + + expect(payload).toEqual({ + action: 'selected', + fileName: '参考图.png', + base64Data: PNG_BASE64, + mimeType: 'image/png', + bytes: 12, + }); + }); + + test('rejects cancelled, invalid and oversized ImagePicker results', () => { + expect(() => + imagePickerResultToImportPayload({ canceled: true, assets: null }, 'selected'), + ).toThrow('file import cancelled'); + + expect(() => + imagePickerResultToImportPayload( + { + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.gif', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.gif', + fileSize: 5, + base64: PNG_BASE64, + mimeType: 'image/gif', + }, + ], + }, + 'selected', + ), + ).toThrow('mimeType must be an allowed image type'); + + expect(() => + imagePickerResultToImportPayload( + { + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.png', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.png', + fileSize: HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES + 1, + base64: PNG_BASE64, + mimeType: 'image/png', + }, + ], + }, + 'selected', + ), + ).toThrow('image exceeds file import size limit'); + + expect(() => + imagePickerResultToImportPayload( + { + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.png', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.png', + fileSize: 7, + base64: JPEG_BASE64, + mimeType: 'image/png', + }, + ], + }, + 'selected', + ), + ).toThrow('image bytes do not match MIME'); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/filePayloads.ts b/apps/mobile-shell/src/host-bridge/filePayloads.ts new file mode 100644 index 000000000..8e076ec1b --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/filePayloads.ts @@ -0,0 +1,458 @@ +import type * as ImagePicker from 'expo-image-picker'; + +import { + type FileImportImageResult, + type HostBridgeAudioMimeType, + type HostBridgeDocumentMimeType, + type HostBridgeImageMimeType, + type HostBridgeTextMimeType, + HOST_BRIDGE_AUDIO_MIME_TYPES, + HOST_BRIDGE_DOCUMENT_MIME_TYPES, + HOST_BRIDGE_IMAGE_MIME_TYPES, + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, + HOST_BRIDGE_TEXT_MIME_TYPES, + normalizeHostBridgeExportFileName, + normalizeHostBridgeImportFileName, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest } from './protocol'; + +export const HOST_BRIDGE_TEXT_MIME_TYPE_SET = + new Set(HOST_BRIDGE_TEXT_MIME_TYPES); +export const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = + new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES); +export const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = + new Set(HOST_BRIDGE_IMAGE_MIME_TYPES); +export const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = + new Set(HOST_BRIDGE_AUDIO_MIME_TYPES); +export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [ + 'audio/*', + ...HOST_BRIDGE_AUDIO_MIME_TYPES, +]; +export const MOBILE_DOCUMENT_PICKER_TYPES = [ + 'text/*', + ...HOST_BRIDGE_DOCUMENT_MIME_TYPES, +]; + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export function utf8ByteLength(value: string) { + let bytes = 0; + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 0x7f) { + bytes += 1; + } else if (codePoint <= 0x7ff) { + bytes += 2; + } else if (codePoint <= 0xffff) { + bytes += 3; + } else { + bytes += 4; + } + } + return bytes; +} + +export function normalizedBase64Data(value: unknown) { + if (typeof value !== 'string') { + return null; + } + + const normalizedValue = value.trim(); + if ( + !normalizedValue || + normalizedValue.length % 4 !== 0 || + !/^[A-Za-z0-9+/]+={0,2}$/u.test(normalizedValue) + ) { + return null; + } + + return normalizedValue; +} + +export function base64DecodedByteLength(value: string) { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +function base64Bytes(value: string) { + const bytes = new Uint8Array(base64DecodedByteLength(value)); + let byteIndex = 0; + + for (let index = 0; index < value.length; index += 4) { + const first = BASE64_ALPHABET.indexOf(value[index] ?? 'A'); + const second = BASE64_ALPHABET.indexOf(value[index + 1] ?? 'A'); + const third = + value[index + 2] === '=' + ? 0 + : BASE64_ALPHABET.indexOf(value[index + 2] ?? 'A'); + const fourth = + value[index + 3] === '=' + ? 0 + : BASE64_ALPHABET.indexOf(value[index + 3] ?? 'A'); + const chunk = (first << 18) | (second << 12) | (third << 6) | fourth; + + if (byteIndex < bytes.length) { + bytes[byteIndex] = (chunk >> 16) & 0xff; + byteIndex += 1; + } + if (byteIndex < bytes.length) { + bytes[byteIndex] = (chunk >> 8) & 0xff; + byteIndex += 1; + } + if (byteIndex < bytes.length) { + bytes[byteIndex] = chunk & 0xff; + byteIndex += 1; + } + } + + return bytes; +} + +function byteAt(bytes: Uint8Array, index: number) { + return bytes[index] ?? 0; +} + +function bytesStartWith(bytes: Uint8Array, header: readonly number[]) { + return header.every((value, index) => byteAt(bytes, index) === value); +} + +function riffContainerMatches(bytes: Uint8Array, kind: string) { + return ( + bytes.length >= 12 && + byteAt(bytes, 0) === 0x52 && + byteAt(bytes, 1) === 0x49 && + byteAt(bytes, 2) === 0x46 && + byteAt(bytes, 3) === 0x46 && + String.fromCharCode(...bytes.slice(8, 12)) === kind + ); +} + +function detectImageMimeType(base64Data: string): HostBridgeImageMimeType | null { + const bytes = base64Bytes(base64Data); + if (bytesStartWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return 'image/png'; + } + if ( + bytes.length >= 3 && + byteAt(bytes, 0) === 0xff && + byteAt(bytes, 1) === 0xd8 && + byteAt(bytes, 2) === 0xff + ) { + return 'image/jpeg'; + } + if (riffContainerMatches(bytes, 'WEBP')) { + return 'image/webp'; + } + + return null; +} + +function detectAudioMimeType(base64Data: string): HostBridgeAudioMimeType | null { + const bytes = base64Bytes(base64Data); + if ( + bytesStartWith(bytes, [0x49, 0x44, 0x33]) || + (bytes.length >= 2 && byteAt(bytes, 0) === 0xff && (byteAt(bytes, 1) & 0xe0) === 0xe0) + ) { + return 'audio/mpeg'; + } + if ( + bytes.length >= 12 && + byteAt(bytes, 4) === 0x66 && + byteAt(bytes, 5) === 0x74 && + byteAt(bytes, 6) === 0x79 && + byteAt(bytes, 7) === 0x70 + ) { + return 'audio/mp4'; + } + if (riffContainerMatches(bytes, 'WAVE')) { + return 'audio/wav'; + } + if (bytesStartWith(bytes, [0x4f, 0x67, 0x67, 0x53])) { + return 'audio/ogg'; + } + if (bytesStartWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) { + return 'audio/webm'; + } + + return null; +} + +export function ensureImageBytesMatchMimeType( + base64Data: string, + mimeType: HostBridgeImageMimeType, +) { + if (detectImageMimeType(base64Data) !== mimeType) { + throw invalidRequest('image bytes do not match MIME'); + } +} + +export function ensureAudioBytesMatchMimeType( + base64Data: string, + mimeType: HostBridgeAudioMimeType, +) { + if (detectAudioMimeType(base64Data) !== mimeType) { + throw invalidRequest('audio bytes do not match MIME'); + } +} + +function imageFileExtension(mimeType: HostBridgeImageMimeType) { + if (mimeType === 'image/jpeg') { + return 'jpg'; + } + if (mimeType === 'image/webp') { + return 'webp'; + } + return 'png'; +} + +export function normalizeExportedImageFileName( + rawFileName: unknown, + mimeType: HostBridgeImageMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = imageFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedImageFileName( + rawFileName: unknown, + mimeType: HostBridgeImageMimeType, +) { + const fileName = normalizeHostBridgeImportFileName(rawFileName); + const extension = imageFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedTextMimeType( + value: unknown, + fileName: string, +): HostBridgeTextMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) { + return mimeType as HostBridgeTextMimeType; + } + } + + const normalizedName = fileName.toLowerCase(); + if (normalizedName.endsWith('.json')) { + return 'application/json'; + } + if (normalizedName.endsWith('.md') || normalizedName.endsWith('.markdown')) { + return 'text/markdown'; + } + if (normalizedName.endsWith('.csv')) { + return 'text/csv'; + } + if (normalizedName.endsWith('.txt')) { + return 'text/plain'; + } + + return null; +} + +export function normalizeImportedDocumentMimeType( + value: unknown, + fileName: string, +): HostBridgeDocumentMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if ( + HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has( + mimeType as HostBridgeDocumentMimeType, + ) + ) { + return mimeType as HostBridgeDocumentMimeType; + } + } + + const textMimeType = normalizeImportedTextMimeType(value, fileName); + if (textMimeType) { + return textMimeType; + } + + return fileName.toLowerCase().endsWith('.docx') + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : null; +} + +type SizedFile = { + size?: number; +}; + +export function assertImportedFileSizeWithinLimit( + pickerSize: unknown, + file: SizedFile, + maxBytes: number, + message: string, +) { + if ( + typeof pickerSize === 'number' && + Number.isFinite(pickerSize) && + pickerSize > 0 && + pickerSize <= maxBytes + ) { + return; + } + + if (typeof pickerSize === 'number') { + throw invalidRequest(message); + } + + const fileSize = file.size; + if ( + typeof fileSize !== 'number' || + !Number.isFinite(fileSize) || + fileSize <= 0 || + fileSize > maxBytes + ) { + throw invalidRequest(message); + } +} + +export function normalizeImportedImageMimeType( + value: unknown, +): HostBridgeImageMimeType | null { + if (typeof value !== 'string') { + return null; + } + + const mimeType = value.toLowerCase(); + return HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType) + ? (mimeType as HostBridgeImageMimeType) + : null; +} + +function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) { + if (mimeType === 'image/jpeg') { + return 'genarrative-import.jpg'; + } + if (mimeType === 'image/webp') { + return 'genarrative-import.webp'; + } + return 'genarrative-import.png'; +} + +export function imagePickerResultToImportPayload( + result: ImagePicker.ImagePickerResult, + action: FileImportImageResult['action'], +): FileImportImageResult { + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + }; + } + + const asset = result.assets[0]; + if (!asset || asset.type !== 'image') { + throw invalidRequest('image asset is required'); + } + + const mimeType = normalizeImportedImageMimeType(asset.mimeType); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed image type'); + } + + const base64Data = normalizedBase64Data(asset.base64); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0) { + throw invalidRequest('image exceeds file import size limit'); + } + ensureImageBytesMatchMimeType(base64Data, mimeType); + if ( + typeof asset.fileSize === 'number' && + asset.fileSize > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES + ) { + throw invalidRequest('image exceeds file import size limit'); + } + + return { + action, + fileName: normalizeImportedImageFileName( + asset.fileName || fallbackImportedImageFileName(mimeType), + mimeType, + ), + base64Data, + mimeType, + bytes, + }; +} + +function audioFileExtension(mimeType: HostBridgeAudioMimeType) { + if (mimeType === 'audio/mpeg') { + return 'mp3'; + } + if (mimeType === 'audio/mp4') { + return 'm4a'; + } + if (mimeType === 'audio/wav') { + return 'wav'; + } + if (mimeType === 'audio/ogg') { + return 'ogg'; + } + return 'webm'; +} + +export function normalizeExportedAudioFileName( + rawFileName: unknown, + mimeType: HostBridgeAudioMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = audioFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedAudioFileName( + rawFileName: unknown, + mimeType: HostBridgeAudioMimeType, +) { + const fileName = normalizeHostBridgeImportFileName(rawFileName); + const extension = audioFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedAudioMimeType( + value: unknown, + fileName: string, +): HostBridgeAudioMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType)) { + return mimeType as HostBridgeAudioMimeType; + } + } + + const normalizedName = fileName.toLowerCase(); + if (normalizedName.endsWith('.mp3')) { + return 'audio/mpeg'; + } + if (normalizedName.endsWith('.m4a') || normalizedName.endsWith('.mp4')) { + return 'audio/mp4'; + } + if (normalizedName.endsWith('.wav')) { + return 'audio/wav'; + } + if (normalizedName.endsWith('.ogg')) { + return 'audio/ogg'; + } + if (normalizedName.endsWith('.webm')) { + return 'audio/webm'; + } + + return null; +} diff --git a/apps/mobile-shell/src/host-bridge/files.test.ts b/apps/mobile-shell/src/host-bridge/files.test.ts new file mode 100644 index 000000000..12ef4219a --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/files.test.ts @@ -0,0 +1,596 @@ +import * as DocumentPicker from 'expo-document-picker'; +import * as ImagePicker from 'expo-image-picker'; +import * as Sharing from 'expo-sharing'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + captureImageFile, + exportAudioFile, + exportImageFile, + exportTextFile, + importAudioFile, + importDocumentFile, + importImageFile, + importTextFile, +} from './files'; + +function encodeBytes(bytes: readonly number[]) { + return Buffer.from(bytes).toString('base64'); +} + +const PNG_BASE64 = encodeBytes([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, +]); +const DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64'); +const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString( + 'base64', +); + +const fileTexts = vi.hoisted(() => new Map()); +const fileBase64Data = vi.hoisted(() => new Map()); +const fileSizes = vi.hoisted(() => new Map()); +const failingTextFiles = vi.hoisted(() => new Set()); +const failingBase64Files = vi.hoisted(() => new Set()); +const writtenFiles = vi.hoisted( + () => + [] as { + uri: string; + content: string; + options?: { encoding?: 'utf8' | 'base64' }; + }[], +); + +vi.mock('expo-file-system', () => ({ + Paths: { + cache: 'file:///cache/', + }, + File: class TestFile { + uri: string; + + constructor(base: string, fileName?: string) { + this.uri = + typeof fileName === 'string' ? `file:///cache/${fileName}` : base; + } + + write(content: string, options?: { encoding?: 'utf8' | 'base64' }) { + writtenFiles.push({ + uri: this.uri, + content, + options, + }); + } + + get size() { + return fileSizes.get(this.uri) ?? null; + } + + text() { + if (failingTextFiles.has(this.uri)) { + return Promise.reject(new Error('native text read failed')); + } + return Promise.resolve(fileTexts.get(this.uri) ?? ''); + } + + base64() { + if (failingBase64Files.has(this.uri)) { + return Promise.reject(new Error('native base64 read failed')); + } + return Promise.resolve(fileBase64Data.get(this.uri) ?? ''); + } + }, +})); + +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: vi.fn(), +})); + +vi.mock('expo-image-picker', () => ({ + PermissionStatus: { + DENIED: 'denied', + GRANTED: 'granted', + }, + launchCameraAsync: vi.fn(), + launchImageLibraryAsync: vi.fn(), + requestCameraPermissionsAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), +})); + +vi.mock('expo-sharing', () => ({ + isAvailableAsync: vi.fn(), + shareAsync: vi.fn(), +})); + +const documentPickerMock = vi.mocked(DocumentPicker.getDocumentAsync); +const shareAvailableMock = vi.mocked(Sharing.isAvailableAsync); +const shareAsyncMock = vi.mocked(Sharing.shareAsync); +const libraryPermissionMock = vi.mocked( + ImagePicker.requestMediaLibraryPermissionsAsync, +); +const cameraPermissionMock = vi.mocked(ImagePicker.requestCameraPermissionsAsync); +const imageLibraryMock = vi.mocked(ImagePicker.launchImageLibraryAsync); +const cameraMock = vi.mocked(ImagePicker.launchCameraAsync); + +describe('mobile HostBridge file actions', () => { + beforeEach(() => { + shareAvailableMock.mockResolvedValue(true); + }); + + afterEach(() => { + vi.clearAllMocks(); + fileTexts.clear(); + fileBase64Data.clear(); + fileSizes.clear(); + failingTextFiles.clear(); + failingBase64Files.clear(); + writtenFiles.length = 0; + }); + + test('exports text through Expo cache file and system share sheet', async () => { + const result = await exportTextFile({ + content: '泥巴AI', + fileName: '创作记录', + mimeType: 'text/markdown', + }); + + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/创作记录', + content: '泥巴AI', + options: undefined, + }, + ]); + expect(shareAsyncMock).toHaveBeenCalledWith('file:///cache/创作记录', { + mimeType: 'text/markdown', + UTI: 'public.plain-text', + dialogTitle: '创作记录', + }); + expect(result).toEqual({ + action: 'saved', + fileName: '创作记录', + bytes: Buffer.byteLength('泥巴AI'), + }); + }); + + test('rejects every export before cache writes when system sharing is unavailable', async () => { + shareAvailableMock.mockResolvedValue(false); + + const exportCases = [ + () => + exportTextFile({ + content: '泥巴AI', + fileName: '创作记录', + mimeType: 'text/plain', + }), + () => + exportImageFile({ + base64Data: PNG_BASE64, + fileName: '分享卡', + mimeType: 'image/png', + }), + () => + exportAudioFile({ + base64Data: MP3_BASE64, + fileName: '声浪', + mimeType: 'audio/mpeg', + }), + ]; + + for (const exportFile of exportCases) { + await expect(exportFile()).rejects.toMatchObject({ + code: 'unsupported_capability', + }); + } + + expect(writtenFiles).toHaveLength(0); + expect(shareAsyncMock).not.toHaveBeenCalled(); + }); + + test('rejects invalid text export MIME before touching native sharing', async () => { + await expect( + exportTextFile({ + content: '泥巴AI', + fileName: '创作记录', + mimeType: 'application/octet-stream', + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'mimeType must be an allowed text type', + }); + + expect(shareAvailableMock).not.toHaveBeenCalled(); + expect(writtenFiles).toHaveLength(0); + expect(shareAsyncMock).not.toHaveBeenCalled(); + }); + + test('maps native sharing availability failures to stable export errors', async () => { + const error = new Error('expo sharing crashed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shareAvailableMock.mockRejectedValueOnce(error); + + await expect( + exportTextFile({ + content: '泥巴AI', + fileName: '创作记录', + mimeType: 'text/plain', + }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'file sharing unavailable', + }); + + expect(writtenFiles).toHaveLength(0); + expect(shareAsyncMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for sharing.available', + ); + warnSpy.mockRestore(); + }); + + test('maps native sharing sheet failures to stable export errors', async () => { + const error = new Error('expo native share failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shareAsyncMock.mockRejectedValueOnce(error); + + await expect( + exportImageFile({ + base64Data: PNG_BASE64, + fileName: '分享卡', + mimeType: 'image/png', + }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'file sharing failed', + }); + + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/分享卡.png', + content: PNG_BASE64, + options: { encoding: 'base64' }, + }, + ]); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for sharing.open', + ); + warnSpy.mockRestore(); + }); + + test('imports text and document files without exposing local URIs', async () => { + fileTexts.set('file:///picked/story.md', '故事'); + fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/story.md', + name: 'story.md', + mimeType: 'text/markdown', + size: Buffer.byteLength('故事'), + lastModified: 0, + }, + ], + }); + + await expect(importTextFile()).resolves.toEqual({ + action: 'selected', + fileName: 'story.md', + content: '故事', + mimeType: 'text/markdown', + bytes: Buffer.byteLength('故事'), + }); + + fileBase64Data.set('file:///picked/brief.docx', DOCX_BASE64); + fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/brief.docx', + name: 'brief.docx', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: Buffer.byteLength('PK\x03\x04docx'), + lastModified: 0, + }, + ], + }); + + await expect(importDocumentFile()).resolves.toEqual({ + action: 'selected', + fileName: 'brief.docx', + base64Data: DOCX_BASE64, + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: Buffer.byteLength('PK\x03\x04docx'), + }); + }); + + test('maps picker cancellation to HostBridge cancellation errors', async () => { + documentPickerMock.mockResolvedValue({ + canceled: true, + assets: null, + }); + + await expect(importAudioFile()).rejects.toMatchObject({ + code: 'cancelled', + }); + }); + + test('maps native document picker failures to stable import errors', async () => { + const error = new Error('native text picker failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + documentPickerMock.mockRejectedValueOnce(error); + + await expect(importTextFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'text file picker unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for document_picker.open', + ); + warnSpy.mockRestore(); + }); + + test('maps native text file read failures to stable import errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + failingTextFiles.add('file:///picked/story.md'); + fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/story.md', + name: 'story.md', + mimeType: 'text/markdown', + size: Buffer.byteLength('故事'), + lastModified: 0, + }, + ], + }); + + await expect(importTextFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'text file unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for file.read_text', + ); + warnSpy.mockRestore(); + }); + + test('maps native binary file read failures to stable import errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + failingBase64Files.add('file:///picked/brief.docx'); + fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/brief.docx', + name: 'brief.docx', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: Buffer.byteLength('PK\x03\x04docx'), + lastModified: 0, + }, + ], + }); + + await expect(importDocumentFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'document file unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for file.read_base64', + ); + warnSpy.mockRestore(); + }); + + test('maps native audio file read failures to stable import errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + failingBase64Files.add('file:///picked/voice.mp3'); + fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/voice.mp3', + name: 'voice.mp3', + mimeType: 'audio/mpeg', + size: Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'), + lastModified: 0, + }, + ], + }); + + await expect(importAudioFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'audio file unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for file.read_base64', + ); + warnSpy.mockRestore(); + }); + + test('imports and captures images only after native permissions are granted', async () => { + libraryPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + imageLibraryMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/image.png', + width: 120, + height: 80, + type: 'image', + fileName: 'image.png', + fileSize: Buffer.byteLength(Buffer.from(PNG_BASE64, 'base64')), + base64: PNG_BASE64, + mimeType: 'image/png', + }, + ], + }); + + await expect(importImageFile()).resolves.toMatchObject({ + action: 'selected', + fileName: 'image.png', + base64Data: PNG_BASE64, + mimeType: 'image/png', + }); + expect(imageLibraryMock).toHaveBeenCalledWith({ + allowsEditing: false, + allowsMultipleSelection: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + + cameraPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.DENIED, + granted: false, + canAskAgain: false, + expires: 'never', + }); + + await expect(captureImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'camera permission denied', + }); + expect(cameraMock).not.toHaveBeenCalled(); + }); + + test('rejects image import before picker launch when library permission is denied', async () => { + libraryPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.DENIED, + granted: false, + canAskAgain: false, + expires: 'never', + }); + + await expect(importImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'photo library permission denied', + }); + expect(imageLibraryMock).not.toHaveBeenCalled(); + }); + + test('rejects image import before picker launch when library permission request fails', async () => { + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + libraryPermissionMock.mockRejectedValueOnce(error); + + await expect(importImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'photo library permission unavailable', + }); + expect(imageLibraryMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.library_permission', + ); + warnSpy.mockRestore(); + }); + + test('maps native image library launch failures to stable import errors', async () => { + const error = new Error('native image picker failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + libraryPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + imageLibraryMock.mockRejectedValueOnce(error); + + await expect(importImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'photo library unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.library_open', + ); + warnSpy.mockRestore(); + }); + + test('rejects image capture before camera launch when camera permission request fails', async () => { + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + cameraPermissionMock.mockRejectedValueOnce(error); + + await expect(captureImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'camera permission unavailable', + }); + expect(cameraMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.camera_permission', + ); + warnSpy.mockRestore(); + }); + + test('maps native camera launch failures to stable capture errors', async () => { + const error = new Error('native camera failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + cameraPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + cameraMock.mockRejectedValueOnce(error); + + await expect(captureImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'camera unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.camera_open', + ); + warnSpy.mockRestore(); + }); + + test('imports audio and exports binary files through controlled payloads', async () => { + fileBase64Data.set('file:///picked/voice.mp3', MP3_BASE64); + fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/voice.mp3', + name: 'voice.mp3', + mimeType: 'audio/mpeg', + size: Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'), + lastModified: 0, + }, + ], + }); + + await expect(importAudioFile()).resolves.toMatchObject({ + action: 'selected', + fileName: 'voice.mp3', + base64Data: MP3_BASE64, + mimeType: 'audio/mpeg', + }); + + await expect( + exportAudioFile({ + base64Data: MP3_BASE64, + fileName: '声浪', + mimeType: 'audio/mpeg', + }), + ).resolves.toMatchObject({ + action: 'saved', + fileName: '声浪.mp3', + }); + expect(writtenFiles.at(-1)).toEqual({ + uri: 'file:///cache/声浪.mp3', + content: MP3_BASE64, + options: { encoding: 'base64' }, + }); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts new file mode 100644 index 000000000..f1e85ab95 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -0,0 +1,606 @@ +import * as DocumentPicker from 'expo-document-picker'; +import { File, Paths } from 'expo-file-system'; +import * as ImagePicker from 'expo-image-picker'; +import * as Sharing from 'expo-sharing'; + +import { + type FileExportAudioPayload, + type FileExportAudioResult, + type FileExportImagePayload, + type FileExportImageResult, + type FileExportTextPayload, + type FileExportTextResult, + type FileImportAudioResult, + type FileImportDocumentResult, + type FileImportImageResult, + type FileImportTextResult, + type HostBridgeAudioMimeType, + type HostBridgeError, + type HostBridgeImageMimeType, + type HostBridgeRequest, + type HostBridgeTextMimeType, + HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES, + HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES, + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES, + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + normalizeHostBridgeExportFileName, + normalizeHostBridgeImportFileName, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + assertImportedFileSizeWithinLimit, + base64DecodedByteLength, + ensureAudioBytesMatchMimeType, + ensureImageBytesMatchMimeType, + HOST_BRIDGE_AUDIO_MIME_TYPE_SET, + HOST_BRIDGE_IMAGE_MIME_TYPE_SET, + HOST_BRIDGE_TEXT_MIME_TYPE_SET, + imagePickerResultToImportPayload, + MOBILE_AUDIO_DOCUMENT_PICKER_TYPES, + MOBILE_DOCUMENT_PICKER_TYPES, + normalizedBase64Data, + normalizeExportedAudioFileName, + normalizeExportedImageFileName, + normalizeImportedAudioFileName, + normalizeImportedAudioMimeType, + normalizeImportedDocumentMimeType, + normalizeImportedTextMimeType, + utf8ByteLength, +} from './filePayloads'; +import { invalidRequest, ok } from './protocol'; + +function logMobileHostBridgeFileFailure(label: string, _error: unknown) { + console.warn(`mobile HostBridge file failed for ${label}`); +} + +type MobileDocumentPickerOptions = Parameters< + typeof DocumentPicker.getDocumentAsync +>[0]; +type MobileDocumentPickerResult = Awaited< + ReturnType +>; + +async function assertMobileFileSharingAvailable() { + let isSharingAvailable = false; + try { + isSharingAvailable = await Sharing.isAvailableAsync(); + } catch (error) { + logMobileHostBridgeFileFailure('sharing.available', error); + throw { + code: 'host_error', + message: 'file sharing unavailable', + } satisfies HostBridgeError; + } + + if (!isSharingAvailable) { + throw { + code: 'unsupported_capability', + message: 'file sharing is unavailable in mobile shell', + } satisfies HostBridgeError; + } +} + +async function shareMobileFile( + file: File, + options: { + mimeType: string; + UTI: string; + dialogTitle: string; + }, +) { + try { + await Sharing.shareAsync(file.uri, options); + } catch (error) { + logMobileHostBridgeFileFailure('sharing.open', error); + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } +} + +export async function exportTextFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportTextPayload | undefined; + const content = exportPayload?.content; + if (typeof content !== 'string') { + throw invalidRequest('content is required'); + } + + const bytes = utf8ByteLength(content); + if (bytes > HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES) { + throw invalidRequest('content exceeds file export size limit'); + } + + const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); + const rawMimeType = exportPayload?.mimeType; + const mimeType = + typeof rawMimeType === 'string' + ? rawMimeType.toLowerCase() + : 'text/plain'; + if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) { + throw invalidRequest('mimeType must be an allowed text type'); + } + + await assertMobileFileSharingAvailable(); + + try { + const file = new File(Paths.cache, fileName); + file.write(content); + await shareMobileFile(file, { + mimeType, + UTI: 'public.plain-text', + dialogTitle: fileName, + }); + } catch (error) { + if (isHostBridgeFileSharingError(error)) { + throw error; + } + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } + + return { + action: 'saved', + fileName, + bytes, + }; +} + +export async function exportMobileHostBridgeTextFile( + request: HostBridgeRequest, +) { + return ok(request, await exportTextFile(request.payload)); +} + +export async function importTextFile(): Promise { + const result = await pickMobileDocumentFile( + { + copyToCacheDirectory: true, + multiple: false, + type: ['text/*', 'application/json'], + }, + 'text file picker unavailable', + ); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('text file is required'); + } + + const fileName = normalizeHostBridgeImportFileName( + asset.name || 'genarrative-import.txt', + ); + const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed text type'); + } + + const file = new File(asset.uri); + assertImportedFileSizeWithinLimit( + asset.size, + file, + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + 'text exceeds file import size limit', + ); + const content = await readMobileTextFile(file, 'text file unavailable'); + const bytes = utf8ByteLength(content); + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) { + throw invalidRequest('text exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + content, + mimeType, + bytes, + }; +} + +export async function importMobileHostBridgeTextFile( + request: HostBridgeRequest, +) { + return ok(request, await importTextFile()); +} + +export async function importDocumentFile(): Promise { + const result = await pickMobileDocumentFile( + { + copyToCacheDirectory: true, + multiple: false, + type: MOBILE_DOCUMENT_PICKER_TYPES, + }, + 'document picker unavailable', + ); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('document file is required'); + } + + const fileName = normalizeHostBridgeImportFileName( + asset.name || 'genarrative-import-document.txt', + ); + const mimeType = normalizeImportedDocumentMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed document type'); + } + + const file = new File(asset.uri); + assertImportedFileSizeWithinLimit( + asset.size, + file, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + 'document exceeds file import size limit', + ); + const base64Data = normalizedBase64Data( + await readMobileBase64File(file, 'document file unavailable'), + ); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES) { + throw invalidRequest('document exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + +export async function importMobileHostBridgeDocumentFile( + request: HostBridgeRequest, +) { + return ok(request, await importDocumentFile()); +} + +export async function exportImageFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportImagePayload | undefined; + const mimeType = exportPayload?.mimeType; + if ( + typeof mimeType !== 'string' || + !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType) + ) { + throw invalidRequest('mimeType must be an allowed image type'); + } + + const base64Data = normalizedBase64Data(exportPayload?.base64Data); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file export size limit'); + } + ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType); + + await assertMobileFileSharingAvailable(); + + const fileName = normalizeExportedImageFileName( + exportPayload?.fileName, + mimeType as HostBridgeImageMimeType, + ); + try { + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await shareMobileFile(file, { + mimeType, + UTI: mimeType === 'image/png' ? 'public.png' : 'public.image', + dialogTitle: fileName, + }); + } catch (error) { + if (isHostBridgeFileSharingError(error)) { + throw error; + } + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } + + return { + action: 'saved', + fileName, + bytes, + }; +} + +export async function exportMobileHostBridgeImageFile( + request: HostBridgeRequest, +) { + return ok(request, await exportImageFile(request.payload)); +} + +export async function importImageFile(): Promise { + let permission: ImagePicker.MediaLibraryPermissionResponse; + try { + permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + } catch (error) { + logMobileHostBridgeFileFailure('image.library_permission', error); + throw { + code: 'host_error', + message: 'photo library permission unavailable', + } satisfies HostBridgeError; + } + if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { + throw { + code: 'host_error', + message: 'photo library permission denied', + } satisfies HostBridgeError; + } + + let result: ImagePicker.ImagePickerResult; + try { + result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + allowsMultipleSelection: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + } catch (error) { + logMobileHostBridgeFileFailure('image.library_open', error); + throw { + code: 'host_error', + message: 'photo library unavailable', + } satisfies HostBridgeError; + } + + const payload = imagePickerResultToImportPayload(result, 'selected'); + if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file import size limit'); + } + return payload; +} + +export async function importMobileHostBridgeImageFile( + request: HostBridgeRequest, +) { + return ok(request, await importImageFile()); +} + +export async function captureImageFile(): Promise { + let permission: ImagePicker.CameraPermissionResponse; + try { + permission = await ImagePicker.requestCameraPermissionsAsync(); + } catch (error) { + logMobileHostBridgeFileFailure('image.camera_permission', error); + throw { + code: 'host_error', + message: 'camera permission unavailable', + } satisfies HostBridgeError; + } + if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { + throw { + code: 'host_error', + message: 'camera permission denied', + } satisfies HostBridgeError; + } + + let result: ImagePicker.ImagePickerResult; + try { + result = await ImagePicker.launchCameraAsync({ + allowsEditing: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + } catch (error) { + logMobileHostBridgeFileFailure('image.camera_open', error); + throw { + code: 'host_error', + message: 'camera unavailable', + } satisfies HostBridgeError; + } + + const payload = imagePickerResultToImportPayload(result, 'captured'); + if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file import size limit'); + } + return payload; +} + +export async function captureMobileHostBridgeImageFile( + request: HostBridgeRequest, +) { + return ok(request, await captureImageFile()); +} + +export async function exportAudioFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportAudioPayload | undefined; + const mimeType = exportPayload?.mimeType; + if ( + typeof mimeType !== 'string' || + !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType) + ) { + throw invalidRequest('mimeType must be an allowed audio type'); + } + + const base64Data = normalizedBase64Data(exportPayload?.base64Data); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES) { + throw invalidRequest('audio exceeds file export size limit'); + } + ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType); + + await assertMobileFileSharingAvailable(); + + const fileName = normalizeExportedAudioFileName( + exportPayload?.fileName, + mimeType as HostBridgeAudioMimeType, + ); + try { + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await shareMobileFile(file, { + mimeType, + UTI: 'public.audio', + dialogTitle: fileName, + }); + } catch (error) { + if (isHostBridgeFileSharingError(error)) { + throw error; + } + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } + + return { + action: 'saved', + fileName, + bytes, + }; +} + +export async function exportMobileHostBridgeAudioFile( + request: HostBridgeRequest, +) { + return ok(request, await exportAudioFile(request.payload)); +} + +export async function importAudioFile(): Promise { + const result = await pickMobileDocumentFile( + { + copyToCacheDirectory: true, + multiple: false, + type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES, + }, + 'audio picker unavailable', + ); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('audio file is required'); + } + + const fileName = normalizeHostBridgeImportFileName( + asset.name || 'genarrative-import-audio.webm', + ); + const mimeType = normalizeImportedAudioMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed audio type'); + } + + const file = new File(asset.uri); + assertImportedFileSizeWithinLimit( + asset.size, + file, + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + 'audio exceeds file import size limit', + ); + const base64Data = normalizedBase64Data( + await readMobileBase64File(file, 'audio file unavailable'), + ); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES) { + throw invalidRequest('audio exceeds file import size limit'); + } + ensureAudioBytesMatchMimeType(base64Data, mimeType); + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + +export async function importMobileHostBridgeAudioFile( + request: HostBridgeRequest, +) { + return ok(request, await importAudioFile()); +} + +function isHostBridgeFileSharingError(error: unknown): error is HostBridgeError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'host_error' && + 'message' in error && + error.message === 'file sharing failed' + ); +} + +async function pickMobileDocumentFile( + options: MobileDocumentPickerOptions, + unavailableMessage: string, +): Promise { + try { + return await DocumentPicker.getDocumentAsync(options); + } catch (error) { + logMobileHostBridgeFileFailure('document_picker.open', error); + throw { + code: 'host_error', + message: unavailableMessage, + } satisfies HostBridgeError; + } +} + +async function readMobileTextFile(file: File, unavailableMessage: string) { + try { + return await file.text(); + } catch (error) { + logMobileHostBridgeFileFailure('file.read_text', error); + throw { + code: 'host_error', + message: unavailableMessage, + } satisfies HostBridgeError; + } +} + +async function readMobileBase64File(file: File, unavailableMessage: string) { + try { + return await file.base64(); + } catch (error) { + logMobileHostBridgeFileFailure('file.read_base64', error); + throw { + code: 'host_error', + message: unavailableMessage, + } satisfies HostBridgeError; + } +} diff --git a/apps/mobile-shell/src/host-bridge/haptics.test.ts b/apps/mobile-shell/src/host-bridge/haptics.test.ts new file mode 100644 index 000000000..d4bc68663 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/haptics.test.ts @@ -0,0 +1,122 @@ +import * as Haptics from 'expo-haptics'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + runMobileHapticsImpact, + runMobileHostBridgeHapticsImpact, +} from './haptics'; + +vi.mock('expo-haptics', () => ({ + ImpactFeedbackStyle: { + Heavy: 'heavy', + Light: 'light', + Medium: 'medium', + }, + impactAsync: vi.fn(), +})); + +function request(payload?: unknown): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'haptics-request', + method: 'haptics.impact', + payload, + }; +} + +beforeEach(() => { + vi.mocked(Haptics.impactAsync).mockReset(); +}); + +describe('mobile haptics helpers', () => { + test('maps allowed impact styles to Expo Haptics styles', async () => { + await expect(runMobileHapticsImpact('light')).resolves.toBe(true); + expect(Haptics.impactAsync).toHaveBeenLastCalledWith( + Haptics.ImpactFeedbackStyle.Light, + ); + + await expect(runMobileHapticsImpact('medium')).resolves.toBe(true); + expect(Haptics.impactAsync).toHaveBeenLastCalledWith( + Haptics.ImpactFeedbackStyle.Medium, + ); + + await expect(runMobileHapticsImpact('heavy')).resolves.toBe(true); + expect(Haptics.impactAsync).toHaveBeenLastCalledWith( + Haptics.ImpactFeedbackStyle.Heavy, + ); + }); + + test('defaults missing style to light impact', async () => { + await expect(runMobileHapticsImpact(undefined)).resolves.toBe(true); + + expect(Haptics.impactAsync).toHaveBeenCalledWith( + Haptics.ImpactFeedbackStyle.Light, + ); + }); + + test('rejects unknown styles without triggering device feedback', async () => { + await expect(runMobileHapticsImpact('rigid')).resolves.toBe(false); + + expect(Haptics.impactAsync).not.toHaveBeenCalled(); + }); + + test('reports unavailable native feedback with a stable HostBridge error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('native haptics failed'); + vi.mocked(Haptics.impactAsync).mockRejectedValueOnce( + nativeError, + ); + + try { + await expect(runMobileHapticsImpact('light')).rejects.toMatchObject({ + code: 'host_error', + message: 'haptics impact unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile haptics failed for impact.dispatch', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + test('wraps HostBridge success response after real impact dispatch', async () => { + const response = await runMobileHostBridgeHapticsImpact( + request({ + style: 'medium', + }), + ); + + expect(response).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'haptics-request', + ok: true, + result: true, + }); + expect(Haptics.impactAsync).toHaveBeenCalledWith( + Haptics.ImpactFeedbackStyle.Medium, + ); + }); + + test('wraps HostBridge invalid request before device feedback', async () => { + await expect( + runMobileHostBridgeHapticsImpact( + request({ + style: 'rigid', + }), + ), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'haptics impact style must be light, medium, or heavy', + }); + + expect(Haptics.impactAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/haptics.ts b/apps/mobile-shell/src/host-bridge/haptics.ts new file mode 100644 index 000000000..eb10ead4a --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/haptics.ts @@ -0,0 +1,51 @@ +import * as Haptics from 'expo-haptics'; + +import { + type HapticsImpactPayload, + type HostBridgeError, + type HostBridgeRequest, + normalizeHostBridgeHapticsImpactStyle, + type HostBridgeHapticsImpactStyle, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest, ok } from './protocol'; + +function toExpoImpactStyle(style: HostBridgeHapticsImpactStyle) { + return style === 'heavy' + ? Haptics.ImpactFeedbackStyle.Heavy + : style === 'medium' + ? Haptics.ImpactFeedbackStyle.Medium + : Haptics.ImpactFeedbackStyle.Light; +} + +function logMobileHapticsFailure(label: string, _error: unknown) { + console.warn(`mobile haptics failed for ${label}`); +} + +export async function runMobileHapticsImpact(rawStyle: unknown) { + const style = normalizeHostBridgeHapticsImpactStyle(rawStyle); + if (!style) { + return false; + } + + try { + await Haptics.impactAsync(toExpoImpactStyle(style)); + } catch (error) { + logMobileHapticsFailure('impact.dispatch', error); + throw { + code: 'host_error', + message: 'haptics impact unavailable', + } satisfies HostBridgeError; + } + return true; +} + +export async function runMobileHostBridgeHapticsImpact( + request: HostBridgeRequest, +) { + const style = (request.payload as HapticsImpactPayload | undefined)?.style; + if (!(await runMobileHapticsImpact(style))) { + throw invalidRequest('haptics impact style must be light, medium, or heavy'); + } + + return ok(request, true); +} diff --git a/apps/mobile-shell/src/host-bridge/navigation.test.ts b/apps/mobile-shell/src/host-bridge/navigation.test.ts new file mode 100644 index 000000000..a102c876b --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/navigation.test.ts @@ -0,0 +1,229 @@ +import * as Linking from 'expo-linking'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import type { MobileHostBridgeNavigation } from './protocol'; +import { + openMobileHostBridgeExternalUrl, + openMobileHostBridgeNativePage, + reloadMobileHostBridgeWebView, +} from './navigation'; + +vi.mock('expo-linking', () => ({ + canOpenURL: vi.fn(), + openURL: vi.fn(), +})); + +function request(method: HostBridgeRequest['method'], payload?: unknown) { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: `${method}-request`, + method, + payload, + } satisfies HostBridgeRequest; +} + +function navigation(): MobileHostBridgeNavigation { + return { + allowedOrigin: 'https://www.genarrative.world', + urlOptions: { + platform: 'ios', + hostVersion: '0.1.0', + capabilities: ['navigation.openNativePage', 'app.reloadWebView'], + }, + openWebViewUrl: vi.fn(), + reloadWebView: vi.fn(), + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(Linking.canOpenURL).mockReset(); + vi.mocked(Linking.openURL).mockReset(); +}); + +describe('mobile HostBridge navigation helpers', () => { + test('opens allowed external URLs through Expo Linking', async () => { + vi.mocked(Linking.canOpenURL).mockResolvedValue(true); + + await expect( + openMobileHostBridgeExternalUrl( + request('app.openExternalUrl', { + url: ' https://example.com/path?from=native ', + }), + ), + ).resolves.toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'app.openExternalUrl-request', + ok: true, + result: true, + }); + expect(Linking.canOpenURL).toHaveBeenCalledWith( + 'https://example.com/path?from=native', + ); + expect(Linking.openURL).toHaveBeenCalledWith( + 'https://example.com/path?from=native', + ); + }); + + test.each([ + undefined, + {}, + { url: '/works/detail?work=PZ-1' }, + { url: 'javascript:alert(1)' }, + { url: 'https://example.com/\u0000' }, + ])('rejects unsafe external URL payloads: %o', async (payload) => { + await expect( + openMobileHostBridgeExternalUrl( + request('app.openExternalUrl', payload), + ), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'url must use an allowed external protocol', + }); + expect(Linking.canOpenURL).not.toHaveBeenCalled(); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + test('converts system external open failures to host_error', async () => { + vi.mocked(Linking.canOpenURL).mockResolvedValue(false); + + await expect( + openMobileHostBridgeExternalUrl( + request('app.openExternalUrl', { + url: 'mailto:hello@example.com', + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'external URL cannot be opened', + }); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + test('converts native external open exceptions to stable host_error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + vi.mocked(Linking.canOpenURL).mockRejectedValueOnce( + new Error('native canOpenURL failed'), + ); + + await expect( + openMobileHostBridgeExternalUrl( + request('app.openExternalUrl', { + url: 'https://example.com/path', + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'external URL cannot be opened', + }); + expect(Linking.openURL).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge navigation failed for external.open', + ); + + vi.mocked(Linking.canOpenURL).mockResolvedValueOnce(true); + vi.mocked(Linking.openURL).mockRejectedValueOnce( + new Error('native openURL failed'), + ); + + await expect( + openMobileHostBridgeExternalUrl( + request('app.openExternalUrl', { + url: 'https://example.com/path', + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'external URL cannot be opened', + }); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + test('opens same-origin native page targets in the WebView with host context', () => { + const nav = navigation(); + + expect( + openMobileHostBridgeNativePage( + request('navigation.openNativePage', { + url: '/works/detail?work=PZ-1#play', + }), + nav, + ), + ).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'navigation.openNativePage-request', + ok: true, + result: true, + }); + expect(nav.openWebViewUrl).toHaveBeenCalledTimes(1); + const [targetUrl] = vi.mocked(nav.openWebViewUrl).mock.calls[0] ?? []; + expect(targetUrl).toBeTypeOf('string'); + if (typeof targetUrl !== 'string') { + throw new Error('navigation.openNativePage did not open a WebView URL'); + } + const target = new URL(targetUrl); + expect(target.origin).toBe('https://www.genarrative.world'); + expect(target.pathname).toBe('/works/detail'); + expect(target.searchParams.get('work')).toBe('PZ-1'); + expect(target.hash).toBe('#play'); + expect(target.searchParams.get('clientType')).toBe('native_app'); + expect(target.searchParams.get('hostShell')).toBe('expo_mobile'); + expect(target.searchParams.get('hostPlatform')).toBe('ios'); + expect(target.searchParams.get('hostCapabilities')).toBe( + 'navigation.openNativePage,app.reloadWebView', + ); + }); + + test.each([ + undefined, + { url: 'https://example.com/evil' }, + { url: 'javascript:alert(1)' }, + ])('rejects unsafe native page targets: %o', (payload) => { + const nav = navigation(); + + expect(() => + openMobileHostBridgeNativePage( + request('navigation.openNativePage', payload), + nav, + ), + ).toThrowError(); + expect(nav.openWebViewUrl).not.toHaveBeenCalled(); + }); + + test('requires navigation adapter for native page and reload requests', () => { + expect(() => + openMobileHostBridgeNativePage( + request('navigation.openNativePage', { + url: '/works/detail?work=PZ-1', + }), + null, + ), + ).toThrowError('navigation.openNativePage unsupported in mobile shell'); + expect(() => + reloadMobileHostBridgeWebView(request('app.reloadWebView'), null), + ).toThrowError('app.reloadWebView unsupported in mobile shell'); + }); + + test('reloads the WebView through the navigation adapter', () => { + const nav = navigation(); + + expect(reloadMobileHostBridgeWebView(request('app.reloadWebView'), nav)) + .toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'app.reloadWebView-request', + ok: true, + result: true, + }); + expect(nav.reloadWebView).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/navigation.ts b/apps/mobile-shell/src/host-bridge/navigation.ts new file mode 100644 index 000000000..12ece029c --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/navigation.ts @@ -0,0 +1,98 @@ +import * as Linking from 'expo-linking'; + +import { + type HostBridgeError, + type HostBridgeRequest, + type NavigateNativePagePayload, + normalizeHostBridgeExternalUrlPayload, + type OpenExternalUrlPayload, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + type MobileShellExternalNavigator, + openMobileShellExternalNavigation, + resolveMobileShellWebViewUrl, +} from '../shell/navigation'; +import { buildMobileShellUrl } from '../shell/url'; +import { + type MobileHostBridgeNavigation, + invalidRequest, + ok, + unsupported, +} from './protocol'; + +function logMobileHostBridgeNavigationFailure(label: string, _error: unknown) { + console.warn(`mobile HostBridge navigation failed for ${label}`); +} + +export async function openMobileHostBridgeExternalUrl( + request: HostBridgeRequest, +) { + const externalUrlPayload = normalizeHostBridgeExternalUrlPayload( + (request.payload as OpenExternalUrlPayload | undefined)?.url, + ); + if (!externalUrlPayload) { + throw invalidRequest('url must use an allowed external protocol'); + } + + let opened = false; + try { + opened = await openMobileShellExternalNavigation( + Linking, + externalUrlPayload.url, + ); + } catch (error) { + logMobileHostBridgeNavigationFailure('external.open', error); + opened = false; + } + if (!opened) { + throw { + code: 'host_error', + message: 'external URL cannot be opened', + } satisfies HostBridgeError; + } + + return ok(request, true); +} + +export function openMobileHostBridgeNativePage( + request: HostBridgeRequest, + navigation: MobileHostBridgeNavigation | null, +) { + if (!navigation) { + throw unsupported('navigation.openNativePage'); + } + + const url = (request.payload as NavigateNativePagePayload | undefined)?.url; + if (typeof url !== 'string') { + throw invalidRequest('url is required'); + } + + const webViewUrl = resolveMobileShellWebViewUrl( + url, + navigation.allowedOrigin, + ); + if (!webViewUrl) { + throw invalidRequest('url must be an allowed same-origin web path'); + } + + navigation.openWebViewUrl( + buildMobileShellUrl( + webViewUrl, + navigation.urlOptions, + navigation.baseWebUrlOptions, + ), + ); + return ok(request, true); +} + +export function reloadMobileHostBridgeWebView( + request: HostBridgeRequest, + navigation: MobileHostBridgeNavigation | null, +) { + if (!navigation) { + throw unsupported('app.reloadWebView'); + } + + navigation.reloadWebView(); + return ok(request, true); +} diff --git a/apps/mobile-shell/src/host-bridge/network.test.ts b/apps/mobile-shell/src/host-bridge/network.test.ts new file mode 100644 index 000000000..12d621d97 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/network.test.ts @@ -0,0 +1,99 @@ +import * as Network from 'expo-network'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { getMobileHostBridgeNetworkStatus } from './network'; + +vi.mock('expo-network', () => ({ + getNetworkStateAsync: vi.fn(), + NetworkStateType: { + CELLULAR: 'CELLULAR', + NONE: 'NONE', + WIFI: 'WIFI', + }, +})); + +function request(): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request', + method: 'network.status', + }; +} + +beforeEach(() => { + vi.mocked(Network.getNetworkStateAsync).mockReset(); +}); + +describe('mobile HostBridge network helper', () => { + test('wraps Expo Network status in the HostBridge response shape', async () => { + vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ + type: Network.NetworkStateType.CELLULAR, + isConnected: true, + isInternetReachable: false, + }); + + await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request', + ok: true, + result: { + isConnected: true, + isInternetReachable: false, + connectionType: 'cellular', + nativeType: 'CELLULAR', + }, + }); + }); + + test('normalizes disconnected network state before wrapping response', async () => { + vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ + type: Network.NetworkStateType.NONE, + }); + + await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request', + ok: true, + result: { + isConnected: false, + isInternetReachable: null, + connectionType: 'none', + nativeType: 'NONE', + }, + }); + }); + + test('converts native network failures to a stable host_error response', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('network query failed'); + vi.mocked(Network.getNetworkStateAsync).mockRejectedValue( + nativeError, + ); + + try { + await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request', + ok: false, + error: { + code: 'host_error', + message: 'network status unavailable', + }, + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile network failed for status.query', + ); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/network.ts b/apps/mobile-shell/src/host-bridge/network.ts new file mode 100644 index 000000000..c87f6f8f1 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/network.ts @@ -0,0 +1,21 @@ +import { getMobileNetworkStatus } from '../shell/network'; +import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge'; +import { failure, ok } from './protocol'; + +function logMobileNetworkFailure(label: string, _error: unknown) { + console.warn(`mobile network failed for ${label}`); +} + +export async function getMobileHostBridgeNetworkStatus( + request: HostBridgeRequest, +) { + try { + return ok(request, await getMobileNetworkStatus()); + } catch (error) { + logMobileNetworkFailure('status.query', error); + return failure(request, { + code: 'host_error', + message: 'network status unavailable', + }); + } +} diff --git a/apps/mobile-shell/src/host-bridge/notifications.test.ts b/apps/mobile-shell/src/host-bridge/notifications.test.ts new file mode 100644 index 000000000..3d6f60812 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/notifications.test.ts @@ -0,0 +1,313 @@ +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + showMobileHostBridgeLocalNotification, + showMobileLocalNotification, +} from './notifications'; + +type NotificationPermissionStatus = + Awaited>; + +const GRANTED_NOTIFICATION_PERMISSION = { + status: 'granted', + granted: true, + canAskAgain: true, + expires: 'never', +} as NotificationPermissionStatus; + +const PROVISIONAL_NOTIFICATION_PERMISSION = { + status: 'undetermined', + granted: false, + canAskAgain: true, + expires: 'never', + ios: { + status: 'provisional', + }, +} as unknown as NotificationPermissionStatus; + +const DENIED_NOTIFICATION_PERMISSION = { + status: 'denied', + granted: false, + canAskAgain: false, + expires: 'never', +} as NotificationPermissionStatus; + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { + DEFAULT: 'default', + }, + IosAuthorizationStatus: { + PROVISIONAL: 'provisional', + }, + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + setNotificationChannelAsync: vi.fn(), + setNotificationHandler: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})); + +function request(payload?: unknown): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'notification-request', + method: 'notification.showLocal', + payload, + }; +} + +function setPlatformOS(os: 'ios' | 'android') { + (Platform as { OS: 'ios' | 'android' }).OS = os; +} + +beforeEach(() => { + vi.mocked(Notifications.getPermissionsAsync).mockReset(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockReset(); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.scheduleNotificationAsync).mockReset(); + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue( + 'notification-1', + ); + vi.mocked(Notifications.setNotificationChannelAsync).mockReset(); + vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null); + setPlatformOS('ios'); +}); + +describe('mobile local notification helpers', () => { + test('uses existing granted or provisional permission without prompting', async () => { + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).resolves.toEqual(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT); + + expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce( + PROVISIONAL_NOTIFICATION_PERMISSION, + ); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).resolves.toEqual(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT); + + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('requests alert-only permission when current permission is missing', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).resolves.toEqual(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT); + + expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ + ios: { + allowAlert: true, + allowBadge: false, + allowSound: false, + }, + }); + }); + + test('rejects delivery when permission remains denied', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification permission denied', + }); + + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); + + test('rejects delivery when current permission lookup is unavailable', async () => { + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification permission unavailable', + }); + + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for permission.current', + ); + warnSpy.mockRestore(); + }); + + test('rejects delivery when permission request is unavailable', async () => { + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(error); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification permission unavailable', + }); + + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for permission.request', + ); + warnSpy.mockRestore(); + }); + + test('schedules iOS notification without channel trigger', async () => { + await showMobileLocalNotification({ + title: '生成完成', + body: '作品已准备好', + }); + + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ + content: { + title: '生成完成', + body: '作品已准备好', + }, + trigger: null, + }); + }); + + test('uses fixed Android local notification channel', async () => { + setPlatformOS('android'); + + await showMobileLocalNotification({ title: '生成完成' }); + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + { + name: 'Genarrative', + importance: Notifications.AndroidImportance.DEFAULT, + }, + ); + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ + content: { + title: '生成完成', + }, + trigger: { + channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + }, + }); + }); + + test('maps Android channel setup failures to stable delivery errors', async () => { + const error = new Error('native channel failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + setPlatformOS('android'); + vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce(error); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification delivery unavailable', + }); + + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for delivery.schedule', + ); + warnSpy.mockRestore(); + }); + + test('maps native notification schedule failures to stable delivery errors', async () => { + const error = new Error('native schedule failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(error); + + await expect( + showMobileLocalNotification({ title: '生成完成' }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'notification delivery unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for delivery.schedule', + ); + warnSpy.mockRestore(); + }); + + test('normalizes HostBridge payload and wraps structured delivery result', async () => { + const response = await showMobileHostBridgeLocalNotification( + request({ + title: ' 生成完成 ', + body: ' 作品已准备好 可以试玩 ', + }), + ); + + expect(response).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'notification-request', + ok: true, + result: HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, + }); + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ + content: { + title: '生成完成', + body: '作品已准备好 可以试玩', + }, + trigger: null, + }); + }); + + test('rejects invalid HostBridge notification payload before system calls', async () => { + await expect( + showMobileHostBridgeLocalNotification( + request({ + title: '生成\n完成', + }), + ), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'title is required', + }); + + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/notifications.ts b/apps/mobile-shell/src/host-bridge/notifications.ts new file mode 100644 index 000000000..0892dc184 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/notifications.ts @@ -0,0 +1,120 @@ +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +import { + HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + type HostBridgeError, + type HostBridgeRequest, + type LocalNotificationPayload, + normalizeHostBridgeLocalNotification, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest, ok } from './protocol'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), +}); + +function logMobileNotificationFailure(label: string, _error: unknown) { + console.warn(`mobile notification failed for ${label}`); +} + +function hasNotificationPermission( + permission: Awaited>, +) { + return ( + permission.granted || + permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL + ); +} + +async function ensureNotificationPermission() { + let currentPermission: Awaited< + ReturnType + >; + try { + currentPermission = await Notifications.getPermissionsAsync(); + } catch (error) { + logMobileNotificationFailure('permission.current', error); + throw { + code: 'host_error', + message: 'notification permission unavailable', + } satisfies HostBridgeError; + } + if (hasNotificationPermission(currentPermission)) { + return; + } + + let requestedPermission: Awaited< + ReturnType + >; + try { + requestedPermission = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: false, + allowSound: false, + }, + }); + } catch (error) { + logMobileNotificationFailure('permission.request', error); + throw { + code: 'host_error', + message: 'notification permission unavailable', + } satisfies HostBridgeError; + } + if (!hasNotificationPermission(requestedPermission)) { + throw { + code: 'host_error', + message: 'notification permission denied', + } satisfies HostBridgeError; + } +} + +export async function showMobileLocalNotification( + notification: LocalNotificationPayload, +) { + await ensureNotificationPermission(); + try { + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync( + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + { + name: 'Genarrative', + importance: Notifications.AndroidImportance.DEFAULT, + }, + ); + } + + await Notifications.scheduleNotificationAsync({ + content: notification, + trigger: + Platform.OS === 'android' + ? { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID } + : null, + }); + } catch (error) { + logMobileNotificationFailure('delivery.schedule', error); + throw { + code: 'host_error', + message: 'notification delivery unavailable', + } satisfies HostBridgeError; + } + return HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT; +} + +export async function showMobileHostBridgeLocalNotification( + request: HostBridgeRequest, +) { + const notification = normalizeHostBridgeLocalNotification(request.payload); + if (!notification) { + throw invalidRequest('title is required'); + } + + return ok(request, await showMobileLocalNotification(notification)); +} diff --git a/apps/mobile-shell/src/host-bridge/protocol.test.ts b/apps/mobile-shell/src/host-bridge/protocol.test.ts new file mode 100644 index 000000000..68feed41c --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/protocol.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + failure, + invalidRequest, + isHostBridgeRequest, + normalizeMobileHostBridgeError, + ok, + parseRequest, + unsupported, +} from './protocol'; + +function request(overrides: Partial = {}): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request-1', + method: 'host.getRuntime', + ...overrides, + }; +} + +describe('mobile HostBridge protocol helpers', () => { + test('parses JSON messages and drops malformed payloads before validation', () => { + expect(parseRequest(JSON.stringify(request()))).toEqual(request()); + expect(parseRequest('{')).toBeNull(); + }); + + test('accepts only valid HostBridge envelopes and request ids', () => { + expect(isHostBridgeRequest(request())).toBe(true); + expect(isHostBridgeRequest(request({ id: ' id-with-space ' }))).toBe(true); + + for (const candidate of [ + null, + {}, + { ...request(), bridge: 'wrong-bridge' }, + { ...request(), version: HOST_BRIDGE_VERSION + 1 }, + request({ id: '' }), + request({ id: 'bad\u0000id' }), + request({ id: 'x'.repeat(129) }), + request({ method: 'unknown.method' as HostBridgeRequest['method'] }), + ]) { + expect(isHostBridgeRequest(candidate)).toBe(false); + } + }); + + test('wraps successful and failed responses in the shared response envelope', () => { + expect(ok(request(), { shell: 'expo_mobile' })).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request-1', + ok: true, + result: { shell: 'expo_mobile' }, + }); + expect(failure(request(), invalidRequest('bad request'))).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request-1', + ok: false, + error: { + code: 'invalid_request', + message: 'bad request', + }, + }); + }); + + test('keeps unsupported and invalid request errors explicit', () => { + expect(unsupported('payment.request')).toEqual({ + code: 'unsupported_method', + message: 'payment.request unsupported in mobile shell', + }); + expect(invalidRequest('url is required')).toEqual({ + code: 'invalid_request', + message: 'url is required', + }); + }); + + test('passes through only allowed HostBridge errors from native helpers', () => { + expect( + normalizeMobileHostBridgeError({ + code: 'cancelled', + message: 'qr scan cancelled', + nativeStack: 'hidden', + }), + ).toEqual({ + code: 'cancelled', + message: 'qr scan cancelled', + }); + }); + + test('normalizes unsafe native errors to host_error without leaking fields', () => { + expect( + normalizeMobileHostBridgeError({ + code: 'private_error', + message: 'do not expose', + detail: 'native secret', + }), + ).toEqual({ + code: 'host_error', + message: 'mobile host bridge request failed', + }); + expect(normalizeMobileHostBridgeError(new Error('disk failed'))).toEqual({ + code: 'host_error', + message: 'mobile host bridge request failed', + }); + expect(normalizeMobileHostBridgeError('boom')).toEqual({ + code: 'host_error', + message: 'mobile host bridge request failed', + }); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/protocol.ts b/apps/mobile-shell/src/host-bridge/protocol.ts new file mode 100644 index 000000000..73443bc48 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/protocol.ts @@ -0,0 +1,116 @@ +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeError, + type HostBridgeMethod, + type HostBridgeRequest, + type HostBridgeResponse, + isHostBridgeMethod, + normalizeHostBridgeRequestId, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import type { + MobileShellBaseWebUrlOptions, + MobileShellUrlOptions, +} from '../shell/url'; + +const HOST_BRIDGE_ERROR_CODES = new Set([ + 'invalid_request', + 'unsupported_method', + 'unsupported_capability', + 'timeout', + 'cancelled', + 'host_error', +]); + +export type MobileHostBridgeNavigation = { + allowedOrigin: string; + urlOptions: MobileShellUrlOptions; + baseWebUrlOptions?: MobileShellBaseWebUrlOptions; + openWebViewUrl: (url: string) => void; + reloadWebView: () => void; +}; + +export function unsupported(method: HostBridgeMethod): HostBridgeError { + return { + code: 'unsupported_method', + message: `${method} unsupported in mobile shell`, + }; +} + +export function invalidRequest(message: string): HostBridgeError { + return { + code: 'invalid_request', + message, + }; +} + +export function isHostBridgeRequest(value: unknown): value is HostBridgeRequest { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + const requestId = normalizeHostBridgeRequestId(candidate.id); + return ( + candidate.bridge === HOST_BRIDGE_PROTOCOL && + candidate.version === HOST_BRIDGE_VERSION && + requestId !== null && + isHostBridgeMethod(candidate.method) + ); +} + +export function parseRequest(raw: string) { + try { + return JSON.parse(raw) as unknown; + } catch { + return null; + } +} + +export function ok( + request: HostBridgeRequest, + result?: Result, +): HostBridgeResponse { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: request.id, + ok: true, + result, + }; +} + +export function failure( + request: Pick, + error: HostBridgeError, +): HostBridgeResponse { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: request.id, + ok: false, + error, + }; +} + +export function normalizeMobileHostBridgeError(error: unknown): HostBridgeError { + if ( + error && + typeof error === 'object' && + 'code' in error && + 'message' in error && + typeof error.code === 'string' && + HOST_BRIDGE_ERROR_CODES.has(error.code as HostBridgeError['code']) && + typeof error.message === 'string' + ) { + return { + code: error.code as HostBridgeError['code'], + message: error.message, + }; + } + + return { + code: 'host_error', + message: 'mobile host bridge request failed', + }; +} diff --git a/apps/mobile-shell/src/host-bridge/runtime.test.ts b/apps/mobile-shell/src/host-bridge/runtime.test.ts new file mode 100644 index 000000000..62d909ba1 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/runtime.test.ts @@ -0,0 +1,87 @@ +import { Platform } from 'react-native'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime'; +import { + getMobileHostBridgeRuntime, + getMobileHostBridgeRuntimeResponse, + getMobileRuntimePlatform, +} from './runtime'; + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})); + +function request(): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'runtime-request', + method: 'host.getRuntime', + }; +} + +function setPlatformOS(os: 'ios' | 'android') { + (Platform as { OS: 'ios' | 'android' }).OS = os; +} + +beforeEach(() => { + setPlatformOS('ios'); +}); + +describe('mobile HostBridge runtime helper', () => { + test('reports iOS runtime with host version, bridge version and iOS capabilities', () => { + expect(getMobileRuntimePlatform()).toBe('ios'); + + const runtime = getMobileHostBridgeRuntime(); + + expect(runtime).toEqual( + expect.objectContaining({ + shell: 'expo_mobile', + platform: 'ios', + hostVersion: MOBILE_SHELL_HOST_VERSION, + bridgeVersion: HOST_BRIDGE_VERSION, + }), + ); + expect(runtime.capabilities).toBe(HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES); + }); + + test('reports Android runtime without iOS-only app badge capability', () => { + setPlatformOS('android'); + + const runtime = getMobileHostBridgeRuntime(); + + expect(getMobileRuntimePlatform()).toBe('android'); + expect(runtime).toEqual( + expect.objectContaining({ + shell: 'expo_mobile', + platform: 'android', + hostVersion: MOBILE_SHELL_HOST_VERSION, + bridgeVersion: HOST_BRIDGE_VERSION, + }), + ); + expect(runtime.capabilities).toBe(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES); + expect(runtime.capabilities).not.toContain('app.setBadgeCount'); + }); + + test('wraps runtime metadata in the HostBridge response shape', () => { + const response = getMobileHostBridgeRuntimeResponse(request()); + + expect(response).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'runtime-request', + ok: true, + result: getMobileHostBridgeRuntime(), + }); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/runtime.ts b/apps/mobile-shell/src/host-bridge/runtime.ts new file mode 100644 index 000000000..0c5a8bc62 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/runtime.ts @@ -0,0 +1,29 @@ +import { Platform } from 'react-native'; + +import { + HOST_BRIDGE_VERSION, + type HostBridgeRequest, + type HostBridgeRuntimeResult, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime'; +import { resolveMobileHostCapabilities } from './capabilities'; +import { ok } from './protocol'; + +export function getMobileRuntimePlatform() { + return Platform.OS === 'ios' ? 'ios' : 'android'; +} + +export function getMobileHostBridgeRuntime(): HostBridgeRuntimeResult { + const platform = getMobileRuntimePlatform(); + return { + shell: 'expo_mobile', + platform, + hostVersion: MOBILE_SHELL_HOST_VERSION, + bridgeVersion: HOST_BRIDGE_VERSION, + capabilities: resolveMobileHostCapabilities(platform), + }; +} + +export function getMobileHostBridgeRuntimeResponse(request: HostBridgeRequest) { + return ok(request, getMobileHostBridgeRuntime()); +} diff --git a/apps/mobile-shell/src/host-bridge/scanner.test.ts b/apps/mobile-shell/src/host-bridge/scanner.test.ts new file mode 100644 index 000000000..ed5573aed --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/scanner.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_SCANNER_TIMEOUT_MS, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + cancelQrCodeScan, + completeQrCodeScan, + failQrCodeScan, + resetQrScannerForTest, + scanMobileHostBridgeQrCode, + scanQrCode, + subscribeQrScannerState, +} from './scanner'; + +function request(): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'scanner-request', + method: 'scanner.scanQrCode', + }; +} + +afterEach(() => { + resetQrScannerForTest(); +}); + +describe('mobile QR scanner helpers', () => { + test('notifies subscribers when a scan starts and clears', async () => { + const listener = vi.fn(); + const unsubscribe = subscribeQrScannerState(listener); + + const pendingScan = scanQrCode(); + expect(listener).toHaveBeenNthCalledWith(1, { + active: false, + requestKey: 0, + }); + expect(listener).toHaveBeenNthCalledWith(2, { + active: true, + requestKey: 1, + }); + + expect(completeQrCodeScan(' https://www.genarrative.world/w/PZ-1 ')) + .toBe(true); + await expect(pendingScan).resolves.toEqual({ + value: 'https://www.genarrative.world/w/PZ-1', + format: 'qr_code', + }); + expect(listener).toHaveBeenNthCalledWith(3, { + active: false, + requestKey: 0, + }); + + unsubscribe(); + const secondScan = scanQrCode(); + expect(listener).toHaveBeenCalledTimes(3); + expect(completeQrCodeScan('PZ-2')).toBe(true); + await expect(secondScan).resolves.toEqual({ + value: 'PZ-2', + format: 'qr_code', + }); + }); + + test('rejects a second scan while the scanner is already active', async () => { + const pendingScan = scanQrCode(); + + await expect(scanQrCode()).rejects.toEqual({ + code: 'host_error', + message: 'qr scanner already active', + }); + expect(completeQrCodeScan('PZ-1')).toBe(true); + await expect(pendingScan).resolves.toEqual({ + value: 'PZ-1', + format: 'qr_code', + }); + }); + + test('keeps the pending scan active when completion payload is invalid', async () => { + const listener = vi.fn(); + subscribeQrScannerState(listener); + const pendingScan = scanQrCode(); + + expect(completeQrCodeScan('')).toBe(false); + expect(listener).toHaveBeenLastCalledWith({ + active: true, + requestKey: 1, + }); + + expect(completeQrCodeScan('valid-code')).toBe(true); + await expect(pendingScan).resolves.toEqual({ + value: 'valid-code', + format: 'qr_code', + }); + }); + + test('cancels the pending scan with the shared cancelled error', async () => { + const listener = vi.fn(); + subscribeQrScannerState(listener); + const pendingScan = scanQrCode(); + + cancelQrCodeScan(); + + await expect(pendingScan).rejects.toEqual({ + code: 'cancelled', + message: 'qr scan cancelled', + }); + expect(listener).toHaveBeenLastCalledWith({ + active: false, + requestKey: 0, + }); + }); + + test('fails the pending scan with a stable host_error message', async () => { + const pendingScan = scanQrCode(); + + failQrCodeScan(); + + await expect(pendingScan).rejects.toEqual({ + code: 'host_error', + message: 'qr scanner unavailable', + }); + }); + + test('times out and clears the pending scan with the shared scanner timeout', async () => { + vi.useFakeTimers(); + const listener = vi.fn(); + subscribeQrScannerState(listener); + const pendingScan = scanQrCode(); + + vi.advanceTimersByTime(HOST_BRIDGE_SCANNER_TIMEOUT_MS); + + await expect(pendingScan).rejects.toEqual({ + code: 'timeout', + message: 'qr scan timed out', + }); + expect(listener).toHaveBeenLastCalledWith({ + active: false, + requestKey: 0, + }); + + const nextScan = scanQrCode(); + expect(completeQrCodeScan('PZ-after-timeout')).toBe(true); + await expect(nextScan).resolves.toEqual({ + value: 'PZ-after-timeout', + format: 'qr_code', + }); + vi.useRealTimers(); + }); + + test('ignores completion, cancellation and failure without a pending scan', () => { + expect(completeQrCodeScan('PZ-1')).toBe(false); + expect(() => cancelQrCodeScan()).not.toThrow(); + expect(() => failQrCodeScan()).not.toThrow(); + }); + + test('wraps the scanner result in the HostBridge response shape', async () => { + const pendingResponse = scanMobileHostBridgeQrCode(request()); + + expect(completeQrCodeScan('PZ-00000001')).toBe(true); + + await expect(pendingResponse).resolves.toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'scanner-request', + ok: true, + result: { + value: 'PZ-00000001', + format: 'qr_code', + }, + }); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/scanner.ts b/apps/mobile-shell/src/host-bridge/scanner.ts new file mode 100644 index 000000000..b80cbdfa6 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/scanner.ts @@ -0,0 +1,133 @@ +import { + HOST_BRIDGE_SCANNER_TIMEOUT_MS, + type HostBridgeError, + type HostBridgeRequest, + type ScannerScanQrCodeResult, + normalizeHostBridgeQrCodeValue, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { ok } from './protocol'; + +type QrScannerState = { + active: boolean; + requestKey: number; +}; + +type PendingQrScan = { + requestKey: number; + resolve: (result: ScannerScanQrCodeResult) => void; + reject: (error: HostBridgeError) => void; + timeout: ReturnType; +}; + +const scannerListeners = new Set<(state: QrScannerState) => void>(); + +let pendingQrScan: PendingQrScan | null = null; +let nextQrScanRequestKey = 0; + +function hostBridgeError( + code: HostBridgeError['code'], + message: string, +): HostBridgeError { + return { code, message }; +} + +function currentQrScannerState(): QrScannerState { + return { + active: Boolean(pendingQrScan), + requestKey: pendingQrScan?.requestKey ?? 0, + }; +} + +function emitQrScannerState() { + const state = currentQrScannerState(); + for (const listener of scannerListeners) { + listener(state); + } +} + +function clearPendingQrScan() { + if (pendingQrScan) { + clearTimeout(pendingQrScan.timeout); + } + pendingQrScan = null; + emitQrScannerState(); +} + +export function subscribeQrScannerState( + listener: (state: QrScannerState) => void, +) { + scannerListeners.add(listener); + listener(currentQrScannerState()); + + return () => { + scannerListeners.delete(listener); + }; +} + +export function scanQrCode(): Promise { + if (pendingQrScan) { + return Promise.reject( + hostBridgeError('host_error', 'qr scanner already active'), + ); + } + + nextQrScanRequestKey += 1; + return new Promise((resolve, reject) => { + const requestKey = nextQrScanRequestKey; + pendingQrScan = { + requestKey, + resolve, + reject, + timeout: setTimeout(() => { + if (pendingQrScan?.requestKey !== requestKey) { + return; + } + pendingQrScan.reject(hostBridgeError('timeout', 'qr scan timed out')); + clearPendingQrScan(); + }, HOST_BRIDGE_SCANNER_TIMEOUT_MS), + }; + emitQrScannerState(); + }); +} + +export async function scanMobileHostBridgeQrCode(request: HostBridgeRequest) { + return ok(request, await scanQrCode()); +} + +export function completeQrCodeScan(rawValue: unknown) { + const result = normalizeHostBridgeQrCodeValue(rawValue); + if (!result || !pendingQrScan) { + return false; + } + + pendingQrScan.resolve(result); + clearPendingQrScan(); + return true; +} + +export function cancelQrCodeScan() { + if (!pendingQrScan) { + return; + } + + pendingQrScan.reject(hostBridgeError('cancelled', 'qr scan cancelled')); + clearPendingQrScan(); +} + +export function failQrCodeScan() { + if (!pendingQrScan) { + return; + } + + pendingQrScan.reject(hostBridgeError('host_error', 'qr scanner unavailable')); + clearPendingQrScan(); +} + +export function resetQrScannerForTest() { + if (pendingQrScan) { + clearTimeout(pendingQrScan.timeout); + } + pendingQrScan = null; + nextQrScanRequestKey = 0; + scannerListeners.clear(); +} diff --git a/apps/mobile-shell/src/host-bridge/share.test.ts b/apps/mobile-shell/src/host-bridge/share.test.ts new file mode 100644 index 000000000..dc5e90151 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/share.test.ts @@ -0,0 +1,265 @@ +import { Share } from 'react-native'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + openShare, + resetMobileHostBridgeShareTargetForTest, + setMobileHostBridgeShareTarget, +} from './share'; + +vi.mock('react-native', () => ({ + Share: { + share: vi.fn(), + }, +})); + +function request( + method: 'share.open' | 'share.setTarget', + payload?: unknown, +): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: `${method}-request`, + method, + payload, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(Share.share).mockReset(); + resetMobileHostBridgeShareTargetForTest(); +}); + +describe('mobile share helpers', () => { + test('opens the native share sheet with normalized explicit payload', async () => { + const response = await openShare( + request('share.open', { + title: '测试作品', + message: '来玩这个作品', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }), + ); + + expect(response).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'share.open-request', + ok: true, + result: true, + }); + expect(Share.share).toHaveBeenCalledWith({ + title: '测试作品', + message: + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }); + }); + + test('uses cached work target when share.open has no explicit payload', async () => { + const targetResponse = setMobileHostBridgeShareTarget( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: '暖灯猫街', + message: '来玩这个作品', + work: 'PZ-00000001', + }, + }, + }), + ); + + expect(targetResponse).toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'share.setTarget-request', + ok: true, + result: true, + }); + + await expect(openShare(request('share.open'))).resolves.toMatchObject({ + ok: true, + result: true, + }); + expect(Share.share).toHaveBeenCalledWith({ + title: '暖灯猫街', + message: + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + }); + }); + + test('stores only the normalized cached share payload', async () => { + setMobileHostBridgeShareTarget( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: ' 暖灯猫街 ', + message: ' 来玩这个作品 ', + work: 'PZ-00000001', + privateDraftId: 'draft-1', + }, + }, + }), + ); + + await expect(openShare(request('share.open'))).resolves.toMatchObject({ + ok: true, + result: true, + }); + expect(Share.share).toHaveBeenCalledWith({ + title: '暖灯猫街', + message: + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + }); + }); + + test('keeps the previous cached target when a new target is missing or invalid', async () => { + setMobileHostBridgeShareTarget( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: '暖灯猫街', + work: 'PZ-00000001', + }, + }, + }), + ); + + expect(() => setMobileHostBridgeShareTarget(request('share.setTarget', {}))) + .toThrowError('target is required'); + expect(() => + setMobileHostBridgeShareTarget( + request('share.setTarget', { + target: {}, + }), + ), + ).toThrowError('share target is required'); + expect(() => + setMobileHostBridgeShareTarget( + request('share.setTarget', { + target: { + title: '危险作品', + url: 'https://example.com/works/detail?work=PZ-1', + }, + }), + ), + ).toThrowError('share target is invalid'); + + await expect(openShare(request('share.open'))).resolves.toMatchObject({ + ok: true, + result: true, + }); + expect(Share.share).toHaveBeenCalledWith({ + title: '暖灯猫街', + message: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + }); + }); + + test('normalizes same-origin paths into public share URLs', async () => { + await expect( + openShare( + request('share.open', { + title: '测试作品', + path: '/works/detail?work=PZ-2#play', + }), + ), + ).resolves.toMatchObject({ + ok: true, + result: true, + }); + + expect(Share.share).toHaveBeenCalledWith({ + title: '测试作品', + message: 'https://www.genarrative.world/works/detail?work=PZ-2#play', + url: 'https://www.genarrative.world/works/detail?work=PZ-2#play', + }); + }); + + test('maps native share sheet failures to stable host errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const nativeError = new Error('native share failed'); + vi.mocked(Share.share).mockRejectedValueOnce(nativeError); + + await expect( + openShare( + request('share.open', { + title: '测试作品', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'share unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile share failed for open.share', + ); + }); + + test.each([ + 'https://example.com/works/detail?work=PZ-1', + '//example.com/works/detail?work=PZ-1', + '//www.genarrative.world/works/detail?work=PZ-1', + 'javascript:alert(1)', + ])('rejects unsafe explicit share URLs: %s', async (url) => { + await expect( + openShare( + request('share.open', { + title: '测试作品', + url, + }), + ), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'share target is invalid', + }); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('does not fall back to cached target when explicit payload is unsafe', async () => { + setMobileHostBridgeShareTarget( + request('share.setTarget', { + target: { + type: 'genarrative:share-target', + payload: { + title: '暖灯猫街', + work: 'PZ-00000001', + }, + }, + }), + ); + + await expect( + openShare( + request('share.open', { + title: '测试作品', + url: 'https://example.com/works/detail?work=PZ-1', + }), + ), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'share target is invalid', + }); + expect(Share.share).not.toHaveBeenCalled(); + }); + + test('rejects empty share requests before opening native share sheet', async () => { + await expect(openShare(request('share.open', {}))).rejects.toMatchObject({ + code: 'invalid_request', + message: 'share target is required', + }); + expect(Share.share).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile-shell/src/host-bridge/share.ts b/apps/mobile-shell/src/host-bridge/share.ts new file mode 100644 index 000000000..291253934 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/share.ts @@ -0,0 +1,80 @@ +import { Share } from 'react-native'; + +import { + type HostBridgeError, + type HostBridgeRequest, + normalizeHostBridgeShareOpenPayload, + type ShareOpenPayload, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest, ok } from './protocol'; + +let currentShareTarget: ShareOpenPayload | null = null; + +function logMobileShareFailure(label: string, _error: unknown) { + console.warn(`mobile share failed for ${label}`); +} + +export function setMobileHostBridgeShareTarget(request: HostBridgeRequest) { + const payload = request.payload; + const target = + payload && typeof payload === 'object' + ? (payload as { target?: unknown }).target + : undefined; + if (target === undefined) { + throw invalidRequest('target is required'); + } + const normalizedTarget = normalizeHostBridgeShareOpenPayload(target); + if (normalizedTarget.status !== 'valid') { + throw invalidRequest( + normalizedTarget.status === 'invalid' + ? 'share target is invalid' + : 'share target is required', + ); + } + + currentShareTarget = normalizedTarget.payload; + return ok(request, true); +} + +export function resetMobileHostBridgeShareTargetForTest() { + currentShareTarget = null; +} + +export async function openShare(request: HostBridgeRequest) { + const explicitPayload = normalizeHostBridgeShareOpenPayload(request.payload); + if (explicitPayload.status === 'invalid') { + throw invalidRequest('share target is invalid'); + } + + const cachedPayload = + explicitPayload.status === 'valid' + ? explicitPayload + : normalizeHostBridgeShareOpenPayload(currentShareTarget); + if (cachedPayload.status === 'invalid') { + throw invalidRequest('share target is invalid'); + } + + const sharePayload = + cachedPayload.status === 'valid' ? cachedPayload.payload : undefined; + if (!sharePayload) { + throw invalidRequest('share target is required'); + } + + const url = sharePayload?.url; + const message = [sharePayload?.message, url].filter(Boolean).join('\n'); + + try { + await Share.share({ + title: sharePayload?.title, + message: message || url || sharePayload?.title || '', + url, + }); + } catch (error) { + logMobileShareFailure('open.share', error); + throw { + code: 'host_error', + message: 'share unavailable', + } satisfies HostBridgeError; + } + return ok(request, true); +} diff --git a/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx b/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx new file mode 100644 index 000000000..bf326eb78 --- /dev/null +++ b/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx @@ -0,0 +1,203 @@ +/* @vitest-environment jsdom */ + +import { act, render, waitFor } from '@testing-library/react'; +import * as CameraModule from 'expo-camera'; +import React from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + resetQrScannerForTest, + scanQrCode, +} from '../host-bridge/scanner'; +import { QrScannerOverlay } from './QrScannerOverlay'; + +const overlayHarness = vi.hoisted(() => ({ + cameraViewProps: { + current: null as Record | null, + }, + reset() { + this.cameraViewProps.current = null; + }, +})); + +vi.mock('expo-camera', () => ({ + Camera: { + requestCameraPermissionsAsync: vi.fn(), + }, + CameraView: (props: Record) => { + overlayHarness.cameraViewProps.current = props; + return React.createElement('mobile-camera-view'); + }, +})); + +vi.mock('react-native', () => ({ + Pressable: ({ + children, + onPress, + accessibilityLabel: _accessibilityLabel, + accessibilityRole: _accessibilityRole, + ...props + }: { + accessibilityLabel?: string; + accessibilityRole?: string; + children?: React.ReactNode; + onPress?: () => void; + }) => React.createElement('button', { ...props, onClick: onPress }, children), + StyleSheet: { + create: (styles: T) => styles, + }, + Text: ({ children, ...props }: { children?: React.ReactNode }) => + React.createElement('span', props, children), + View: ({ + children, + pointerEvents: _pointerEvents, + ...props + }: { + children?: React.ReactNode; + pointerEvents?: string; + }) => React.createElement('div', props, children), +})); + +const cameraPermissionMock = vi.mocked( + CameraModule.Camera.requestCameraPermissionsAsync, +); + +function createDeferredPermission() { + let resolvePermission!: ( + permission: Awaited< + ReturnType + >, + ) => void; + const promise = new Promise< + Awaited> + >((resolve) => { + resolvePermission = resolve; + }); + return { + promise, + resolvePermission, + }; +} + +describe('QrScannerOverlay', () => { + afterEach(() => { + resetQrScannerForTest(); + overlayHarness.reset(); + vi.clearAllMocks(); + }); + + test('requests camera permission and completes a QR scan result', async () => { + cameraPermissionMock.mockResolvedValue({ + granted: true, + } as Awaited>); + render(); + + const scanPromise = scanQrCode(); + + await waitFor(() => { + expect(overlayHarness.cameraViewProps.current).toBeTruthy(); + }); + + expect(cameraPermissionMock).toHaveBeenCalledTimes(1); + expect(overlayHarness.cameraViewProps.current).toMatchObject({ + facing: 'back', + barcodeScannerSettings: { + barcodeTypes: ['qr'], + }, + }); + + const cameraProps = overlayHarness.cameraViewProps.current as { + onBarcodeScanned?: (result: { type: string; data: string }) => void; + }; + cameraProps.onBarcodeScanned?.({ + type: 'qr', + data: ' https://www.genarrative.world/works/detail?work=PZ-1 ', + }); + + await expect(scanPromise).resolves.toEqual({ + format: 'qr_code', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', + }); + }); + + test('rejects the active scan when camera permission is denied', async () => { + cameraPermissionMock.mockResolvedValue({ + granted: false, + } as Awaited>); + render(); + + const scanPromise = scanQrCode(); + + await expect(scanPromise).rejects.toMatchObject({ + code: 'host_error', + message: 'qr scanner unavailable', + }); + expect(overlayHarness.cameraViewProps.current).toBeNull(); + }); + + test('logs and rejects the active scan when camera permission request fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const error = new Error('camera unavailable'); + cameraPermissionMock.mockRejectedValue(error); + render(); + + const scanPromise = scanQrCode(); + + await expect(scanPromise).rejects.toMatchObject({ + code: 'host_error', + message: 'qr scanner unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile QR scanner permission request failed', + ); + expect(overlayHarness.cameraViewProps.current).toBeNull(); + }); + + test('close button cancels the pending scan', async () => { + cameraPermissionMock.mockResolvedValue({ + granted: true, + } as Awaited>); + const screen = render(); + + const scanPromise = scanQrCode(); + + await waitFor(() => { + expect(screen.getByText('关闭')).toBeTruthy(); + }); + + screen.getByText('关闭').click(); + + await expect(scanPromise).rejects.toMatchObject({ + code: 'cancelled', + message: 'qr scan cancelled', + }); + }); + + test('ignores late camera permission after the scan is cancelled', async () => { + const permission = createDeferredPermission(); + cameraPermissionMock.mockReturnValue(permission.promise); + const screen = render(); + + const scanPromise = scanQrCode(); + + await waitFor(() => { + expect(screen.getByText('关闭')).toBeTruthy(); + }); + + screen.getByText('关闭').click(); + + await expect(scanPromise).rejects.toMatchObject({ + code: 'cancelled', + message: 'qr scan cancelled', + }); + + await act(async () => { + permission.resolvePermission({ + granted: true, + } as Awaited>); + await permission.promise; + }); + + expect(overlayHarness.cameraViewProps.current).toBeNull(); + }); +}); diff --git a/apps/mobile-shell/src/shell/QrScannerOverlay.tsx b/apps/mobile-shell/src/shell/QrScannerOverlay.tsx new file mode 100644 index 000000000..0953ec255 --- /dev/null +++ b/apps/mobile-shell/src/shell/QrScannerOverlay.tsx @@ -0,0 +1,146 @@ +import { + Camera, + CameraView, + type BarcodeScanningResult, +} from 'expo-camera'; +import { useCallback, useEffect, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { + cancelQrCodeScan, + completeQrCodeScan, + failQrCodeScan, + subscribeQrScannerState, +} from '../host-bridge/scanner'; + +function logQrScannerPermissionFailure(_error: unknown) { + console.warn('mobile QR scanner permission request failed'); +} + +export function QrScannerOverlay() { + const [isActive, setIsActive] = useState(false); + const [requestKey, setRequestKey] = useState(0); + const [hasPermission, setHasPermission] = useState(false); + const [scanCompleted, setScanCompleted] = useState(false); + + useEffect(() => subscribeQrScannerState((state) => { + setIsActive(state.active); + setRequestKey(state.requestKey); + }), []); + + useEffect(() => { + if (!isActive) { + setHasPermission(false); + setScanCompleted(false); + return; + } + + let disposed = false; + setHasPermission(false); + setScanCompleted(false); + void Camera.requestCameraPermissionsAsync() + .then((permission) => { + if (disposed) { + return; + } + if (!permission.granted) { + failQrCodeScan(); + return; + } + setHasPermission(true); + }) + .catch((error: unknown) => { + if (!disposed) { + logQrScannerPermissionFailure(error); + failQrCodeScan(); + } + }); + + return () => { + disposed = true; + }; + }, [isActive, requestKey]); + + const handleBarcodeScanned = useCallback( + (result: BarcodeScanningResult) => { + if (scanCompleted || result.type !== 'qr') { + return; + } + + if (completeQrCodeScan(result.data)) { + setScanCompleted(true); + } + }, + [scanCompleted], + ); + + if (!isActive) { + return null; + } + + return ( + + {hasPermission ? ( + + ) : null} + + + 关闭 + + + ); +} + +const styles = StyleSheet.create({ + root: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: '#0c0a09', + }, + camera: { + flex: 1, + }, + frame: { + position: 'absolute', + top: '22%', + right: 42, + bottom: '22%', + left: 42, + borderWidth: 2, + borderColor: '#fffdf9', + borderRadius: 8, + }, + closeButton: { + position: 'absolute', + top: 20, + right: 20, + minWidth: 76, + minHeight: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + backgroundColor: '#fffdf9', + paddingHorizontal: 16, + }, + closeButtonText: { + color: '#211a16', + fontSize: 15, + fontWeight: '700', + lineHeight: 20, + }, +}); diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx new file mode 100644 index 000000000..8cd52d326 --- /dev/null +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -0,0 +1,824 @@ +/* @vitest-environment jsdom */ + +import { act, render, waitFor } from '@testing-library/react'; +import * as Network from 'expo-network'; +import React from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; + +const shellHarness = vi.hoisted(() => { + const appStateListeners = [] as Array<(state: string) => void>; + const webViewProps = { current: null as Record | null }; + const cameraViewProps = { current: null as Record | null }; + const injectJavaScriptError = { current: null as Error | null }; + const injectedScripts = [] as string[]; + const linkingUrlListeners = [] as Array<(event: { url: string }) => void>; + const networkListeners = [] as Array<(state: Record) => void>; + const reloadWebView = vi.fn(); + + return { + appStateListeners, + cameraViewProps, + injectJavaScriptError, + injectedScripts, + networkListeners, + reset() { + appStateListeners.length = 0; + webViewProps.current = null; + cameraViewProps.current = null; + injectJavaScriptError.current = null; + injectedScripts.length = 0; + linkingUrlListeners.length = 0; + networkListeners.length = 0; + reloadWebView.mockClear(); + }, + linkingUrlListeners, + reloadWebView, + webViewProps, + }; +}); + +vi.mock('expo-camera', () => ({ + Camera: { + requestCameraPermissionsAsync: vi.fn(async () => ({ + granted: true, + })), + }, + CameraView: (props: Record) => { + shellHarness.cameraViewProps.current = props; + return React.createElement('mobile-camera-view'); + }, +})); + +vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: vi.fn(), +})); + +vi.mock('expo-file-system', () => ({ + File: class MockFile { + uri = 'file:///cache/test'; + size = 0; + base64() { + return Promise.resolve(''); + } + text() { + return Promise.resolve(''); + } + write() { + return undefined; + } + }, + Paths: { + cache: 'file:///cache/', + }, +})); + +vi.mock('expo-haptics', () => ({ + ImpactFeedbackStyle: { + Heavy: 'heavy', + Light: 'light', + Medium: 'medium', + }, + impactAsync: vi.fn(), +})); + +vi.mock('expo-image-picker', () => ({ + PermissionStatus: { + DENIED: 'denied', + GRANTED: 'granted', + }, + launchCameraAsync: vi.fn(), + launchImageLibraryAsync: vi.fn(), + requestCameraPermissionsAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), +})); + +vi.mock('expo-linking', () => ({ + canOpenURL: vi.fn(async () => true), + createURL: vi.fn((path = '') => `genarrative://${path}`), + openURL: vi.fn(async () => undefined), + parse: vi.fn(() => ({ path: null, queryParams: {} })), +})); + +vi.mock('expo-network', () => ({ + addNetworkStateListener: vi.fn((listener) => { + shellHarness.networkListeners.push(listener); + return { + remove: vi.fn(), + }; + }), + getNetworkStateAsync: vi.fn(async () => ({ + isConnected: true, + isInternetReachable: true, + type: 'WIFI', + })), + NetworkStateType: { + CELLULAR: 'CELLULAR', + ETHERNET: 'ETHERNET', + NONE: 'NONE', + WIFI: 'WIFI', + }, +})); + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { + DEFAULT: 'default', + }, + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + setNotificationChannelAsync: vi.fn(), + setNotificationHandler: vi.fn(), +})); + +vi.mock('expo-sharing', () => ({ + isAvailableAsync: vi.fn(async () => true), + shareAsync: vi.fn(), +})); + +vi.mock('expo-status-bar', () => ({ + StatusBar: () => React.createElement('mobile-status-bar'), +})); + +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + SafeAreaView: ({ + children, + ...props + }: { + children?: React.ReactNode; + }) => React.createElement('mobile-safe-area-view', props, children), +})); + +vi.mock('react-native-webview', () => ({ + WebView: React.forwardRef((_props: Record, ref) => { + shellHarness.webViewProps.current = _props; + React.useImperativeHandle(ref, () => ({ + goBack: vi.fn(), + injectJavaScript: (script: string) => { + if (shellHarness.injectJavaScriptError.current) { + throw shellHarness.injectJavaScriptError.current; + } + shellHarness.injectedScripts.push(script); + }, + reload: shellHarness.reloadWebView, + })); + return React.createElement('mobile-web-view'); + }), +})); + +vi.mock('react-native', () => ({ + AppState: { + addEventListener: vi.fn((_event, listener) => { + shellHarness.appStateListeners.push(listener); + return { remove: vi.fn() }; + }), + currentState: 'active', + }, + BackHandler: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, + Linking: { + addEventListener: vi.fn((_event, listener) => { + shellHarness.linkingUrlListeners.push(listener); + return { remove: vi.fn() }; + }), + canOpenURL: vi.fn(async () => true), + getInitialURL: vi.fn(async () => null), + openURL: vi.fn(async () => undefined), + }, + Platform: { + OS: 'ios', + }, + Pressable: ({ + children, + onPress, + accessibilityLabel: _accessibilityLabel, + accessibilityRole: _accessibilityRole, + ...props + }: { + accessibilityLabel?: string; + accessibilityRole?: string; + children?: React.ReactNode; + onPress?: () => void; + }) => React.createElement('button', { ...props, onClick: onPress }, children), + StyleSheet: { + create: (styles: T) => styles, + }, + Text: ({ children, ...props }: { children?: React.ReactNode }) => + React.createElement('span', props, children), + View: ({ children, ...props }: { children?: React.ReactNode }) => + React.createElement('div', props, children), +})); + +async function importShellApp(isDev = true) { + vi.stubGlobal('__DEV__', isDev); + vi.resetModules(); + return (await import('./ShellApp')).default; +} + +async function importShellAppWithDevFlag(isDev: boolean) { + return await importShellApp(isDev); +} + +function buildRequest(): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'scan-request-1', + method: 'scanner.scanQrCode', + }; +} + +function extractInjectedHostBridgeMessage(script: string) { + const match = script.match(/data: ("(?:\\.|[^"])*")/); + const encodedMessage = match?.[1]; + if (!encodedMessage) { + throw new Error('injected HostBridge message missing'); + } + + return JSON.parse(JSON.parse(encodedMessage)) as unknown; +} + +function expectInjectedHostBridgeMessageSource(script: string) { + expect(script).toContain('origin: window.location.origin'); + expect(script).toContain('source: window'); +} + +function hostBridgeMessages() { + return shellHarness.injectedScripts.map(extractInjectedHostBridgeMessage); +} + +function hostBridgeEvent(eventName: string) { + return hostBridgeMessages().find( + (message) => + typeof message === 'object' && + message !== null && + (message as { event?: unknown }).event === eventName, + ) as { event: string; payload?: unknown } | undefined; +} + +function lastHostBridgeEvent(eventName: string) { + return hostBridgeMessages() + .filter( + (message) => + typeof message === 'object' && + message !== null && + (message as { event?: unknown }).event === eventName, + ) + .at(-1) as { event: string; payload?: unknown } | undefined; +} + +afterEach(() => { + shellHarness.reset(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('ShellApp QR scanner HostBridge flow', () => { + test('production shell ignores local H5 URL env before opening WebView', async () => { + vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/'); + const ShellApp = await importShellAppWithDevFlag(false); + + render(); + + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const sourceUrl = new URL(webViewProps.source?.uri ?? ''); + + expect(sourceUrl.origin).toBe('https://www.genarrative.world'); + expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app'); + expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile'); + }); + + test('development shell allows explicit local H5 URL env', async () => { + vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/'); + const ShellApp = await importShellAppWithDevFlag(true); + + render(); + + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const sourceUrl = new URL(webViewProps.source?.uri ?? ''); + + expect(sourceUrl.origin).toBe('http://127.0.0.1:3000'); + expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app'); + expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile'); + }); + + test('scanner.scanQrCode drives overlay camera scan and injects HostBridge response into WebView', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onMessage?: (event: { + nativeEvent: { + data: string; + url: string; + }; + }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri; + expect(webViewUrl).toContain('https://www.genarrative.world'); + + webViewProps.onMessage?.({ + nativeEvent: { + data: JSON.stringify(buildRequest()), + url: webViewUrl ?? 'https://www.genarrative.world/', + }, + }); + + await waitFor(() => { + expect(shellHarness.cameraViewProps.current).toBeTruthy(); + }); + + const cameraViewProps = shellHarness.cameraViewProps.current as { + onBarcodeScanned?: (result: { type: string; data: string }) => void; + }; + cameraViewProps.onBarcodeScanned?.({ + type: 'qr', + data: ' https://www.genarrative.world/works/detail?work=PZ-1 ', + }); + + await waitFor(() => { + expect( + shellHarness.injectedScripts.some((script) => + script.includes('scan-request-1'), + ), + ).toBe(true); + }); + + const responseScript = shellHarness.injectedScripts.find((script) => + script.includes('scan-request-1'), + ); + expectInjectedHostBridgeMessageSource(responseScript ?? ''); + const response = extractInjectedHostBridgeMessage(responseScript ?? ''); + + expect(response).toMatchObject({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'scan-request-1', + ok: true, + result: { + format: 'qr_code', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', + }, + }); + }); + + test('logs HostBridge response injection failures without crashing the shell', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onMessage?: (event: { + nativeEvent: { + data: string; + url: string; + }; + }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; + const injectionError = new Error('response injection failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shellHarness.injectJavaScriptError.current = injectionError; + + expect(() => { + webViewProps.onMessage?.({ + nativeEvent: { + data: JSON.stringify({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'runtime-request-1', + method: 'host.getRuntime', + }), + url: webViewUrl, + }, + }); + }).not.toThrow(); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge message injection failed', + ); + }); + + warnSpy.mockRestore(); + }); + + test('drops delayed HostBridge responses after shell unmount', async () => { + const ShellApp = await importShellApp(); + let resolveNetworkStatus: + (state: Awaited>) => void = + () => { + throw new Error('network status resolver missing'); + }; + vi.mocked(Network.getNetworkStateAsync).mockReturnValueOnce( + new Promise((resolve) => { + resolveNetworkStatus = resolve; + }), + ); + const { unmount } = render(); + + const webViewProps = shellHarness.webViewProps.current as { + onMessage?: (event: { + nativeEvent: { + data: string; + url: string; + }; + }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; + + webViewProps.onMessage?.({ + nativeEvent: { + data: JSON.stringify({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request-1', + method: 'network.status', + }), + url: webViewUrl, + }, + }); + unmount(); + + resolveNetworkStatus({ + isConnected: true, + isInternetReachable: true, + type: Network.NetworkStateType.WIFI, + }); + + await waitFor(() => { + expect(Network.getNetworkStateAsync).toHaveBeenCalled(); + }); + expect( + shellHarness.injectedScripts.some((script) => + script.includes('network-request-1'), + ), + ).toBe(false); + }); +}); + +describe('ShellApp HostBridge event injection', () => { + test('WebView origin whitelist is limited to the resolved H5 origin', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + originWhitelist?: string[]; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; + + expect(webViewProps.originWhitelist).toEqual([new URL(webViewUrl).origin]); + }); + + test('AppState changes inject app.lifecycle events into WebView', async () => { + const ShellApp = await importShellApp(); + render(); + + await waitFor(() => { + expect(shellHarness.appStateListeners.length).toBeGreaterThan(0); + }); + + shellHarness.injectedScripts.length = 0; + shellHarness.appStateListeners[0]?.('background'); + + const event = hostBridgeEvent('app.lifecycle'); + expectInjectedHostBridgeMessageSource(shellHarness.injectedScripts[0] ?? ''); + expect(event).toMatchObject({ + event: 'app.lifecycle', + payload: { + state: 'background', + focused: false, + nativeState: 'background', + }, + }); + }); + + test('native network listener injects network.statusChanged events into WebView', async () => { + const ShellApp = await importShellApp(); + render(); + + await waitFor(() => { + expect(shellHarness.networkListeners.length).toBeGreaterThan(0); + }); + + shellHarness.injectedScripts.length = 0; + shellHarness.networkListeners[0]?.({ + isConnected: false, + isInternetReachable: false, + type: 'NONE', + }); + + const event = hostBridgeEvent('network.statusChanged'); + expect(event).toMatchObject({ + event: 'network.statusChanged', + payload: { + isConnected: false, + isInternetReachable: false, + connectionType: 'none', + nativeType: 'NONE', + }, + }); + }); + + test('host event injection failures are logged without crashing the shell', async () => { + const ShellApp = await importShellApp(); + render(); + + await waitFor(() => { + expect(shellHarness.networkListeners.length).toBeGreaterThan(0); + }); + + const injectionError = new Error('webview injection failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shellHarness.injectJavaScriptError.current = injectionError; + + expect(() => { + shellHarness.networkListeners[0]?.({ + isConnected: false, + isInternetReachable: false, + type: 'NONE', + }); + }).not.toThrow(); + + expect(warnSpy).toHaveBeenCalledWith( + 'mobile host event failed for network.statusChanged', + ); + + warnSpy.mockRestore(); + }); + + test('native and H5 navigation state inject combined navigation.canGoBack events', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onMessage?: (event: { + nativeEvent: { + data: string; + url: string; + }; + }) => void; + onNavigationStateChange?: (event: { canGoBack: boolean }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; + + shellHarness.injectedScripts.length = 0; + webViewProps.onNavigationStateChange?.({ canGoBack: true }); + expect(lastHostBridgeEvent('navigation.canGoBack')).toMatchObject({ + event: 'navigation.canGoBack', + payload: { + canGoBack: true, + }, + }); + + shellHarness.injectedScripts.length = 0; + webViewProps.onNavigationStateChange?.({ canGoBack: false }); + webViewProps.onMessage?.({ + nativeEvent: { + data: JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + url: webViewUrl, + }, + }); + + expect(lastHostBridgeEvent('navigation.canGoBack')).toMatchObject({ + event: 'navigation.canGoBack', + payload: { + canGoBack: true, + }, + }); + }); + + test('load-time network event replay failure is logged instead of hidden', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onLoad?: (event: { + nativeEvent: { + url: string; + }; + }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; + const networkError = new Error('network replay failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Network.getNetworkStateAsync).mockRejectedValueOnce(networkError); + + webViewProps.onLoad?.({ + nativeEvent: { + url: webViewUrl, + }, + }); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile host event failed for network.statusChanged', + ); + }); + + warnSpy.mockRestore(); + }); + + test('external WebView navigation native failures stay outside the WebView', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onShouldStartLoadWithRequest?: (request: { url: string }) => boolean; + }; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { Linking } = await import('react-native'); + vi.mocked(Linking.openURL).mockRejectedValueOnce( + new Error('system browser unavailable'), + ); + + expect( + webViewProps.onShouldStartLoadWithRequest?.({ + url: 'https://outside.example/work/1', + }), + ).toBe(false); + + await waitFor(() => { + expect(Linking.openURL).toHaveBeenCalledWith( + 'https://outside.example/work/1', + ); + }); + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile shell navigation failed for external_navigation.open', + ); + }); + + warnSpy.mockRestore(); + }); + + test('blocked WebView file downloads are logged for host diagnostics', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onFileDownload?: (event: unknown) => void; + }; + const downloadEvent = { + nativeEvent: { + downloadUrl: 'blob:https://www.genarrative.world/download-id', + }, + }; + + webViewProps.onFileDownload?.(downloadEvent); + + expect(warnSpy).toHaveBeenCalledWith( + 'mobile shell blocked WebView file download', + ); + + warnSpy.mockRestore(); + }); + + test('initial deep link read failures are logged without replacing the current WebView URL', async () => { + const { Linking } = await import('react-native'); + const initialUrlError = new Error('initial URL unavailable'); + vi.mocked(Linking.getInitialURL).mockRejectedValueOnce(initialUrlError); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + + render(); + + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const initialWebUrl = webViewProps.source?.uri; + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile shell deep link failed for initial_url.read', + ); + }); + expect(shellHarness.webViewProps.current?.source).toEqual({ + uri: initialWebUrl, + }); + + warnSpy.mockRestore(); + }); + + test('runtime deep link rejections are logged and fall back to a safe WebView URL', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + + render(); + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const initialWebUrl = webViewProps.source?.uri; + + shellHarness.linkingUrlListeners[0]?.({ + url: 'https://outside.example/works/detail?work=PZ-1', + }); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile shell deep link failed for runtime_url.rejected', + ); + }); + expect(shellHarness.webViewProps.current?.source).toEqual({ + uri: initialWebUrl, + }); + + warnSpy.mockRestore(); + }); + + test('first WebView process failure reloads the current page once', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_000); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + + render(); + const { Linking } = await import('react-native'); + await waitFor(() => { + expect(Linking.getInitialURL).toHaveBeenCalled(); + }); + const webViewProps = shellHarness.webViewProps.current as { + onContentProcessDidTerminate?: () => void; + }; + + act(() => { + webViewProps.onContentProcessDidTerminate?.(); + }); + + expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile WebView process failed for content_process_terminated', + ); + + warnSpy.mockRestore(); + nowSpy.mockRestore(); + }); + + test('repeated WebView process failures show the load failure panel and retry clears the failure window', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + + const screen = render(); + const { Linking } = await import('react-native'); + await waitFor(() => { + expect(Linking.getInitialURL).toHaveBeenCalled(); + }); + const webViewProps = shellHarness.webViewProps.current as { + onRenderProcessGone?: () => void; + }; + + act(() => { + webViewProps.onRenderProcessGone?.(); + webViewProps.onRenderProcessGone?.(); + }); + + await waitFor(() => { + expect(screen.getByText('页面已停止')).toBeTruthy(); + }); + expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenLastCalledWith( + 'mobile WebView process failed for render_process_gone', + ); + + act(() => { + screen.getByText('重试').click(); + }); + expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(2); + + act(() => { + webViewProps.onRenderProcessGone?.(); + }); + expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(3); + + warnSpy.mockRestore(); + nowSpy.mockRestore(); + }); +}); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx new file mode 100644 index 000000000..438c1a49c --- /dev/null +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -0,0 +1,589 @@ +import { StatusBar } from 'expo-status-bar'; +import { + type ComponentType, + type RefAttributes, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + AppState, + type AppStateStatus, + BackHandler, + Linking, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import { + WebView, + type WebViewMessageEvent, + type WebViewNavigation, + type WebViewProps, +} from 'react-native-webview'; + +import { + type HostBridgeEventName, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + configureMobileHostBridgeNavigation, + handleMobileHostBridgeMessage, + resolveMobileHostCapabilities, +} from '../host-bridge/bridge'; +import { resolveMobileShellUrlFromDeepLink } from './deepLink'; +import { lifecyclePayloadFromAppState } from './lifecycle'; +import { + type MobileShellLoadFailure, + normalizeMobileShellLoadFailure, +} from './loadFailure'; +import { + openMobileShellExternalNavigation, + shouldAcceptMobileShellHostBridgeMessage, + shouldOpenInMobileShellWebView, +} from './navigation'; +import { + getMobileNetworkStatus, + subscribeMobileNetworkStatus, +} from './network'; +import { MOBILE_SHELL_HOST_VERSION } from './runtime'; +import { MOBILE_SHELL_SAFE_AREA_EDGES } from './safeArea'; +import { + buildMobileShellUrl, + resolveMobileShellBaseWebUrl, +} from './url'; +import { + MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, + shouldBlockMobileWebViewNavigationRequest, +} from './webViewPolicy'; +import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; +import { QrScannerOverlay } from './QrScannerOverlay'; + +const MobileShellWebView = WebView as unknown as ComponentType< + WebViewProps & RefAttributes> +>; + +function buildHostBridgeMessageScript(message: unknown) { + return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( + JSON.stringify(message), + )}, origin: window.location.origin, source: window })); true;`; +} + +function logMobileHostEventFailure(label: HostBridgeEventName, _error: unknown) { + console.warn(`mobile host event failed for ${label}`); +} + +function logMobileHostBridgeMessageFailure(_error: unknown) { + console.warn('mobile HostBridge message injection failed'); +} + +function logMobileShellNavigationFailure(label: string, _error: unknown) { + console.warn(`mobile shell navigation failed for ${label}`); +} + +function logMobileShellDownloadBlocked(_event: unknown) { + console.warn('mobile shell blocked WebView file download'); +} + +function logMobileShellDeepLinkFailure(label: string, _error: unknown) { + console.warn(`mobile shell deep link failed for ${label}`); +} + +const MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS = 30_000; +const MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT = 1; + +type MobileWebViewLoadErrorEvent = { + nativeEvent: { + url: string; + code?: number; + description?: string; + }; +}; + +type MobileWebViewHttpErrorEvent = { + nativeEvent: { + url: string; + statusCode?: number; + description?: string; + }; +}; + +export default function ShellApp() { + const webViewRef = useRef>(null); + const isShellMountedRef = useRef(true); + const nativeCanGoBackRef = useRef(false); + const h5CanGoBackRef = useRef(false); + const webViewProcessFailureRef = useRef({ + count: 0, + firstFailureAt: 0, + }); + const [canGoBack, setCanGoBack] = useState(false); + const baseWebUrl = resolveMobileShellBaseWebUrl( + process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL, + { + allowLocalDevelopment: __DEV__, + }, + ); + const baseWebUrlOptions = useMemo( + () => ({ + allowLocalDevelopment: __DEV__, + }), + [], + ); + const urlOptions = useMemo( + () => ({ + platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const, + hostVersion: MOBILE_SHELL_HOST_VERSION, + capabilities: resolveMobileHostCapabilities(), + }), + [], + ); + const [webUrl, setWebUrl] = useState(() => + buildMobileShellUrl(baseWebUrl, urlOptions, baseWebUrlOptions), + ); + const [loadFailure, setLoadFailure] = + useState(null); + const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]); + const reloadCurrentWebView = useCallback(() => { + webViewRef.current?.reload(); + }, []); + const resetWebViewProcessFailureWindow = useCallback(() => { + webViewProcessFailureRef.current = { + count: 0, + firstFailureAt: 0, + }; + }, []); + useEffect(() => { + isShellMountedRef.current = true; + + return () => { + isShellMountedRef.current = false; + }; + }, []); + const injectHostBridgeMessage = useCallback( + ( + message: unknown, + onError: (error: unknown) => void, + ) => { + if (!isShellMountedRef.current) { + return; + } + + try { + webViewRef.current?.injectJavaScript( + buildHostBridgeMessageScript(message), + ); + } catch (error) { + onError(error); + } + }, + [], + ); + const injectHostBridgeEvent = useCallback( + (event: HostBridgeEventName, payload: unknown) => { + injectHostBridgeMessage( + { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + event, + payload, + }, + (error) => logMobileHostEventFailure(event, error), + ); + }, + [injectHostBridgeMessage], + ); + const injectLifecycleEvent = useCallback( + (state: AppStateStatus) => { + injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); + }, + [injectHostBridgeEvent], + ); + const injectNetworkStatusEvent = useCallback( + (payload: Awaited>) => { + injectHostBridgeEvent('network.statusChanged', payload); + }, + [injectHostBridgeEvent], + ); + const syncNavigationCanGoBack = useCallback( + (source: 'native' | 'h5', nextCanGoBack: boolean) => { + if (source === 'native') { + nativeCanGoBackRef.current = nextCanGoBack; + } else { + h5CanGoBackRef.current = nextCanGoBack; + } + + const combinedCanGoBack = + nativeCanGoBackRef.current || h5CanGoBackRef.current; + setCanGoBack(combinedCanGoBack); + injectHostBridgeEvent('navigation.canGoBack', { + canGoBack: combinedCanGoBack, + }); + }, + [injectHostBridgeEvent], + ); + const resetNavigationCanGoBack = useCallback(() => { + nativeCanGoBackRef.current = false; + h5CanGoBackRef.current = false; + setCanGoBack(false); + }, []); + + useEffect(() => { + configureMobileHostBridgeNavigation({ + allowedOrigin: allowedWebOrigin, + urlOptions, + baseWebUrlOptions, + openWebViewUrl(url) { + resetWebViewProcessFailureWindow(); + resetNavigationCanGoBack(); + setLoadFailure(null); + setWebUrl(url); + }, + reloadWebView: reloadCurrentWebView, + }); + + return () => configureMobileHostBridgeNavigation(null); + }, [ + allowedWebOrigin, + baseWebUrlOptions, + reloadCurrentWebView, + resetNavigationCanGoBack, + resetWebViewProcessFailureWindow, + urlOptions, + ]); + + useEffect(() => { + let disposed = false; + + const openDeepLink = ( + url: string | null | undefined, + source: 'initial_url' | 'runtime_url', + ) => { + const resolution = resolveMobileShellUrlFromDeepLink( + url, + baseWebUrl, + urlOptions, + baseWebUrlOptions, + ); + if (resolution.status === 'rejected') { + logMobileShellDeepLinkFailure(`${source}.rejected`, url); + } + resetWebViewProcessFailureWindow(); + resetNavigationCanGoBack(); + setLoadFailure(null); + setWebUrl(resolution.url); + }; + + void Linking.getInitialURL() + .then((url) => { + if (!disposed) { + openDeepLink(url, 'initial_url'); + } + }) + .catch((error: unknown) => { + logMobileShellDeepLinkFailure('initial_url.read', error); + }); + + const subscription = Linking.addEventListener('url', (event) => { + try { + openDeepLink(event.url, 'runtime_url'); + } catch (error) { + logMobileShellDeepLinkFailure('runtime_url.open', error); + } + }); + + return () => { + disposed = true; + subscription.remove(); + }; + }, [ + baseWebUrl, + resetNavigationCanGoBack, + resetWebViewProcessFailureWindow, + urlOptions, + ]); + + useEffect(() => { + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + if (!canGoBack) { + return false; + } + + if (h5CanGoBackRef.current) { + webViewRef.current?.injectJavaScript('window.history.back(); true;'); + } else { + webViewRef.current?.goBack(); + } + return true; + }, + ); + + return () => subscription.remove(); + }, [canGoBack]); + + useEffect(() => { + const subscription = AppState.addEventListener( + 'change', + injectLifecycleEvent, + ); + injectLifecycleEvent(AppState.currentState); + + return () => subscription.remove(); + }, [injectLifecycleEvent]); + + useEffect(() => { + return subscribeMobileNetworkStatus(injectNetworkStatusEvent); + }, [injectNetworkStatusEvent]); + + const handleMessage = (event: WebViewMessageEvent) => { + if ( + !shouldAcceptMobileShellHostBridgeMessage( + event.nativeEvent.url, + allowedWebOrigin, + ) + ) { + return; + } + + const historyState = parseMobileWebViewHistoryStateMessage( + event.nativeEvent.data, + ); + if (historyState) { + syncNavigationCanGoBack('h5', historyState.canGoBack); + return; + } + + void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => { + injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure); + }); + }; + + const handleShouldStartLoad = (request: { url: string }) => { + if (shouldBlockMobileWebViewNavigationRequest(request)) { + return false; + } + + if (shouldOpenInMobileShellWebView(request.url, allowedWebOrigin)) { + return true; + } + + void openMobileShellExternalNavigation(Linking, request.url).catch( + (error: unknown) => { + logMobileShellNavigationFailure('external_navigation.open', error); + }, + ); + return false; + }; + + const handleWebViewLoad = (event: { nativeEvent: { url: string } }) => { + if ( + !shouldAcceptMobileShellHostBridgeMessage( + event.nativeEvent.url, + allowedWebOrigin, + ) + ) { + return; + } + + setLoadFailure(null); + resetWebViewProcessFailureWindow(); + injectLifecycleEvent(AppState.currentState); + void getMobileNetworkStatus() + .then(injectNetworkStatusEvent) + .catch((error: unknown) => { + logMobileHostEventFailure('network.statusChanged', error); + }); + }; + const handleWebViewLoadError = (event: MobileWebViewLoadErrorEvent) => { + resetNavigationCanGoBack(); + setLoadFailure( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: event.nativeEvent.url, + code: event.nativeEvent.code, + description: event.nativeEvent.description, + }, + allowedWebOrigin, + webUrl, + ), + ); + }; + const handleWebViewHttpError = (event: MobileWebViewHttpErrorEvent) => { + resetNavigationCanGoBack(); + setLoadFailure( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: event.nativeEvent.url, + statusCode: event.nativeEvent.statusCode, + description: event.nativeEvent.description, + }, + allowedWebOrigin, + webUrl, + ), + ); + }; + const handleRetryLoadFailure = () => { + setLoadFailure(null); + resetWebViewProcessFailureWindow(); + reloadCurrentWebView(); + }; + const handleWebViewProcessFailure = (label: string) => { + const now = Date.now(); + const current = webViewProcessFailureRef.current; + const isWithinFailureWindow = + current.firstFailureAt > 0 && + now - current.firstFailureAt <= MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS; + const nextFailureWindow = isWithinFailureWindow + ? { + count: current.count + 1, + firstFailureAt: current.firstFailureAt, + } + : { + count: 1, + firstFailureAt: now, + }; + webViewProcessFailureRef.current = nextFailureWindow; + console.warn(`mobile WebView process failed for ${label}`); + + if (nextFailureWindow.count <= MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT) { + reloadCurrentWebView(); + return; + } + + resetNavigationCanGoBack(); + setLoadFailure( + normalizeMobileShellLoadFailure( + { + type: 'process', + url: webUrl, + description: 'WebView renderer terminated repeatedly', + }, + allowedWebOrigin, + webUrl, + ), + ); + }; + const handleBlockedFileDownload = (event: unknown) => { + logMobileShellDownloadBlocked(event); + }; + + return ( + + + + { + handleWebViewProcessFailure('content_process_terminated'); + }} + onRenderProcessGone={() => { + handleWebViewProcessFailure('render_process_gone'); + }} + onLoad={handleWebViewLoad} + onError={handleWebViewLoadError} + onHttpError={handleWebViewHttpError} + onShouldStartLoadWithRequest={handleShouldStartLoad} + onNavigationStateChange={(event: WebViewNavigation) => { + syncNavigationCanGoBack('native', event.canGoBack); + }} + setSupportMultipleWindows={false} + /> + {loadFailure ? ( + + {loadFailure.title} + {loadFailure.detail} + + + {loadFailure.retryLabel} + + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#fffdf9', + }, + loadFailurePanel: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 28, + backgroundColor: '#fffdf9', + }, + loadFailureTitle: { + color: '#211a16', + fontSize: 18, + fontWeight: '700', + lineHeight: 24, + textAlign: 'center', + }, + loadFailureDetail: { + maxWidth: 320, + marginTop: 10, + color: '#6a5c52', + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + loadFailureButton: { + minWidth: 112, + minHeight: 42, + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + borderRadius: 8, + backgroundColor: '#211a16', + paddingHorizontal: 22, + }, + loadFailureButtonText: { + color: '#fffdf9', + fontSize: 15, + fontWeight: '700', + lineHeight: 20, + textAlign: 'center', + }, +}); diff --git a/apps/mobile-shell/src/shell/deepLink.test.ts b/apps/mobile-shell/src/shell/deepLink.test.ts new file mode 100644 index 000000000..82bab421b --- /dev/null +++ b/apps/mobile-shell/src/shell/deepLink.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildMobileShellUrlFromDeepLink, + resolveMobileShellUrlFromDeepLink, +} from './deepLink'; + +const options = { + platform: 'ios' as const, + hostVersion: '0.1.0', + capabilities: ['host.getRuntime' as const], +}; + +describe('buildMobileShellUrlFromDeepLink', () => { + test('把原生 scheme deep link 映射成同源 H5 目标页', () => { + const url = new URL( + buildMobileShellUrlFromDeepLink( + 'genarrative://open/works/detail?work=PZ-1', + 'https://www.genarrative.world/', + options, + ), + ); + + expect(url.origin).toBe('https://www.genarrative.world'); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('PZ-1'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + expect(url.searchParams.get('hostShell')).toBe('expo_mobile'); + }); + + test('把同源 universal link 映射成带宿主上下文的 H5 URL', () => { + const url = new URL( + buildMobileShellUrlFromDeepLink( + 'https://www.genarrative.world/creation/puzzle#draft', + 'https://www.genarrative.world/', + options, + ), + ); + + expect(url.pathname).toBe('/creation/puzzle'); + expect(url.hash).toBe('#draft'); + expect(url.searchParams.get('hostCapabilities')).toBe('host.getRuntime'); + }); + + test('外域和危险协议回退到默认首页', () => { + const external = new URL( + buildMobileShellUrlFromDeepLink( + 'https://example.com/works/detail?work=PZ-1', + 'https://www.genarrative.world/', + options, + ), + ); + const unsafe = new URL( + buildMobileShellUrlFromDeepLink( + 'javascript:alert(1)', + 'https://www.genarrative.world/', + options, + ), + ); + + expect(external.origin).toBe('https://www.genarrative.world'); + expect(external.pathname).toBe('/'); + expect(unsafe.origin).toBe('https://www.genarrative.world'); + expect(unsafe.pathname).toBe('/'); + }); + + test('协议相对外域 URL 不会被装进移动壳 WebView', () => { + const url = new URL( + buildMobileShellUrlFromDeepLink( + '//example.com/works/detail?work=PZ-1', + 'https://www.genarrative.world/', + options, + ), + ); + + expect(url.origin).toBe('https://www.genarrative.world'); + expect(url.pathname).toBe('/'); + }); + + test('基准 H5 URL 配置非法或外域时回退到默认启动地址', () => { + const url = new URL( + buildMobileShellUrlFromDeepLink( + 'genarrative://open/works/detail?work=PZ-1', + 'https://example.com/app', + options, + ), + ); + + expect(url.origin).toBe('https://www.genarrative.world'); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('PZ-1'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + }); + + test('基准 H5 URL 只在显式开发模式保留本机 deep link 入口', () => { + const productionUrl = new URL( + buildMobileShellUrlFromDeepLink( + 'http://127.0.0.1:3000/works/detail?work=PZ-1', + 'http://127.0.0.1:3000/', + options, + ), + ); + const developmentUrl = new URL( + buildMobileShellUrlFromDeepLink( + 'http://127.0.0.1:3000/works/detail?work=PZ-1', + 'http://127.0.0.1:3000/', + options, + { + allowLocalDevelopment: true, + }, + ), + ); + + expect(productionUrl.origin).toBe('https://www.genarrative.world'); + expect(productionUrl.pathname).toBe('/'); + expect(developmentUrl.origin).toBe('http://127.0.0.1:3000'); + expect(developmentUrl.pathname).toBe('/works/detail'); + expect(developmentUrl.searchParams.get('work')).toBe('PZ-1'); + expect(developmentUrl.searchParams.get('clientRuntime')).toBe('native_app'); + }); + + test('返回 deep link 解析状态用于壳层记录拒绝路径', () => { + const mapped = resolveMobileShellUrlFromDeepLink( + 'genarrative://open/works/detail?work=PZ-1', + 'https://www.genarrative.world/', + options, + ); + const rejected = resolveMobileShellUrlFromDeepLink( + 'https://example.com/works/detail?work=PZ-1', + 'https://www.genarrative.world/', + options, + ); + const empty = resolveMobileShellUrlFromDeepLink( + null, + 'https://www.genarrative.world/', + options, + ); + + expect(mapped.status).toBe('mapped'); + expect(new URL(mapped.url).pathname).toBe('/works/detail'); + expect(rejected.status).toBe('rejected'); + expect(new URL(rejected.url).pathname).toBe('/'); + expect(empty.status).toBe('default'); + expect(new URL(empty.url).pathname).toBe('/'); + }); +}); diff --git a/apps/mobile-shell/src/shell/deepLink.ts b/apps/mobile-shell/src/shell/deepLink.ts new file mode 100644 index 000000000..a0e64b91d --- /dev/null +++ b/apps/mobile-shell/src/shell/deepLink.ts @@ -0,0 +1,112 @@ +import { + buildMobileShellUrl, + type MobileShellBaseWebUrlOptions, + resolveMobileShellBaseWebUrl, + type MobileShellUrlOptions, +} from './url'; + +const supportedHosts = new Set(['open', 'app']); + +type MobileShellDeepLinkResolutionStatus = 'default' | 'mapped' | 'rejected'; + +export type MobileShellDeepLinkResolution = { + status: MobileShellDeepLinkResolutionStatus; + url: string; +}; + +function extractPathFromNativeUrl(url: URL) { + if (supportedHosts.has(url.hostname)) { + return `${url.pathname}${url.search}${url.hash}`; + } + + return `${url.hostname ? `/${url.hostname}` : ''}${url.pathname}${url.search}${url.hash}`; +} + +function resolveTargetPath(rawUrl: string, webOrigin: string) { + try { + const url = new URL(rawUrl); + if (url.protocol === 'genarrative:') { + return extractPathFromNativeUrl(url); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + if (url.origin !== webOrigin) { + return null; + } + + return `${url.pathname}${url.search}${url.hash}`; + } catch { + if (!rawUrl.startsWith('/') && !rawUrl.startsWith('#')) { + return null; + } + + try { + const relativeUrl = new URL(rawUrl, webOrigin); + if (relativeUrl.origin !== webOrigin) { + return null; + } + + return `${relativeUrl.pathname}${relativeUrl.search}${relativeUrl.hash}`; + } catch { + return null; + } + } +} + +export function buildMobileShellUrlFromDeepLink( + rawUrl: string | null | undefined, + baseWebUrl: string, + options: MobileShellUrlOptions, + baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}, +) { + return resolveMobileShellUrlFromDeepLink( + rawUrl, + baseWebUrl, + options, + baseWebUrlOptions, + ).url; +} + +export function resolveMobileShellUrlFromDeepLink( + rawUrl: string | null | undefined, + baseWebUrl: string, + options: MobileShellUrlOptions, + baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}, +): MobileShellDeepLinkResolution { + const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl( + baseWebUrl, + baseWebUrlOptions, + ); + const defaultUrl = buildMobileShellUrl( + normalizedBaseWebUrl, + options, + baseWebUrlOptions, + ); + if (!rawUrl) { + return { + status: 'default', + url: defaultUrl, + }; + } + + const webOrigin = new URL(normalizedBaseWebUrl).origin; + const targetPath = resolveTargetPath(rawUrl, webOrigin); + if (!targetPath) { + return { + status: 'rejected', + url: defaultUrl, + }; + } + + return { + status: 'mapped', + url: buildMobileShellUrl( + new URL(targetPath, webOrigin).toString(), + options, + baseWebUrlOptions, + ), + }; +} diff --git a/apps/mobile-shell/src/shell/lifecycle.test.ts b/apps/mobile-shell/src/shell/lifecycle.test.ts new file mode 100644 index 000000000..89d96d3ca --- /dev/null +++ b/apps/mobile-shell/src/shell/lifecycle.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; + +import { lifecyclePayloadFromAppState } from './lifecycle'; + +describe('lifecycle', () => { + test('把 React Native AppState 映射为统一 HostBridge 生命周期状态', () => { + expect(lifecyclePayloadFromAppState('active')).toEqual({ + state: 'active', + focused: true, + nativeState: 'active', + }); + expect(lifecyclePayloadFromAppState('background')).toEqual({ + state: 'background', + focused: false, + nativeState: 'background', + }); + expect(lifecyclePayloadFromAppState('inactive')).toEqual({ + state: 'inactive', + focused: false, + nativeState: 'inactive', + }); + expect(lifecyclePayloadFromAppState('unknown')).toEqual({ + state: 'inactive', + focused: false, + nativeState: 'unknown', + }); + }); +}); diff --git a/apps/mobile-shell/src/shell/lifecycle.ts b/apps/mobile-shell/src/shell/lifecycle.ts new file mode 100644 index 000000000..90179e267 --- /dev/null +++ b/apps/mobile-shell/src/shell/lifecycle.ts @@ -0,0 +1,13 @@ +import type { AppStateStatus } from 'react-native'; + +export function lifecyclePayloadFromAppState(state: AppStateStatus) { + return { + state: state === 'active' + ? 'active' + : state === 'background' + ? 'background' + : 'inactive', + focused: state === 'active', + nativeState: state, + }; +} diff --git a/apps/mobile-shell/src/shell/loadFailure.test.ts b/apps/mobile-shell/src/shell/loadFailure.test.ts new file mode 100644 index 000000000..3db25e246 --- /dev/null +++ b/apps/mobile-shell/src/shell/loadFailure.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'vitest'; + +import { normalizeMobileShellLoadFailure } from './loadFailure'; + +const allowedOrigin = 'https://www.genarrative.world'; + +describe('loadFailure', () => { + test('归一化同源 HTTP 加载失败', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://www.genarrative.world/creation/puzzle?sessionId=private#recover', + statusCode: 503, + description: 'Service Unavailable', + }, + allowedOrigin, + ), + ).toEqual({ + type: 'http', + url: 'https://www.genarrative.world/creation/puzzle', + title: '加载失败 503', + detail: '服务器暂时没有返回可用页面', + retryLabel: '重试', + }); + }); + + test('归一化同源原生加载失败并隐藏系统错误描述', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: '/works/detail?work=WF-1&token=private#runtime', + code: -1009, + description: ' The Internet connection appears to be offline. ', + }, + allowedOrigin, + ), + ).toEqual({ + type: 'native', + url: 'https://www.genarrative.world/works/detail', + title: '网络不可用', + detail: '当前页面没有加载成功', + retryLabel: '重试', + }); + }); + + test('忽略外域和非页面加载失败', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://example.com/', + statusCode: 500, + }, + allowedOrigin, + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: 'about:blank', + code: -1, + }, + allowedOrigin, + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: 'javascript:alert(1)', + code: -1, + }, + allowedOrigin, + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: '/favicon.ico', + statusCode: 404, + }, + allowedOrigin, + ), + ).toBeNull(); + }); + + test('只展示当前主页面失败', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://www.genarrative.world/assets/main.js', + statusCode: 404, + }, + allowedOrigin, + 'https://www.genarrative.world/creation/puzzle', + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://www.genarrative.world/creation/puzzle', + statusCode: 503, + }, + allowedOrigin, + 'https://www.genarrative.world/creation/puzzle', + )?.title, + ).toBe('加载失败 503'); + }); + + test('连续 WebView 进程恢复失败时展示同源页面兜底', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'process', + url: 'https://www.genarrative.world/works/detail?work=WF-1&token=private#runtime', + description: 'WebView renderer terminated repeatedly', + }, + allowedOrigin, + 'https://www.genarrative.world/works/detail?work=WF-1&token=private#runtime', + ), + ).toEqual({ + type: 'process', + url: 'https://www.genarrative.world/works/detail', + title: '页面已停止', + detail: '当前页面连续恢复失败', + retryLabel: '重试', + }); + }); +}); diff --git a/apps/mobile-shell/src/shell/loadFailure.ts b/apps/mobile-shell/src/shell/loadFailure.ts new file mode 100644 index 000000000..b04971195 --- /dev/null +++ b/apps/mobile-shell/src/shell/loadFailure.ts @@ -0,0 +1,118 @@ +import { shouldOpenInMobileShellWebView } from './navigation'; + +export type MobileShellLoadFailureInput = + | { + type: 'native'; + url: string; + code?: number | null; + description?: string | null; + } + | { + type: 'http'; + url: string; + statusCode?: number | null; + description?: string | null; + } + | { + type: 'process'; + url: string; + description?: string | null; + }; + +export type MobileShellLoadFailure = { + type: 'native' | 'http' | 'process'; + url: string; + title: string; + detail: string; + retryLabel: string; +}; + +function sameDocumentUrl( + left: URL, + right: string | null | undefined, + allowedOrigin: string, +) { + if (!right) { + return true; + } + + try { + const current = new URL(right, allowedOrigin); + return ( + left.origin === current.origin && + left.pathname === current.pathname && + left.search === current.search + ); + } catch { + return false; + } +} + +function shouldShowLoadFailure( + rawUrl: string, + allowedOrigin: string, + currentPageUrl?: string | null, +) { + if (!shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)) { + return false; + } + + try { + const url = new URL(rawUrl, allowedOrigin); + return ( + url.origin === allowedOrigin && + url.pathname !== '/favicon.ico' && + sameDocumentUrl(url, currentPageUrl, allowedOrigin) + ); + } catch { + return false; + } +} + +function sanitizeLoadFailureUrl(rawUrl: string, allowedOrigin: string) { + const url = new URL(rawUrl, allowedOrigin); + return `${url.origin}${url.pathname}`; +} + +export function normalizeMobileShellLoadFailure( + input: MobileShellLoadFailureInput, + allowedOrigin: string, + currentPageUrl?: string | null, +): MobileShellLoadFailure | null { + if (!shouldShowLoadFailure(input.url, allowedOrigin, currentPageUrl)) { + return null; + } + + const url = sanitizeLoadFailureUrl(input.url, allowedOrigin); + if (input.type === 'http') { + const statusCode = + Number.isInteger(input.statusCode) && input.statusCode + ? input.statusCode + : null; + return { + type: 'http', + url, + title: statusCode ? `加载失败 ${statusCode}` : '加载失败', + detail: '服务器暂时没有返回可用页面', + retryLabel: '重试', + }; + } + + if (input.type === 'process') { + return { + type: 'process', + url, + title: '页面已停止', + detail: '当前页面连续恢复失败', + retryLabel: '重试', + }; + } + + return { + type: 'native', + url, + title: '网络不可用', + detail: '当前页面没有加载成功', + retryLabel: '重试', + }; +} diff --git a/apps/mobile-shell/src/shell/navigation.test.ts b/apps/mobile-shell/src/shell/navigation.test.ts new file mode 100644 index 000000000..a866717ef --- /dev/null +++ b/apps/mobile-shell/src/shell/navigation.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + type MobileShellExternalNavigator, + openMobileShellExternalNavigation, + resolveMobileShellExternalUrl, + resolveMobileShellWebViewUrl, + shouldAcceptMobileShellHostBridgeMessage, + shouldOpenInMobileShellWebView, +} from './navigation'; + +describe('shouldOpenInMobileShellWebView', () => { + test('只允许主站同源页面留在移动壳 WebView 内', () => { + const allowedOrigin = 'https://www.genarrative.world'; + + expect( + shouldOpenInMobileShellWebView( + 'https://www.genarrative.world/works/detail?work=PZ-1', + allowedOrigin, + ), + ).toBe(true); + expect( + shouldOpenInMobileShellWebView('/creation/puzzle', allowedOrigin), + ).toBe(true); + expect( + shouldOpenInMobileShellWebView( + 'http://www.genarrative.world/works/detail?work=PZ-1', + allowedOrigin, + ), + ).toBe(false); + expect( + shouldOpenInMobileShellWebView('about:blank', allowedOrigin), + ).toBe(true); + }); + + test('外链和非网页协议必须离开带 HostBridge 的 WebView', () => { + const allowedOrigin = 'https://www.genarrative.world'; + + expect( + shouldOpenInMobileShellWebView('https://example.com/', allowedOrigin), + ).toBe(false); + expect( + shouldOpenInMobileShellWebView('mailto:hi@example.com', allowedOrigin), + ).toBe(false); + expect( + shouldOpenInMobileShellWebView('//example.com/evil', allowedOrigin), + ).toBe(false); + expect( + shouldOpenInMobileShellWebView('javascript:alert(1)', allowedOrigin), + ).toBe(false); + expect(shouldOpenInMobileShellWebView('not a url', allowedOrigin)).toBe( + false, + ); + }); + + test('只有允许协议能交给系统外部应用打开', () => { + for (const protocol of HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS) { + const url = + protocol === 'mailto:' + ? 'mailto:hi@example.com' + : protocol === 'tel:' + ? 'tel:+12345678' + : `${protocol}//example.com/path`; + expect(resolveMobileShellExternalUrl(` ${url} `)).toBe(url); + } + + expect(resolveMobileShellExternalUrl('javascript:alert(1)')).toBeNull(); + expect(resolveMobileShellExternalUrl('file:///etc/passwd')).toBeNull(); + expect(resolveMobileShellExternalUrl('/relative/path')).toBeNull(); + }); + + test('WebView 外链必须先确认系统能打开再离开壳', async () => { + const navigator = { + canOpenURL: vi.fn(async (url: string) => url.startsWith('https://')), + openURL: vi.fn(async () => undefined), + }; + + await expect( + openMobileShellExternalNavigation(navigator, 'https://example.com/path'), + ).resolves.toBe(true); + expect(navigator.canOpenURL).toHaveBeenCalledWith('https://example.com/path'); + expect(navigator.openURL).toHaveBeenCalledWith('https://example.com/path'); + + navigator.canOpenURL.mockClear(); + navigator.openURL.mockClear(); + + await expect( + openMobileShellExternalNavigation(navigator, 'mailto:hi@example.com'), + ).resolves.toBe(false); + expect(navigator.canOpenURL).toHaveBeenCalledWith('mailto:hi@example.com'); + expect(navigator.openURL).not.toHaveBeenCalled(); + + navigator.canOpenURL.mockClear(); + navigator.openURL.mockClear(); + + await expect( + openMobileShellExternalNavigation(navigator, 'javascript:alert(1)'), + ).resolves.toBe(false); + expect(navigator.canOpenURL).not.toHaveBeenCalled(); + expect(navigator.openURL).not.toHaveBeenCalled(); + }); + + test('WebView 外链原生探测或打开失败时抛给壳层记录', async () => { + const navigator: MobileShellExternalNavigator = { + canOpenURL: vi.fn(async () => { + throw new Error('native canOpenURL failed'); + }), + openURL: vi.fn(async () => undefined), + }; + + await expect( + openMobileShellExternalNavigation(navigator, 'https://example.com/path'), + ).rejects.toThrow('native canOpenURL failed'); + expect(navigator.openURL).not.toHaveBeenCalled(); + + vi.mocked(navigator.canOpenURL).mockReset(); + vi.mocked(navigator.canOpenURL).mockResolvedValue(true); + vi.mocked(navigator.openURL).mockRejectedValueOnce( + new Error('native openURL failed'), + ); + + await expect( + openMobileShellExternalNavigation(navigator, 'https://example.com/path'), + ).rejects.toThrow('native openURL failed'); + }); + + test('HostBridge 主动导航只解析同源网页目标', () => { + const allowedOrigin = 'https://www.genarrative.world'; + + expect( + resolveMobileShellWebViewUrl('/works/detail?work=PZ-1', allowedOrigin), + ).toBe('https://www.genarrative.world/works/detail?work=PZ-1'); + expect( + resolveMobileShellWebViewUrl( + 'https://www.genarrative.world/creation/puzzle#draft', + allowedOrigin, + ), + ).toBe('https://www.genarrative.world/creation/puzzle#draft'); + expect( + resolveMobileShellWebViewUrl('https://example.com/', allowedOrigin), + ).toBeNull(); + expect( + resolveMobileShellWebViewUrl('about:blank', allowedOrigin), + ).toBeNull(); + }); + + test('HostBridge 消息只接受同源主站页面', () => { + const allowedOrigin = 'https://www.genarrative.world'; + + expect( + shouldAcceptMobileShellHostBridgeMessage( + 'https://www.genarrative.world/creation/puzzle', + allowedOrigin, + ), + ).toBe(true); + expect( + shouldAcceptMobileShellHostBridgeMessage('about:blank', allowedOrigin), + ).toBe(false); + expect( + shouldAcceptMobileShellHostBridgeMessage( + 'https://example.com/evil', + allowedOrigin, + ), + ).toBe(false); + expect( + shouldAcceptMobileShellHostBridgeMessage( + 'javascript:alert(1)', + allowedOrigin, + ), + ).toBe(false); + }); +}); diff --git a/apps/mobile-shell/src/shell/navigation.ts b/apps/mobile-shell/src/shell/navigation.ts new file mode 100644 index 000000000..bc6f916c8 --- /dev/null +++ b/apps/mobile-shell/src/shell/navigation.ts @@ -0,0 +1,81 @@ +import { normalizeHostBridgeExternalUrl } from '../../../../packages/shared/src/contracts/hostBridge'; + +export type MobileShellExternalNavigator = { + canOpenURL: (url: string) => Promise; + openURL: (url: string) => Promise; +}; + +export function shouldOpenInMobileShellWebView( + rawUrl: string, + allowedOrigin: string, +) { + if (rawUrl === 'about:blank') { + return true; + } + + if ( + !rawUrl.startsWith('/') && + !rawUrl.startsWith('#') && + !/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) + ) { + return false; + } + + try { + const url = new URL(rawUrl, allowedOrigin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return false; + } + + return url.origin === allowedOrigin; + } catch { + return false; + } +} + +export function resolveMobileShellExternalUrl(rawUrl: string) { + return normalizeHostBridgeExternalUrl(rawUrl); +} + +export async function openMobileShellExternalNavigation( + navigator: MobileShellExternalNavigator, + rawUrl: string, +) { + const externalUrl = resolveMobileShellExternalUrl(rawUrl); + if (!externalUrl) { + return false; + } + + if (!(await navigator.canOpenURL(externalUrl))) { + return false; + } + + await navigator.openURL(externalUrl); + return true; +} + +export function shouldAcceptMobileShellHostBridgeMessage( + rawUrl: string, + allowedOrigin: string, +) { + return rawUrl !== 'about:blank' && + shouldOpenInMobileShellWebView(rawUrl, allowedOrigin); +} + +export function resolveMobileShellWebViewUrl( + rawUrl: string, + allowedOrigin: string, +) { + if ( + rawUrl === 'about:blank' || + !shouldOpenInMobileShellWebView(rawUrl, allowedOrigin) + ) { + return null; + } + + try { + return new URL(rawUrl, allowedOrigin).toString(); + } catch { + return null; + } +} diff --git a/apps/mobile-shell/src/shell/network.test.ts b/apps/mobile-shell/src/shell/network.test.ts new file mode 100644 index 000000000..0e2a69245 --- /dev/null +++ b/apps/mobile-shell/src/shell/network.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + getMobileNetworkStatus, + normalizeMobileNetworkStatus, + subscribeMobileNetworkStatus, +} from './network'; + +vi.mock('expo-network', () => ({ + addNetworkStateListener: vi.fn(), + getNetworkStateAsync: vi.fn(), + NetworkStateType: { + CELLULAR: 'CELLULAR', + ETHERNET: 'ETHERNET', + NONE: 'NONE', + WIFI: 'WIFI', + }, +})); + +describe('network', async () => { + const Network = await import('expo-network'); + + test('归一化 Expo Network 状态', () => { + expect( + normalizeMobileNetworkStatus({ + type: Network.NetworkStateType.WIFI, + isConnected: true, + isInternetReachable: true, + }), + ).toEqual({ + isConnected: true, + isInternetReachable: true, + connectionType: 'wifi', + nativeType: 'WIFI', + }); + + expect( + normalizeMobileNetworkStatus({ + type: Network.NetworkStateType.NONE, + }), + ).toEqual({ + isConnected: false, + isInternetReachable: null, + connectionType: 'none', + nativeType: 'NONE', + }); + }); + + test('查询和订阅真实 Expo Network API', async () => { + vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ + type: Network.NetworkStateType.CELLULAR, + isConnected: true, + isInternetReachable: false, + }); + const remove = vi.fn(); + vi.mocked(Network.addNetworkStateListener).mockReturnValue({ remove }); + const listener = vi.fn(); + + await expect(getMobileNetworkStatus()).resolves.toEqual({ + isConnected: true, + isInternetReachable: false, + connectionType: 'cellular', + nativeType: 'CELLULAR', + }); + + const unsubscribe = subscribeMobileNetworkStatus(listener); + const networkListener = vi.mocked(Network.addNetworkStateListener).mock + .calls[0]?.[0]; + networkListener?.({ + type: Network.NetworkStateType.ETHERNET, + isConnected: true, + isInternetReachable: true, + }); + unsubscribe(); + + expect(listener).toHaveBeenCalledWith({ + isConnected: true, + isInternetReachable: true, + connectionType: 'ethernet', + nativeType: 'ETHERNET', + }); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile-shell/src/shell/network.ts b/apps/mobile-shell/src/shell/network.ts new file mode 100644 index 000000000..5bb9a25f5 --- /dev/null +++ b/apps/mobile-shell/src/shell/network.ts @@ -0,0 +1,40 @@ +import * as Network from 'expo-network'; + +import { + type NetworkStatusResult, + normalizeHostBridgeConnectionType, +} from '../../../../packages/shared/src/contracts/hostBridge'; + +export function normalizeMobileNetworkStatus( + state: Network.NetworkState, +): NetworkStatusResult { + const nativeType = state.type; + const connectionType = normalizeHostBridgeConnectionType(nativeType); + + return { + isConnected: + typeof state.isConnected === 'boolean' + ? state.isConnected + : connectionType !== 'none' && connectionType !== 'unknown', + isInternetReachable: + typeof state.isInternetReachable === 'boolean' + ? state.isInternetReachable + : null, + connectionType, + ...(nativeType ? { nativeType } : {}), + }; +} + +export async function getMobileNetworkStatus() { + return normalizeMobileNetworkStatus(await Network.getNetworkStateAsync()); +} + +export function subscribeMobileNetworkStatus( + listener: (status: NetworkStatusResult) => void, +) { + const subscription = Network.addNetworkStateListener((state) => { + listener(normalizeMobileNetworkStatus(state)); + }); + + return () => subscription.remove(); +} diff --git a/apps/mobile-shell/src/shell/runtime.test.ts b/apps/mobile-shell/src/shell/runtime.test.ts new file mode 100644 index 000000000..9a7c032b2 --- /dev/null +++ b/apps/mobile-shell/src/shell/runtime.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest'; + +import { resolveMobileShellHostVersion } from './runtime'; + +describe('resolveMobileShellHostVersion', () => { + test('uses Expo runtime config version when available', () => { + expect(resolveMobileShellHostVersion('0.2.3')).toBe('0.2.3'); + }); + + test('falls back when Expo runtime config version is unavailable', () => { + expect(resolveMobileShellHostVersion(null)).toBe('0.1.0'); + expect(resolveMobileShellHostVersion(' ')).toBe('0.1.0'); + }); +}); diff --git a/apps/mobile-shell/src/shell/runtime.ts b/apps/mobile-shell/src/shell/runtime.ts new file mode 100644 index 000000000..eca2b72c6 --- /dev/null +++ b/apps/mobile-shell/src/shell/runtime.ts @@ -0,0 +1,18 @@ +import appConfig from '../../app.json'; + +export const MOBILE_SHELL_HOST_VERSION_FALLBACK = '0.1.0'; + +export function resolveMobileShellHostVersion( + expoConfigVersion: unknown, +): string { + if (typeof expoConfigVersion !== 'string') { + return MOBILE_SHELL_HOST_VERSION_FALLBACK; + } + + const version = expoConfigVersion.trim(); + return version.length > 0 ? version : MOBILE_SHELL_HOST_VERSION_FALLBACK; +} + +export const MOBILE_SHELL_HOST_VERSION = resolveMobileShellHostVersion( + appConfig.expo?.version, +); diff --git a/apps/mobile-shell/src/shell/safeArea.test.ts b/apps/mobile-shell/src/shell/safeArea.test.ts new file mode 100644 index 000000000..ba3a55ebc --- /dev/null +++ b/apps/mobile-shell/src/shell/safeArea.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; + +import { MOBILE_SHELL_SAFE_AREA_EDGES } from './safeArea'; + +describe('mobile shell safe area', () => { + test('protects the WebView from every device edge', () => { + expect(MOBILE_SHELL_SAFE_AREA_EDGES).toEqual([ + 'top', + 'right', + 'bottom', + 'left', + ]); + }); + + test('keeps the edge list fixed for shell layout usage', () => { + expect(MOBILE_SHELL_SAFE_AREA_EDGES).toHaveLength(4); + expect([...MOBILE_SHELL_SAFE_AREA_EDGES].sort()).toEqual([ + 'bottom', + 'left', + 'right', + 'top', + ]); + }); +}); diff --git a/apps/mobile-shell/src/shell/safeArea.ts b/apps/mobile-shell/src/shell/safeArea.ts new file mode 100644 index 000000000..1430cad42 --- /dev/null +++ b/apps/mobile-shell/src/shell/safeArea.ts @@ -0,0 +1,6 @@ +export const MOBILE_SHELL_SAFE_AREA_EDGES = [ + 'top', + 'right', + 'bottom', + 'left', +] as const; diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts new file mode 100644 index 000000000..8b5eb8077 --- /dev/null +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from 'vitest'; + +import { + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, + HOST_BRIDGE_VERSION, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + ALLOWED_PRODUCTION_WEB_ORIGIN, + DEFAULT_MOBILE_SHELL_WEB_URL, + buildMobileShellUrl, + resolveMobileShellBaseWebUrl, +} from './url'; + +describe('buildMobileShellUrl', () => { + test('默认 H5 地址指向真实主站', () => { + expect(DEFAULT_MOBILE_SHELL_WEB_URL).toBe(HOST_BRIDGE_PUBLIC_WEB_URL); + expect(ALLOWED_PRODUCTION_WEB_ORIGIN).toBe(HOST_BRIDGE_PUBLIC_WEB_ORIGIN); + }); + + test('为 H5 附加原生移动壳上下文', () => { + const url = new URL( + buildMobileShellUrl('https://www.genarrative.world/works/detail?work=PZ-1', { + platform: 'ios', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime', 'share.open'], + }), + ); + + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + expect(url.searchParams.get('clientType')).toBe('native_app'); + expect(url.searchParams.get('hostShell')).toBe('expo_mobile'); + expect(url.searchParams.get('hostPlatform')).toBe('ios'); + expect(url.searchParams.get('hostVersion')).toBe('0.1.0'); + expect(url.searchParams.get('bridgeVersion')).toBe( + HOST_BRIDGE_VERSION.toString(), + ); + expect(url.searchParams.get('hostCapabilities')).toBe( + 'host.getRuntime,share.open', + ); + expect(url.searchParams.get('work')).toBe('PZ-1'); + }); + + test('附加宿主上下文前会清理旧移动壳 query', () => { + const rawUrl = + 'https://www.genarrative.world/works/detail?clientRuntime=browser&hostShell=old_shell&hostCapabilities=old&work=PZ-1'; + const builtUrl = buildMobileShellUrl(rawUrl, { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime', 'share.open'], + }); + const url = new URL(builtUrl); + + expect(url.searchParams.get('work')).toBe('PZ-1'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + expect(url.searchParams.get('hostShell')).toBe('expo_mobile'); + expect(url.searchParams.get('hostCapabilities')).toBe( + 'host.getRuntime,share.open', + ); + expect(builtUrl.match(/clientRuntime=/g)).toHaveLength(1); + expect(builtUrl.match(/hostShell=/g)).toHaveLength(1); + expect(builtUrl.match(/hostCapabilities=/g)).toHaveLength(1); + expect(builtUrl).not.toContain('clientRuntime=browser'); + expect(builtUrl).not.toContain('hostShell=old_shell'); + expect(builtUrl).not.toContain('hostCapabilities=old'); + }); + + test('支持按平台注入不同能力清单', () => { + const iosUrl = new URL( + buildMobileShellUrl('https://www.genarrative.world/', { + platform: 'ios', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime', 'app.setBadgeCount'], + }), + ); + const androidUrl = new URL( + buildMobileShellUrl('https://www.genarrative.world/', { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }), + ); + + expect(iosUrl.searchParams.get('hostCapabilities')).toBe( + 'host.getRuntime,app.setBadgeCount', + ); + expect(androidUrl.searchParams.get('hostCapabilities')).toBe( + 'host.getRuntime', + ); + }); + + test('移动壳基准 URL 默认只接受生产主站', () => { + expect( + resolveMobileShellBaseWebUrl('https://www.genarrative.world/path'), + ).toBe( + 'https://www.genarrative.world/path', + ); + expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('http://localhost:3000/')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('https://example.com/path')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('http://192.168.1.2:3000/')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('javascript:alert(1)')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('/relative/path')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('')).toBe(DEFAULT_MOBILE_SHELL_WEB_URL); + expect(resolveMobileShellBaseWebUrl(null)).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + }); + + test('移动壳基准 URL 只在开发模式接受本机入口', () => { + const options = { + allowLocalDevelopment: true, + }; + + expect( + resolveMobileShellBaseWebUrl(' http://127.0.0.1:3000/ ', options), + ).toBe('http://127.0.0.1:3000/'); + expect(resolveMobileShellBaseWebUrl('http://localhost:3000/', options)).toBe( + 'http://localhost:3000/', + ); + expect(resolveMobileShellBaseWebUrl('http://[::1]:3000/', options)).toBe( + 'http://[::1]:3000/', + ); + }); + + test('移动壳 URL 构建默认不会接受本机入口', () => { + const url = new URL( + buildMobileShellUrl('http://127.0.0.1:3000/works/detail?work=PZ-1', { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }), + ); + + expect(url.origin).toBe(HOST_BRIDGE_PUBLIC_WEB_ORIGIN); + expect(url.pathname).toBe('/'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + }); + + test('移动壳 URL 构建只在显式开发模式接受本机入口', () => { + const url = new URL( + buildMobileShellUrl( + 'http://127.0.0.1:3000/works/detail?work=PZ-1', + { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }, + { + allowLocalDevelopment: true, + }, + ), + ); + + expect(url.origin).toBe('http://127.0.0.1:3000'); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('PZ-1'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + }); + + test('非法启动 URL 回退到默认 H5 地址并继续附加宿主上下文', () => { + const url = new URL( + buildMobileShellUrl('javascript:alert(1)', { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }), + ); + + expect(url.toString().startsWith(DEFAULT_MOBILE_SHELL_WEB_URL)).toBe(true); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + expect(url.searchParams.get('hostShell')).toBe('expo_mobile'); + }); +}); diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts new file mode 100644 index 000000000..f13d977fd --- /dev/null +++ b/apps/mobile-shell/src/shell/url.ts @@ -0,0 +1,102 @@ +import { + HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEY, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, + HOST_BRIDGE_VERSION, + type HostBridgeCapability, + type NativeHostPlatform, +} from '../../../../packages/shared/src/contracts/hostBridge'; + +export type MobileShellUrlOptions = { + platform: Extract; + hostVersion: string; + capabilities: readonly HostBridgeCapability[]; +}; + +export type MobileShellBaseWebUrlOptions = { + allowLocalDevelopment?: boolean; +}; + +export const DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL; +export const ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN; +const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([ + '127.0.0.1', + 'localhost', + '[::1]', +]); + +function isAllowedMobileShellBaseUrl( + url: URL, + options: MobileShellBaseWebUrlOptions = {}, +) { + if (url.origin === ALLOWED_PRODUCTION_WEB_ORIGIN) { + return true; + } + + return Boolean(options.allowLocalDevelopment) && + url.protocol === 'http:' && + LOCAL_DEVELOPMENT_WEB_HOSTS.has(url.hostname); +} + +export function resolveMobileShellBaseWebUrl( + rawUrl: unknown, + options: MobileShellBaseWebUrlOptions = {}, +) { + if (typeof rawUrl !== 'string') { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } + + const value = rawUrl.trim(); + if (!value) { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } + + try { + const url = new URL(value); + if (!isAllowedMobileShellBaseUrl(url, options)) { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } + + return url.toString(); + } catch { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } +} + +export function buildMobileShellUrl( + rawUrl: string, + options: MobileShellUrlOptions, + baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}, +) { + const url = new URL(resolveMobileShellBaseWebUrl(rawUrl, baseWebUrlOptions)); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime, + HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientType, + HOST_BRIDGE_NATIVE_APP_QUERY.clientType, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostShell, + HOST_BRIDGE_NATIVE_APP_QUERY.hostShellExpoMobile, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostPlatform, + options.platform, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostVersion, + options.hostVersion, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.bridgeVersion, + HOST_BRIDGE_VERSION.toString(), + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostCapabilities, + options.capabilities.join(','), + ); + return url.toString(); +} diff --git a/apps/mobile-shell/src/shell/webViewGlobals.d.ts b/apps/mobile-shell/src/shell/webViewGlobals.d.ts new file mode 100644 index 000000000..3c1752c81 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewGlobals.d.ts @@ -0,0 +1,7 @@ +interface Window { + ReactNativeWebView?: { + postMessage?: (message: string) => void; + }; + __GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__?: boolean; + __GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__?: () => void; +} diff --git a/apps/mobile-shell/src/shell/webViewHistory.test.ts b/apps/mobile-shell/src/shell/webViewHistory.test.ts new file mode 100644 index 000000000..89d7b8c25 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewHistory.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; + +describe('parseMobileWebViewHistoryStateMessage', () => { + test('解析移动 WebView H5 路由栈状态消息', () => { + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ), + ).toEqual({ + canGoBack: true, + }); + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ), + ).toEqual({ + canGoBack: false, + }); + }); + + test('忽略非移动路由状态消息', () => { + expect(parseMobileWebViewHistoryStateMessage('not-json')).toBeNull(); + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + method: 'host.getRuntime', + }), + ), + ).toBeNull(); + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: 'true', + }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile-shell/src/shell/webViewHistory.ts b/apps/mobile-shell/src/shell/webViewHistory.ts new file mode 100644 index 000000000..f2adb8686 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewHistory.ts @@ -0,0 +1,31 @@ +export type MobileWebViewHistoryStateMessage = { + canGoBack: boolean; +}; + +export function parseMobileWebViewHistoryStateMessage( + rawMessage: string, +): MobileWebViewHistoryStateMessage | null { + try { + const value = JSON.parse(rawMessage) as unknown; + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as { + type?: unknown; + canGoBack?: unknown; + }; + if ( + candidate.type !== 'genarrative.mobile.historyState' || + typeof candidate.canGoBack !== 'boolean' + ) { + return null; + } + + return { + canGoBack: candidate.canGoBack, + }; + } catch { + return null; + } +} diff --git a/apps/mobile-shell/src/shell/webViewPolicy.test.ts b/apps/mobile-shell/src/shell/webViewPolicy.test.ts new file mode 100644 index 000000000..79df70b50 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewPolicy.test.ts @@ -0,0 +1,294 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + BLOCK_WEBVIEW_DOWNLOAD_SCRIPT, + MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, + shouldBlockMobileWebViewDownloadUrl, + shouldBlockMobileWebViewNavigationRequest, + TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT, +} from './webViewPolicy'; + +describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { + const originalOpen = window.open; + const originalAnchorClick = HTMLAnchorElement.prototype.click; + + beforeEach(() => { + document.body.innerHTML = ''; + window.open = vi.fn(() => null) as typeof window.open; + HTMLAnchorElement.prototype.click = originalAnchorClick; + window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT); + }); + + afterEach(() => { + document.body.innerHTML = ''; + window.open = originalOpen; + vi.restoreAllMocks(); + HTMLAnchorElement.prototype.click = originalAnchorClick; + }); + + test('保留 WebView 注入脚本返回值', () => { + expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT.trim()).toMatch(/true;$/); + expect(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT.trim()).toMatch(/true;$/); + expect(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT.trim()).toMatch(/true;$/); + }); + + test('注入脚本复用共享下载协议阻断清单', () => { + for (const protocol of HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS) { + expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain( + `${JSON.stringify(protocol)}: true`, + ); + expect(shouldBlockMobileWebViewDownloadUrl(`${protocol}download-id`)).toBe( + true, + ); + } + }); + + test('阻断嵌套元素触发的下载链接点击', () => { + document.body.innerHTML = ` + + 保存 + + `; + const target = document.getElementById('nested-download-target'); + expect(target).toBeTruthy(); + + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }); + const allowed = target?.dispatchEvent(event); + + expect(allowed).toBe(false); + expect(event.defaultPrevented).toBe(true); + }); + + test('放行非下载锚点点击', () => { + document.body.innerHTML = ` + + 打开 + + `; + const target = document.getElementById('nested-page-target'); + expect(target).toBeTruthy(); + + let defaultPreventedBeforeTarget = true; + target?.addEventListener('click', (event) => { + defaultPreventedBeforeTarget = event.defaultPrevented; + event.preventDefault(); + }); + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }); + const allowed = target?.dispatchEvent(event); + + expect(allowed).toBe(false); + expect(defaultPreventedBeforeTarget).toBe(false); + expect(event.defaultPrevented).toBe(true); + }); + + test('阻断危险下载协议的窗口打开', () => { + const opened = window.open('blob:https://www.genarrative.world/file-id'); + + expect(opened).toBeNull(); + }); + + test('阻断程序化下载链接点击', () => { + const originalClickSpy = vi.spyOn( + HTMLAnchorElement.prototype, + 'click', + ); + window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT); + const anchor = document.createElement('a'); + anchor.download = 'hello.txt'; + + anchor.click(); + + expect(originalClickSpy).not.toHaveBeenCalled(); + }); +}); + +describe('TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', () => { + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + beforeEach(() => { + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__; + window.history.replaceState(null, '', '/'); + }); + + afterEach(() => { + delete window.ReactNativeWebView; + delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__; + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }); + + test('追踪 H5 当前文档路由栈并上报返回状态', () => { + const postMessage = vi.fn(); + window.ReactNativeWebView = { + postMessage, + }; + + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + const initialState = window.history.state; + window.history.pushState({ route: 'detail' }, '', '/works/detail'); + window.history.replaceState({ route: 'detail-updated' }, '', '/works/detail?tab=info'); + window.dispatchEvent(new PopStateEvent('popstate', { + state: initialState, + })); + + expect(postMessage).toHaveBeenNthCalledWith(1, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(2, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(3, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(4, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + }); + + test('路由栈状态写入失败时记录日志', () => { + const postMessage = vi.fn(); + const syncError = new Error('history state blocked'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + window.ReactNativeWebView = { + postMessage, + }; + window.history.replaceState = vi.fn(() => { + throw syncError; + }) as typeof window.history.replaceState; + + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + + expect(warnSpy).toHaveBeenCalledWith( + 'mobile navigation state sync failed', + ); + expect(postMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + warnSpy.mockRestore(); + }); + + test('重复注入时回放当前 H5 路由栈状态', () => { + const postMessage = vi.fn(); + window.ReactNativeWebView = { + postMessage, + }; + + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + window.history.pushState({ route: 'detail' }, '', '/works/detail'); + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + + expect(postMessage).toHaveBeenNthCalledWith(1, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(2, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(3, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + }); + + test('组合注入脚本同时保留下载拦截和路由状态追踪', () => { + const postMessage = vi.fn(); + window.ReactNativeWebView = { + postMessage, + }; + + window.eval(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT); + const opened = window.open('blob:https://www.genarrative.world/file-id'); + window.history.pushState(null, '', '/creation/puzzle'); + + expect(opened).toBeNull(); + expect(postMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + }); +}); + +describe('shouldBlockMobileWebViewDownloadUrl', () => { + test('识别不能进入移动壳 WebView 的下载协议', () => { + expect( + shouldBlockMobileWebViewDownloadUrl( + 'blob:https://www.genarrative.world/file-id', + ), + ).toBe(true); + expect(shouldBlockMobileWebViewDownloadUrl('data:text/plain,hello')).toBe( + true, + ); + expect(shouldBlockMobileWebViewDownloadUrl('file:///tmp/export.png')).toBe( + true, + ); + expect( + shouldBlockMobileWebViewDownloadUrl( + 'filesystem:https://www.genarrative.world/temporary/export.png', + ), + ).toBe(true); + }); + + test('放行普通网页和系统外链协议', () => { + expect( + shouldBlockMobileWebViewDownloadUrl( + 'https://www.genarrative.world/works/detail?work=PZ-1', + ), + ).toBe(false); + expect(shouldBlockMobileWebViewDownloadUrl('/creation/puzzle')).toBe(false); + expect(shouldBlockMobileWebViewDownloadUrl('mailto:hi@example.com')).toBe( + false, + ); + expect(shouldBlockMobileWebViewDownloadUrl('tel:+12345678')).toBe(false); + }); +}); + +describe('shouldBlockMobileWebViewNavigationRequest', () => { + test('在 WebView 导航前拦截下载协议', () => { + expect( + shouldBlockMobileWebViewNavigationRequest({ + url: 'blob:https://www.genarrative.world/file-id', + }), + ).toBe(true); + expect( + shouldBlockMobileWebViewNavigationRequest({ + url: 'https://www.genarrative.world/creation/puzzle', + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile-shell/src/shell/webViewPolicy.ts b/apps/mobile-shell/src/shell/webViewPolicy.ts new file mode 100644 index 000000000..ee454e0e8 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewPolicy.ts @@ -0,0 +1,223 @@ +import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge'; +import { DEFAULT_MOBILE_SHELL_WEB_URL } from './url'; + +const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set( + HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS, +); + +function mobileWebViewBlockedDownloadProtocolMapScript() { + return HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS.map( + (protocol) => ` ${JSON.stringify(protocol)}: true`, + ).join(',\n'); +} + +export type MobileWebViewNavigationRequest = { + url?: string | null; +}; + +export function shouldBlockMobileWebViewDownloadUrl( + rawUrl: string | null | undefined, +) { + if (!rawUrl) { + return false; + } + + try { + return MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS.has( + new URL(rawUrl, DEFAULT_MOBILE_SHELL_WEB_URL).protocol, + ); + } catch { + return false; + } +} + +export function shouldBlockMobileWebViewNavigationRequest( + request: MobileWebViewNavigationRequest, +) { + return shouldBlockMobileWebViewDownloadUrl(request.url); +} + +export const BLOCK_WEBVIEW_DOWNLOAD_SCRIPT = ` +(function() { + var blockedDownloadProtocols = { +${mobileWebViewBlockedDownloadProtocolMapScript()} + }; + + function shouldBlockDownloadUrl(rawUrl) { + if (typeof rawUrl !== 'string') { + return false; + } + + try { + return blockedDownloadProtocols[new URL(rawUrl, window.location.href).protocol] === true; + } catch (error) { + return false; + } + } + + function findDownloadAnchor(target) { + if (!target) { + return null; + } + + if (typeof target.closest === 'function') { + return target.closest('a'); + } + + var element = target; + while (element && element !== document) { + if (element.tagName === 'A') { + return element; + } + element = element.parentNode; + } + + return null; + } + + function shouldBlockAnchor(anchor) { + return Boolean( + anchor && + (anchor.hasAttribute('download') || shouldBlockDownloadUrl(anchor.href)) + ); + } + + function blockDownloadEvent(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); + return false; + } + + document.addEventListener('click', function(event) { + if (shouldBlockAnchor(findDownloadAnchor(event.target))) { + return blockDownloadEvent(event); + } + }, true); + + var originalOpen = window.open; + window.open = function(url) { + if (shouldBlockDownloadUrl(url)) { + return null; + } + + return originalOpen.apply(window, arguments); + }; + + if (window.HTMLAnchorElement && HTMLAnchorElement.prototype.click) { + var originalAnchorClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function() { + if (shouldBlockAnchor(this)) { + return undefined; + } + + return originalAnchorClick.apply(this, arguments); + }; + } +})(); +true; +`; + +export const TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT = ` +(function() { + if (window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__) { + if (typeof window.__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__ === 'function') { + window.__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__(); + } + return true; + } + + var historyIndexKey = '__genarrativeMobileHistoryIndex'; + var originalPushState = window.history.pushState.bind(window.history); + var originalReplaceState = window.history.replaceState.bind(window.history); + + function readIndex(state) { + if (!state || typeof state !== 'object') { + return null; + } + + var value = state[historyIndexKey]; + return Number.isInteger(value) && value >= 0 ? value : null; + } + + function stateWithIndex(state, index) { + if (state && typeof state === 'object' && !Array.isArray(state)) { + var nextState = {}; + Object.keys(state).forEach(function(key) { + nextState[key] = state[key]; + }); + nextState[historyIndexKey] = index; + return nextState; + } + + var indexedState = {}; + indexedState[historyIndexKey] = index; + return indexedState; + } + + var currentIndex = readIndex(window.history.state) || 0; + + function postNavigationState() { + var bridge = window.ReactNativeWebView; + if (!bridge || typeof bridge.postMessage !== 'function') { + return; + } + + bridge.postMessage(JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: currentIndex > 0 + })); + } + + function replaceCurrentState() { + try { + originalReplaceState( + stateWithIndex(window.history.state, currentIndex), + '', + window.location.href + ); + } catch (error) { + console.warn('mobile navigation state sync failed'); + } + } + + window.history.pushState = function(state, title, url) { + var nextIndex = currentIndex + 1; + var result = originalPushState(stateWithIndex(state, nextIndex), title, url); + currentIndex = nextIndex; + postNavigationState(); + return result; + }; + + window.history.replaceState = function(state, title, url) { + var result = originalReplaceState( + stateWithIndex(state, currentIndex), + title, + url + ); + postNavigationState(); + return result; + }; + + window.__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__ = postNavigationState; + window.addEventListener('popstate', function(event) { + var nextIndex = readIndex(event.state); + currentIndex = nextIndex === null ? 0 : nextIndex; + if (nextIndex === null) { + replaceCurrentState(); + } + postNavigationState(); + }); + + window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__ = true; + replaceCurrentState(); + postNavigationState(); +})(); +true; +`; + +export const MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT = ` +${BLOCK_WEBVIEW_DOWNLOAD_SCRIPT} +${TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT} +true; +`; diff --git a/apps/mobile-shell/tsconfig.json b/apps/mobile-shell/tsconfig.json new file mode 100644 index 000000000..115b799ef --- /dev/null +++ b/apps/mobile-shell/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "jsx": "react-jsx", + "strict": true, + "types": ["react-native"] + }, + "include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"] +} diff --git a/apps/mobile-shell/vitest.config.ts b/apps/mobile-shell/vitest.config.ts new file mode 100644 index 000000000..c7f1a765d --- /dev/null +++ b/apps/mobile-shell/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +}); diff --git a/docs/README.md b/docs/README.md index c2c64c992..26f01f613 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,12 @@ 微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。 +微信小程序、Expo React Native 移动壳与 Tauri 桌面壳共用的宿主能力契约、method / capability 白名单、H5 facade 和三端边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%AE%BF%E4%B8%BB%E5%A3%B3%E8%83%BD%E5%8A%9B%E7%BB%9F%E4%B8%80%E5%8D%8F%E8%AE%AE-2026-06-17.md)。 + +Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView 安全边界、原生能力实现、构建配置和门禁要求见 [【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md](./%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ExpoReactNative%E4%B8%8ETauri%E5%AE%BF%E4%B8%BB%E5%A3%B3%E6%96%B9%E6%A1%88-2026-06-17.md)。 + +原生壳本地开发与验证命令统一从根工程进入:移动壳使用 `npm run mobile-shell:dev`、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`,桌面壳使用 `npm run desktop-shell:dev`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`,跨端结构和能力门禁使用 `npm run check:native-shells`。 + `/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md)。 `/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 028db89a7..a1dc00759 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -243,6 +243,237 @@ - 影响范围:`scripts/check-pingora-canary-docker.mjs`、`scripts/check-pingora-release-readiness.mjs`、`package.json`、生产运维护栏、Nginx README、Pingora 试点文档和生产运维文档。 - 验证方式:`node --check scripts/check-pingora-canary-docker.mjs scripts/check-pingora-release-readiness.mjs scripts/check-pingora-release-readiness-plan.mjs scripts/check-pingora-realpath-canary-toggle.mjs`、`npm run check:pingora-realpath-canary-toggle`、`npm run check:pingora-canary-docker`、`npm run check:pingora-release-readiness`、`npm run check:nginx-pingora-canary`、`npm run check:production-ops`;有 Docker 镜像或允许拉取时追加 `node scripts/check-pingora-canary-docker.mjs --require-docker --pull`,目标机切换窗口追加 `node scripts/check-pingora-release-readiness.mjs --require-docker --pull-docker --require-nginx --require-live --live-base-url http://127.0.0.1 --live-host <域名>`。 - 关联文档:`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`deploy/nginx/README.md`。 + +## 2026-06-21 移动原生包产物必须显式验收 + +- 背景:`npm run check:native-shells` 会跑 Expo production bundle 和 EAS profile smoke,但不在普通开发机上强制执行 Android APK 或 iOS simulator 包构建;真实构建完成后仍需要一个固定命令证明输出不是空文件、错误压缩包或非原生包。 +- 决策:移动壳新增 `npm run mobile-shell:build-artifacts`,读取 `build/native/mobile/genarrative-mobile-android.apk` 和 `build/native/mobile/genarrative-mobile-ios-simulator.tar.gz`。APK 必须是 ZIP 格式并包含 `AndroidManifest.xml`、`classes.dex`、`assets/index.android.bundle`;iOS simulator 包必须是 gzip tar 并包含 `.app/Info.plist`、`.app/Genarrative`、`.app/main.jsbundle`。该命令只在真实移动构建后运行,不替代 `check:native-shells` 的普通门禁。 +- 影响范围:`apps/mobile-shell/scripts/check-build-artifacts.mjs`、`apps/mobile-shell/package.json`、根 `package.json`、移动端分发构建流程。 +- 验证方式:无移动构建产物时运行 `npm run mobile-shell:build-artifacts` 应明确失败;真实构建后运行同一命令必须通过。普通改动继续运行 `npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`。 + +## 2026-06-21 原生壳生成物必须被 gitignore 覆盖 + +- 背景:移动壳和桌面壳验收会生成 Expo `.expo/`、Expo export smoke、Tauri `target/`、Tauri schema、自动生成权限目录和根目录 `build/native/` 分发产物;只检查这些路径未被 Git 追踪,不能防止后续误删 `.gitignore` 条目后把生成物暴露给开发者手动误加。 +- 决策:`npm run check:native-shells` 必须同时用 `git ls-files` 确认原生壳生成物未被追踪,并用 `git check-ignore -v` 确认这些生成物路径仍被 `.gitignore` 覆盖。手写 capability、权限配置和壳源码仍在生产扫描范围内,不得借生成目录排除规则绕开检查。 +- 影响范围:`.gitignore`、`scripts/check-native-shells.mjs`、Expo / Tauri 构建和分发烟测。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`。 + +## 2026-06-21 原生壳依赖版本门禁不可移除 + +- 背景:Expo / React Native / Tauri 的依赖版本会影响 WebView、权限、capability、构建产物和宿主桥接行为;两端单端配置检查已经锁定 package、lockfile 和 Cargo 解析版本,但根级总验收也需要防止未来重构时把这些锁版本检查从单端脚本中移除。 +- 决策:`npm run check:native-shells` 必须反查移动壳 `check-config.mjs` 继续校验 Expo SDK、React Native、WebView、EAS CLI 和 `package-lock.json` 解析版本;桌面壳 `check-config.mjs` 必须继续校验 Tauri CLI、Cargo manifest、`Cargo.lock` 解析版本和直接依赖关系。升级原生壳底层依赖必须同步更新单端配置检查、锁文件和宿主壳方案文档。 +- 影响范围:`scripts/check-native-shells.mjs`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、原生壳依赖升级流程。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`。 + +## 2026-06-20 移动 HostBridge 消息注入失败边界 + +- 背景:Expo 移动壳通过 WebView `injectJavaScript` 把 HostBridge response 以及 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 等宿主事件回放给 H5;如果 WebView 进程切换、页面卸载或注入同步失败,壳层不能因为响应或事件回灌异常而崩溃。 +- 决策:移动壳所有 HostBridge message 注入必须统一经过 `injectHostBridgeMessage`,该函数捕获同步注入异常,且 shell 卸载后直接丢弃迟到 response;事件注入用 `logMobileHostEventFailure(event, error)` 记录,response 注入用 `logMobileHostBridgeMessageFailure(error)` 记录。移动壳声明的请求 capability 不允许只由 `unsupported(request.method)` case 支撑;配置检查反查运行时 mounted guard、try/catch、ShellApp 注入失败测试、卸载后迟到响应测试和 capability 真实实现边界。 +- 影响范围:`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/ShellApp.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + +## 2026-06-20 桌面图片拖拽坐标非负边界 + +- 背景:Tauri 桌面壳通过系统拖拽事件向 H5 发送 `file.imageDropped`,拖拽坐标来自窗口事件;窗口边缘或平台差异可能产生负数或小数坐标,H5 只负责校验有限 number,不负责裁剪桌面系统坐标。 +- 决策:桌面壳在发送图片拖拽 HostBridge 事件前,必须把拖拽坐标 round 成整数并裁剪到非负值,再组装 import image payload;配置检查反查坐标归一 helper 和对应 Rust 单测。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run desktop-shell:test -- file_drop`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + +## 2026-06-20 桌面图片拖拽候选读取失败不可静默 + +- 背景:桌面壳拖入图片时会按路径列表寻找第一个真实可导入图片;扩展名合法但内容损坏、超限或读取失败的文件如果被静默跳过,用户只会看到拖拽无反应,开发侧也缺少排障线索。 +- 决策:`first_valid_desktop_image_drop_payload(...)` 对扩展名符合图片候选但 payload 组装失败的路径必须记录 `desktop host event failed for file.imageDropped.payload`,然后继续尝试后续候选;目录、非图片扩展名或没有任何有效图片仍保持不派发 `file.imageDropped` payload。 +- 2026-06-21 调整:拖拽图片 payload 组装失败日志只记录固定 `file.imageDropped.payload` 标签,不把本机读取错误、文件内容校验错误或其它 payload 细节写入可分发桌面壳 stderr;配置检查拒绝重新输出 `: {error}`。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::file_drop`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + +## 2026-06-20 移动扫码权限异步取消边界 + +- 背景:Expo 移动壳 `scanner.scanQrCode` 会打开真实相机权限请求和扫码 overlay;如果用户在系统权限 Promise 返回前关闭扫码,旧权限结果不能重新激活 CameraView,也不能完成已经取消的 HostBridge 请求。 +- 决策:`QrScannerOverlay` 必须在 active/requestKey 变化和组件清理时忽略迟到的权限结果;移动壳配置检查必须反查“取消后迟到权限不重新打开 CameraView”的测试用例。 +- 影响范围:`apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/QrScannerOverlay.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + +## 2026-06-20 桌面壳系统能力调用顺序门禁 + +- 背景:Tauri 桌面壳文件导出和本地通知已经在运行时代码中先校验 HostBridge payload,再打开系统保存对话框、读取通知权限或请求通知权限;如果后续重构把系统能力调用提前,非法请求会触达原生系统边界。 +- 决策:桌面壳单端配置检查必须逐函数反查 `file.exportText`、`file.exportImage`、`file.exportAudio` 先调用对应 payload helper 再进入 `.dialog()`,并反查 `notification.showLocal` 先调用 `local_notification_payload(request)` 再进入 `app.notification()`。文件导入仍以用户主动选择文件后的本地 payload 读取和大小 / MIME 校验为准。 +- 影响范围:`apps/desktop-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`。 +- 验证方式:`node apps/desktop-shell/scripts/check-config.mjs`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + +## 2026-06-20 原生壳替身词扫描排除构建产物 + +- 背景:桌面壳单端配置检查会递归扫描生产源码和配置中的替身词;本地或 CI 运行 Tauri / Cargo 后,`apps/desktop-shell/src-tauri/target/` 会包含依赖 `.d` 等生成文件,若纳入扫描会让门禁被缓存内容污染。 +- 决策:生产替身词扫描只覆盖壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Tauri schema `gen/`、Tauri 自动生成权限目录、Cargo / Metro 缓存和 release 构建产物不进入扫描范围。桌面单端配置检查显式跳过 `target/`、`gen/` 和 `permissions/autogenerated/`,根级 `check:native-shells` 继续排除生成目录。 +- 影响范围:`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档、宿主壳能力统一协议文档和共享开发流程记忆。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-17 原生移动与桌面壳统一作为 HostBridge Adapter + +- 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。 +- 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。 +- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件,`share.setTarget` / `share.open` 解析统一分享目标并调用 React Native 系统分享面板,发布分享弹窗在 Expo 移动壳中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`file.exportText` 写入 Expo 缓存文本文件后交给系统分享 / 保存面板,成功只返回文件名和字节数,`haptics.impact` 通过 Expo Haptics 承接 H5 运行时点击反馈;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;H5 复制服务在 native_app 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板,失败后再回退浏览器复制路径;H5 运行时反馈在 native_app 中优先通过 `haptics.impact` 请求真实移动端触觉,宿主不可用或 unsupported 时回退浏览器 `navigator.vibrate`;H5 主站按当前平台阶段同步 `document.title` 并通过 `app.setTitle` 请求宿主窗口标题,Tauri 壳通过主窗口 API 同步非空窗口标题,Expo 移动壳不声明该能力时静默忽略;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,将 `navigation.openNativePage` 实现为 `https://www.genarrative.world` 同源 H5 route 的主窗口受控跳转,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板,H5 发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 +- 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `` 行为,宿主不可用或拒绝时回退浏览器外链。 +- 2026-06-18 外链协议白名单门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 是 `app.openExternalUrl` 唯一协议来源,当前只允许 `http:`、`https:`、`mailto:`、`tel:`;Expo 直接复用共享归一化逻辑,Tauri Rust 侧必须用 URL parser 镜像同一清单,根级 `npm run check:native-shells` 会拒绝共享契约与桌面壳协议清单漂移。 +- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。 +- 2026-06-19 移动壳 WebView 外链协议共源:`apps/mobile-shell/src/shell/navigation.ts` 的 WebView 外链离壳判断必须调用共享 `normalizeHostBridgeExternalUrl`,不得在 shell 层另写协议判断;`apps/mobile-shell/scripts/check-config.mjs` 会拒绝重新硬编码 `mailto:` / `tel:` / `javascript:` 等协议分支,`navigation.test.ts` 用 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 反查当前允许协议。 +- 2026-06-19 移动壳 WebView 外链打开收口:Expo WebView 外链拦截统一调用 `openMobileShellExternalNavigation(Linking, request.url)`,该 helper 先复用共享外链协议 normalizer,再调用 `canOpenURL` 确认系统可处理,最后才 `openURL`;系统不能打开或 URL 被拒绝时只阻断留壳,不伪造成功也不把危险协议交给系统。`ShellApp` 不再内联 `Linking.canOpenURL` / `Linking.openURL` Promise 链,移动壳配置检查和 `navigation.test.ts` 会覆盖该顺序。 +- 2026-06-19 移动壳外链打开 helper 共用:Expo WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `openMobileShellExternalNavigation` 执行系统外链打开动作;HostBridge 分支仍先调用 `normalizeHostBridgeExternalUrlPayload` 保留 payload 错误语义,且 `apps/mobile-shell/src/host-bridge/navigation.ts` 自己承接 `expo-linking` 系统 API 调用,不再让 `dispatch.ts` 直接导入 `Linking` 或维护 `Linking.canOpenURL` / `Linking.openURL` 顺序。移动壳配置检查会拒绝 `app.openExternalUrl` 绕开该 helper 或分发层重新导入 `expo-linking`,避免两条离壳路径漂移。 +- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://www.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。 +- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://www.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。 +- 2026-06-19 原生壳分享桥接边界:Expo `share.setTarget` / `share.open` 的缓存目标、分享 payload 归一、系统分享调用和 HostBridge 响应统一收口在 `apps/mobile-shell/src/host-bridge/share.ts`;Tauri `share.setTarget` / `share.open` 的缓存目标、分享文本生成、剪贴板 fallback 写入和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/share.rs`。两端 `dispatch` 只负责委托对应 share 模块,配置检查会拒绝分发层直接持有分享状态、生成分享文本、写入分享剪贴板结果或包装分享成功响应。 +- 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;`dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。 +- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 组装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。 +- 2026-06-20 移动壳文件桥接载荷边界:Expo `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.exportAudio` 的 DocumentPicker、ImagePicker、File、Sharing 系统交互、用户取消语义、缓存读写编排和 HostBridge 响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`;MIME、大小、base64、文件名清洗、图片 / 音频 bytes 匹配和 picker 结果到 HostBridge payload 的组装统一收口在 `apps/mobile-shell/src/host-bridge/filePayloads.ts`。移动壳单端配置检查和根级 `npm run check:native-shells` 会把 `filePayloads.ts` 纳入结构清单与 HostBridge 源码扫描,避免文件载荷边界重新散落到分发层或 shell 层。 +- 2026-06-20 移动文件动作单测边界:`apps/mobile-shell/src/host-bridge/files.test.ts` 直接覆盖 Expo 文件动作 helper 的文本导出、系统分享不可用、文本 / 文档 / 音频导入、用户取消、图片相册导入、相机权限拒绝和音频二进制导出;单端配置检查会反查这些动作测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免系统文件交互只靠完整 HostBridge bridge 流程间接覆盖。 +- 2026-06-20 移动文件导出分享不可用边界:Expo `file.exportText` / `file.exportImage` / `file.exportAudio` 在 `Sharing.isAvailableAsync()` 返回 false 时必须直接返回 `unsupported_capability`,不得写入 Expo cache,也不得调用 `Sharing.shareAsync` 或伪造 saved 成功;`apps/mobile-shell/src/host-bridge/files.test.ts` 用三类导出参数化覆盖该顺序,配置检查反查“不写缓存”断言。 +- 2026-06-20 移动文件载荷单测边界:`apps/mobile-shell/src/host-bridge/filePayloads.test.ts` 直接覆盖移动壳文件载荷 helper 的 base64、UTF-8 byte、MIME / 扩展名归一、图片 / 音频 bytes 匹配、导出文件名补扩展、导入大小门禁和 ImagePicker payload 转换;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,防止后续只靠完整 HostBridge bridge 流程间接覆盖文件安全边界。 +- 2026-06-20 移动本地通知单测边界:`apps/mobile-shell/src/host-bridge/notifications.test.ts` 直接覆盖 Expo `notification.showLocal` 的已授权 / iOS provisional 权限复用、alert-only 权限请求、权限拒绝失败、iOS 即时调度、Android 固定 channel、共享 payload 归一和结构化 `delivered_to_system` 成功响应;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免移动通知边界只靠完整 HostBridge bridge 流程间接覆盖。 +- 2026-06-20 桌面能力清单单测边界:Tauri `capabilities.rs` 必须用 Rust 单测同时覆盖桌面 runtime capability 清单顺序、无重复、真实桌面能力完整包含,并显式排除 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact` 等未接入能力;桌面单端配置检查会反查该测试边界,避免只靠方案文档或共享 profile 发现桌面壳能力伪声明。 +- 2026-06-20 桌面本地通知契约镜像:Tauri `notification.showLocal` 的 title / body 归一化、长度上限和成功结果 action 必须镜像共享 HostBridge 契约;Rust 侧常量使用 `HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH`、`HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH` 和 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION` 命名,桌面单端配置检查会与 `packages/shared/src/contracts/hostBridge.ts` 比对数值并反查成功结果由该 action 常量组装,避免通知 payload 边界变成桌面壳本地规则。 +- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 +- 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。 +- 2026-06-20 微信受控原生页能力声明:微信小程序壳真实 capability profile 声明 `navigation.openNativePage`,用于承接已经登记并测试的小程序原生页 flow;当前订阅生成结果通知页通过 H5 `requestGenerationResultSubscribePermission()` 调用 `navigateHostNativePage()` 打开 `/pages/subscribe-message/index`,小程序页再调用真实 `wx.requestSubscribeMessage` 并按既有结果协议回灌。根级 `npm run check:native-shells` 必须把该能力反查到共享 profile、微信 `WECHAT_HOST_CAPABILITIES` 镜像、订阅页协议常量、H5 入口、小程序 host-bridge / shell / page 文件和相关测试;该能力不代表开放任意小程序页面跳转。 +- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示受控分享动作,并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达,避免旧壳或裁剪壳露出不可用入口。 +- 2026-06-20 H5 原生能力门控收口:除 `host.getRuntime` 为了支持旧入口 URL 缺少 capability 时回读真实 runtime 可保留特殊判断外,H5 facade 中所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,不得在业务能力函数内直接读取 `runtime.hostCapabilities.includes(...)`,避免各能力复制门控规则;根级 `npm run check:native-shells` 会从共享 `HOST_BRIDGE_METHODS` 自动派生需门控的 request capability 清单,新增 method 时必须同步补齐 H5 facade 门控。 +- 2026-06-20 移动壳未声明 method 覆盖:Expo 移动壳对未进入 `HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 的共享 request method 必须由测试从 `HOST_BRIDGE_METHODS` 自动派生覆盖,并在请求到达时返回明确 `unsupported_method`;平台差异能力如 Android 不声明的 `app.setBadgeCount` 保持独立 `unsupported_capability` 语义,不混入未声明 method 清单,移动壳配置检查必须反查 Android 角标请求失败测试仍存在。 +- 2026-06-20 移动 dispatch 单测边界:`apps/mobile-shell/src/host-bridge/dispatch.test.ts` 直接从 `HOST_BRIDGE_METHODS` 与 `HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 派生未声明 method 清单,覆盖 `dispatchMobileHostBridgeRequest(...)` 返回 `unsupported_method`,避免只靠完整 `bridge.test.ts` 间接证明 `auth.requestLogin`、`payment.request` 等等待真实 SDK 的能力不会伪成功。 +- 2026-06-20 移动壳能力清单单测边界:`apps/mobile-shell/src/host-bridge/capabilities.test.ts` 直接覆盖 Expo 移动壳 `MOBILE_HOST_CAPABILITIES` / `IOS_MOBILE_HOST_CAPABILITIES` 必须引用共享 `HOST_BRIDGE_EXPO_MOBILE_*` profile,Android 只使用 base profile 且不声明 `app.setBadgeCount`,iOS 只额外声明真实角标能力,并且两端在真实 SDK / 渠道流程落地前不得声明 `auth.requestLogin` 或 `payment.request`。根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免移动壳 capability profile 本地复制或伪声明。 +- 2026-06-20 壳文档能力清单反查:`npm run check:native-shells` 会同时反查移动壳 / 桌面壳主状态段落能力清单和后续完整能力清单;主状态段落按集合检查,允许按叙述需要调整顺序,但不得漏写或多写 capability,完整能力清单继续按共享 profile 顺序检查。 +- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。该回读请求的短超时由共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 声明,H5 facade 不得本地重声明。 +- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 由 `miniprogram/host-bridge/protocol.test.js` 和根级 `npm run check:native-shells` 反查共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`。新增能力必须先更新契约和真实壳实现,再通过这些检查。 +- 2026-06-19 宿主上下文 query 契约收口:`packages/shared/src/contracts/hostBridge.ts` 是宿主上下文 query 字段和值的唯一 TypeScript 来源;`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime parser 字段,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query,`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要保留的宿主字段。Expo 壳直接引用共享常量,Tauri Rust 和微信 CommonJS 镜像由 `npm run check:native-shells` / 单壳配置检查反查;微信请求头必须从 `WEB_VIEW_SOURCE_QUERY` 读取 `clientType` / `clientRuntime`,不得另起常量。 +- 2026-06-19 H5 HostBridge 载荷边界收口:`src/services/host-bridge/hostBridge.ts` 作为 H5 facade 也必须直接导入 `HOST_BRIDGE_TEXT_MIME_TYPES`、`HOST_BRIDGE_DOCUMENT_MIME_TYPES`、`HOST_BRIDGE_IMAGE_MIME_TYPES` 和 `HOST_BRIDGE_AUDIO_MIME_TYPES`,只能从共享契约派生本地 Set 用于归一化,不得重新写 MIME 字面量清单;`npm run check:native-shells` 会拒绝 H5 facade 重新复制文本、图片或音频 MIME 边界。 +- 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和 H5 HostBridge 真实调用链禁替身验收散落成容易漏跑的单项命令。 +- 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件;新增 H5 调用点接入 HostBridge 时,必须让自动扫描覆盖对应文件或在同等门禁中证明生产代码没有 mock / fake / stub / TODO / FIXME / 模拟 / 伪造。 +- 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。 +- 2026-06-21 微信 WebView env 诊断日志收口:微信 `web-view` 壳读取小程序 envVersion 失败时只记录 `[web-view] read mini program env failed` 固定标签,不输出 `wx.getAccountInfoSync()` 原生异常对象;根级原生壳门禁拒绝恢复 `console.warn(..., error)`。 +- 2026-06-21 微信九宫切图诊断日志收口:微信 `share-grid` 壳下载封面、读取图片、导出切图、保存相册或最终保存流程失败时,只记录 `[share-grid] ` 固定标签,不输出 `wx.downloadFile`、`wx.getImageInfo`、`wx.canvasToTempFilePath`、`wx.saveImageToPhotosAlbum` 或其它原生错误对象;页面仍只展示 `九宫切图保存失败。` 稳定文案。 +- 2026-06-21 微信支付与订阅诊断日志收口:微信支付参数解析、虚拟支付失败和订阅消息请求失败只记录 `[wechat-pay] ` / `[subscribe-message] ` 固定标签,不输出微信原生错误对象;H5 回灌继续只使用稳定 `wechat payment unavailable` / `wechat subscribe unavailable` 语义。 +- 2026-06-21 微信 WebView 认证诊断日志收口:微信 `web-view` 壳解析认证结果、`wx.login`、小程序登录请求、手机号绑定请求、认证流程和手机号授权拒绝失败时,只记录 `[web-view] ` 固定标签,不输出微信原生错误对象、HTTP response 或授权 detail;页面仍只展示稳定登录 / 绑手机号错误文案。 +- 2026-06-21 微信 WebView 页面事件诊断日志收口:微信 `web-view` 壳加载成功、加载失败和 H5 message 事件只记录 `[web-view] ` 固定标签,不输出 WebView `event.detail`;分享目标解析继续走结构化 message payload,但生产日志不得回吐原生事件体。 +- 2026-06-21 原生壳生产替身词门禁同步:根级 `check:native-shells`、Expo 移动壳单端 `check-config` 和 Tauri 桌面壳单端 `check-config` 都必须拒绝生产壳源码出现 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造 / 未实现 / 临时 / 后续 等替身或未来式占位词;真实未接能力只能通过明确 unsupported 语义表达。 +- 2026-06-21 HostBridge 导航与移动 WebView 进程诊断收口:H5 微信小程序 `navigateTo.fail` 只记录 `[host-bridge] wechat mini program navigation failed` 固定标签,不输出微信原生失败对象;Expo 移动壳 WebView content / render process failure 只记录 `mobile WebView process failed for ` 固定标签,不输出计数、URL、native event 或 detail 对象。 +- 2026-06-19 微信壳路由一致性门禁:`npm run check:native-shells` 必须反查 `miniprogram/app.json.pages`、`miniprogram/host-bridge/protocol.js`、H5 `src/services/host-bridge/hostBridge.ts` 小程序页面常量、H5 `src/services/wechatMiniProgramSubscribe.ts` 订阅授权页面常量、`miniprogram/host-bridge/webView.js` 分享入口 / 分享消息类型、`miniprogram/config.js` source query / 域名格式、`miniprogram/shell/webView.js` 请求头来源标记、H5 runtime parser 和 H5 路由保留字段。新增或调整小程序页面、登录派生 URL、支付页、九宫切图页、订阅页、WebView 来源标记、H5 入口域名、API base URL 或宿主上下文 query 字段时,必须同步这几处常量并保持生产 / 开发域名都显式配置为纯 HTTPS domain;运行时开发域名回退生产域名只作为异常兜底。 +- 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。 +- 2026-06-20 桌面壳未声明 method 禁伪成功:Tauri 桌面壳只声明真实可用 capability;共享 HostBridge method 白名单中未进入桌面 capability profile 的 method,例如 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact`,请求实际到达桌面壳时必须统一返回明确 `unsupported_method`,不得伪造成功或半接入。桌面 Rust 测试必须从 `HOST_BRIDGE_METHODS` 与 `capabilities()` 差集派生 unsupported 覆盖清单,桌面单端配置检查会反查该派生路径,后续共享契约新增 method 时必须同步声明真实桌面能力或补进 unsupported 语义。 +- 2026-06-18 移动壳触觉反馈边界:`haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知值必须返回 `invalid_request`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capability,H5 继续按 HostBridge fallback 处理。 +- 2026-06-19 移动壳触觉反馈模块边界:Expo `haptics.impact` 的 HostBridge payload 解析、共享 style 归一、`light` / `medium` / `heavy` 到 `Haptics.ImpactFeedbackStyle` 的映射、真实 `Haptics.impactAsync(...)` 调用和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/haptics.ts`;`dispatch.ts` 只负责把完整 request 委托给 `runMobileHostBridgeHapticsImpact(...)`,不得直接导入 `expo-haptics`、读取 `HapticsImpactPayload`、调用 `Haptics.impactAsync` 或包装触觉反馈成功响应。移动壳配置检查会覆盖该模块结构、共享 style 边界和 dispatch 委托关系,避免触觉反馈能力散落到分发层。 +- 2026-06-20 移动触觉反馈单测边界:`apps/mobile-shell/src/host-bridge/haptics.test.ts` 直接覆盖 `haptics.impact` 的 `light` / `medium` / `heavy` 到 Expo Haptics style 映射、缺省 `light`、未知 style 不触发设备反馈、HostBridge 成功响应和 `invalid_request` 失败包装;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免触觉反馈边界只靠完整 HostBridge bridge 流程间接覆盖。 +- 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capability,H5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。Expo 图片导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。 +- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capability,H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明,并通过 Expo Notifications 查询 / 请求 `allowBadge` 权限后调用 `setBadgeCountAsync(count)`,只有系统返回 `true` 才报告成功,Android 不声明、不返回成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。 +- 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。 +- 2026-06-19 原生壳角标边界:Expo `app.setBadgeCount` 的 iOS 平台判定、共享上限校验、badge 权限确认、`Notifications.setBadgeCountAsync(count)` 返回值校验和 HostBridge 成功 / 失败响应映射统一收口在 `apps/mobile-shell/src/host-bridge/badge.ts`;Tauri `app.setBadgeCount` 的 payload 校验、清除语义、主窗口 `set_badge_count` 调用和 HostBridge 响应映射统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`。两端 `dispatch` 只负责委托对应 badge 模块,配置检查会拒绝分发层直接导入角标底层 API、重声明数量边界或包装角标成功响应。 +- 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capability,Expo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。 +- 2026-06-19 原生壳外观查询边界:Expo `appearance.getColorScheme` 的系统配色读取、HostBridge 配色归一和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/appearance.ts`;Tauri `appearance.getColorScheme` 的主窗口 `theme()` 读取、`light / dark / unknown` 映射和 HostBridge 响应包装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`。两端 `dispatch` 只负责委托对应 appearance 模块,配置检查会拒绝分发层直接读取系统配色、窗口主题或包装外观查询成功响应。 +- 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`,Tauri 壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发统一状态;桌面隐藏到托盘或最小化都归一为 `background`,`hidden`、`minimized`、`focused`、`blurred` 只进入 `nativeState` 便于排障,不扩展共享 `state`。两端都声明 `host.events` 表示事件通过 HostBridge message 注入,但不把它作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。 +- 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态;Tauri 壳只声明 `network.status`,从 `WEB_APP_ORIGIN` 解析主站 host / port 后做短超时 TCP 可达性查询,暂不声明 `network.statusChanged`,避免把 WebView `online` / `offline` 当作桌面 Rust 网络事实。H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。 +- 2026-06-19 移动壳本地通知边界:Expo `notification.showLocal` 的 payload 归一、权限确认、iOS 仅 alert 且不请求 badge/sound、Android 固定 channel、即时调度、通知 handler 和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/notifications.ts`;`dispatch.ts` 只负责把 HostBridge request 委托给 `showMobileHostBridgeLocalNotification(...)`,不得直接调用 `expo-notifications` 调度或权限 API,不得重新做通知 payload 归一,也不得包装本地通知成功响应。移动壳配置检查会覆盖该模块结构、固定 channel、即时调度形态和 dispatch 委托关系,避免后续混入远程推送、后台推送或散落的本地通知实现。 +- 2026-06-18 移动壳 WebView 状态重放:Expo WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、受控刷新、H5 刷新和系统回收 WebView 进程后的新 JS 上下文;补发不新增 HostBridge capability,也不向 `about:blank`、外域或错误页注入宿主状态。 +- 2026-06-20 移动壳加载失败边界:Expo 壳 `onError` / `onHttpError` 只对同源 H5 主页面展示原生失败兜底层,外域、`about:blank`、危险协议、favicon 和非当前主页面资源失败不得触发兜底。兜底层可以用完整 URL 判断是否属于当前主文档,但返回给 UI 的 `url` 只保留 `origin + pathname`,`detail` 只使用稳定文案,不展示 query、hash 或系统原生 description。移动壳配置检查会反查 `loadFailure.test.ts` 的同源、favicon、当前主页面、URL 脱敏和稳定文案测试,避免错误页策略漂移或泄露 H5 运行态上下文。 +- 2026-06-18 桌面壳 WebView 状态重放:Tauri 主 WebView 每次页面加载完成后都会回放当前 `app.lifecycle`,覆盖托盘刷新、`app.reloadWebView` 和 H5 自刷新后的新 JS 上下文;桌面 runtime 同步声明 `host.events` 表示生命周期、返回栈和拖拽图片事件通道可用,但桌面暂不声明 `network.statusChanged`,仍不开放 Tauri event 插件或额外 command。 +- 2026-06-18 桌面壳 H5 返回栈事件:Tauri 壳开始声明 `navigation.canGoBack`,但只通过固定注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈并派发 HostBridge event;不把该能力实现为 request method,不开放 H5 到 Tauri 的 event 写入通道,也不声明跨文档 native back-forward list 真相。 +- 2026-06-18 移动壳 H5 返回栈事件:Expo 壳开始用固定 WebView 注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈,并通过内部 `genarrative.mobile.historyState` 消息回传给壳层;壳层把该状态与 `react-native-webview` 原生 `canGoBack` 合成为 HostBridge `navigation.canGoBack` 事件。Android 返回键优先回退 H5 当前文档路由栈,H5 不可回退时才走 WebView 原生 `goBack()`;该内部消息不是 HostBridge request method,不开放通用 H5 -> 原生事件通道,外域 / 危险页面消息仍在进入 HostBridge 前丢弃。 +- 2026-06-18 外部生成队列轮询接入宿主网络状态:H5 新增 `useHostNetworkOnline()`,宿主未声明网络能力时按在线处理以保持浏览器和旧壳行为;宿主明确 `isConnected=false` 或 `isInternetReachable=false` 时,平台外部生成队列概览暂停 HTTP 轮询,恢复在线后重新刷新。该能力只减少离线请求,不改变外部生成队列、作品架、弹窗或后端任务状态事实。 +- 2026-06-18 桌面图片导入:新增 `file.importImage` 与 `file.imageDropped` HostBridge capability,Tauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。拖入目录、文本、损坏图片或没有任何有效图片时不向 H5 派发 `file.imageDropped` payload,桌面壳配置门禁会反查该单测边界。 +- 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;picker 调用必须固定为单选、禁用编辑、禁用 EXIF、请求 base64 且 `mediaTypes` 只允许 `images`,不得扩大到视频或任意媒体。成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`。Expo 图片导入的相册权限、ImagePicker 调用、MIME / 体积 / 图片字节校验和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。 +- 2026-06-18 移动图片拍摄导入:Expo 壳新增 `file.captureImage` HostBridge capability,通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则;相机 picker 必须禁用编辑、禁用 EXIF、请求 base64 且 `mediaTypes` 只允许 `images`,成功回传 `action=captured`,不暴露设备本地 URI;该拍摄能力不使用麦克风权限,移动壳麦克风权限只服务同源 H5 实时玩法。Tauri 壳不声明该能力,不伪造桌面拍摄。Expo 图片拍摄的相机权限、ImagePicker 调用、MIME / 体积 / 图片字节校验和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。 +- 2026-06-19 移动二维码扫描:Expo 壳新增 `scanner.scanQrCode` HostBridge capability,通过 `expo-camera` 请求相机权限并打开真实扫码 overlay,成功只返回共享契约清洗后的二维码文本和 `qr_code` 格式,空值、控制字符和超长文本按 `normalizeHostBridgeQrCodeValue` 处理;HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/scanner.ts`,`dispatch.ts` 只委托扫码请求。用户关闭扫码返回 `cancelled`,H5 个人中心扫码入口不再连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中返回 `unsupported_method`,不声明 capability、不伪造桌面扫码;宿主缺能力或非法结果时 H5 继续走原浏览器扫码 fallback。 +- 2026-06-20 移动壳扫码单测边界:`apps/mobile-shell/src/host-bridge/scanner.test.ts` 直接覆盖扫码 helper 的订阅状态初始值、进行中 requestKey、并发扫码拒绝、非法完成不清 pending、成功完成的二维码值清洗、用户取消 `cancelled`、宿主失败 `host_error`、无 pending 时的空操作和 HostBridge 成功响应包装;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免扫码状态机只靠完整 bridge 流程或 overlay 测试间接覆盖。 +- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `` 路径,不新增玩法侧上传分叉。 +- 2026-06-18 移动壳安全区:Expo 壳根布局使用 `react-native-safe-area-context` 的 `SafeAreaProvider` 与四边 `SafeAreaView` 保护 WebView,避免 H5 主站内容贴进 iOS 刘海、底部 Home Indicator、Android 状态栏或横屏边缘;该能力属于宿主壳布局保护,不新增 H5 占位 UI,不改变玩法 runtime 或 HostBridge capability。`safeArea.test.ts` 必须证明 top / right / bottom / left 四边固定覆盖,移动壳配置检查会反查该测试边界。 +- 2026-06-18 移动壳方向策略:Expo 壳 `orientation` 固定为 `default`,不锁竖屏或横屏;后续固定玩法和 AI H5 sandbox 的方向需求由设备方向、H5 响应式布局和玩法自身画布适配承接,壳层只负责安全区、WebView 容器和 HostBridge。移动壳配置检查和 Expo public config smoke 会拒绝重新锁定 portrait / landscape。 +- 2026-06-18 移动壳键盘布局:Expo Android 壳 `softwareKeyboardLayoutMode` 固定为 `resize`,让系统键盘打开时真实调整 WebView 可视高度;H5 继续使用已有 viewport / 输入法聚焦适配承接创作表单、聊天输入和玩法输入框,壳层不新增键盘遮挡补偿 UI、不伪造键盘状态。移动壳配置检查和 Expo public config smoke 会拒绝该字段缺失或漂移。 +- 2026-06-18 移动壳媒体策略:Expo WebView 允许内联媒体播放和用户触发的全屏视频,但保留 `mediaPlaybackRequiresUserAction`,不允许无手势自动播放;固定玩法和 AI H5 sandbox 的音频仍由 H5 用户开关、运行态状态和宿主生命周期控制,壳层不注入额外播放器或假播放状态。移动壳配置检查会拒绝 WebView 媒体策略漂移。 +- 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://www.genarrative.world`,以及本机开发联调 `http://127.0.0.1`、`http://localhost`、`http://[::1]`;空值、相对路径、外域、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。 +- 2026-06-18 移动壳主动导航上下文:Expo 壳的 `navigation.openNativePage` 与 deep link 都必须复用 `buildMobileShellUrl(...)` 补写 `native_app`、`expo_mobile`、真实平台、版本和 capability 清单;受控导航只接受当前允许 origin 的同源 H5 URL。移动壳配置检查会拒绝主动导航或 deep link 绕过该宿主上下文构造入口。 +- 2026-06-20 移动壳导航单测边界:`apps/mobile-shell/src/host-bridge/navigation.test.ts` 直接覆盖 `app.openExternalUrl` 的共享外链 helper 调用、危险 URL 拒绝、系统不能打开时的 `host_error`,以及 `navigation.openNativePage` 的同源 H5 跳转、宿主上下文补写、缺失 navigation adapter 的 unsupported 语义和 `app.reloadWebView` adapter 调用;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免宿主导航边界只靠 WebView shell 测试或完整 bridge 流程间接覆盖。 +- 2026-06-18 移动壳协议常量来源:Expo 壳的 HostBridge 事件注入、入口 URL `bridgeVersion`、`host.getRuntime` 回包和 Expo public config smoke 必须使用 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`,不得在壳层重新写死协议名或版本字面量;配置检查会拒绝这些常量漂移。 +- 2026-06-19 公开 Web origin 单一来源:原生壳允许加载 / 分享 / 跳转的公开 H5 主站 origin 以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` / `HOST_BRIDGE_PUBLIC_WEB_URL` 为源;Expo 移动壳只能通过 `DEFAULT_MOBILE_SHELL_WEB_URL` / `ALLOWED_PRODUCTION_WEB_ORIGIN` 语义别名引用共享常量,Tauri 桌面壳 `WEB_APP_ORIGIN` 作为 Rust 运行时镜像常量必须由 `apps/desktop-shell/scripts/check-config.mjs` 反查同一共享值。两端不得在分享、WebView policy、启动 URL 或桌面导航里另行复刻 `https://www.genarrative.world` 作为独立真相。 +- 2026-06-18 桌面壳协议常量来源:Tauri Rust 侧 `host_bridge/protocol.rs` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`、桌面入口 URL `bridgeVersion`、HostBridge event 注入和 runtime 回包必须与 `packages/shared/src/contracts/hostBridge.ts` 保持一致;桌面配置检查会反查共享契约并拒绝协议名或协议版本漂移。`tauri.conf.json` 只保留基础入口,`shell/url.rs` 统一补写桌面宿主上下文和真实 capability 清单,配置检查会拒绝把 `hostCapabilities` 等宿主 query 长串重新写回 Tauri 配置。 +- 2026-06-18 移动壳默认入口:Expo 壳默认 H5 地址固定为 `https://www.genarrative.world/`,开发联调本机 Vite 必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 +- 2026-06-18 移动壳安装包身份:Expo 移动壳的 iOS bundle identifier 与 Android package 统一固定为 `world.genarrative.mobile`,应用版本固定为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续分发安装包时递增构建号 / versionCode,产品版本号按发布节奏调整。移动壳配置检查会校验 `app.json` 与 `package.json` 版本一致,并拒绝缺失或漂移的包标识,当前不写入假商店元数据、假更新端点或占位渠道 SDK 配置。 +- 2026-06-19 移动壳 HostBridge 版本运行时来源:Expo 移动壳的 H5 入口 query 和 `host.getRuntime` 回包都读取 `MOBILE_SHELL_HOST_VERSION`,该值必须从移动壳 `app.json` 的 Expo `version` 配置解析,异常配置只回退到与 `app.json` / `package.json` 一致的受检 fallback;配置检查会拒绝 `App.tsx`、`bridge.ts` 或 `runtime.ts` 重新散落硬编码版本,避免安装包版本升级时 H5 首屏上下文与 runtime 回读分叉。 +- 2026-06-19 移动壳 runtime 桥接边界:Expo `host.getRuntime` 的平台归一、hostVersion、bridgeVersion、capability 清单组装和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/runtime.ts`;`dispatch.ts` 只负责把 `host.getRuntime` 委托给 `getMobileHostBridgeRuntimeResponse(...)`。移动壳配置检查会拒绝分发层重新读取 `MOBILE_SHELL_HOST_VERSION`、`HOST_BRIDGE_VERSION`、`resolveMobileHostCapabilities(...)`、`Platform.OS` 或包装 runtime 成功响应,避免入口 URL、runtime 回包和能力清单继续分叉。 +- 2026-06-19 桌面壳 runtime 桥接边界:Tauri `host.getRuntime` 的平台归一、hostVersion、bridgeVersion、capability 清单组装和 HostBridge 成功响应包装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`;`dispatch.rs` 只负责把 `host.getRuntime` 委托给 `desktop_host_bridge_runtime_response(&request)`。桌面壳配置检查会拒绝分发层重新读取 `env!("CARGO_PKG_VERSION")`、`HOST_BRIDGE_VERSION`、`capabilities()`、`desktop_platform()` 或包装 runtime 成功响应,避免入口 URL、runtime 回包和能力清单继续分叉。 +- 2026-06-18 移动壳发布通道边界:Expo 移动壳默认显式关闭 OTA 更新,只允许 `updates.enabled=false`;在真实发布通道、更新端点、签名 / 回滚策略和团队发布流程落地前,不得配置 `runtimeVersion`、release channel、EAS channel、`expo-updates` 插件或移动端 crash / analytics / CodePush 依赖。移动壳配置检查和 Expo public config smoke 会拒绝这些发布通道能力被提前打开,根 `package-lock.json` 也不得解析 `expo-updates`、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等真实发布 / 观测 SDK。 +- 2026-06-18 移动壳观测与渠道 SDK 初始化边界:移动壳生产入口、HostBridge、启动 URL 和 runtime 配置不得提前初始化 Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 或 Expo Updates;这些 SDK 必须等真实发布通道、采集字段、用户授权、隐私披露、签名 / 回滚策略和团队发布流程落地后逐项接入。配置检查会同时拒绝相关依赖、锁文件解析、Expo 配置和源码初始化片段;`expo-application` 可能由 Expo 自身传递解析,但项目不得主动 direct 依赖它实现渠道逻辑。 +- 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。 +- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。 +- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 +- 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。 +- 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还必须按 MIME 给系统分享 / 保存面板补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免缓存文件名与真实图片类型漂移。配置检查和移动壳测试覆盖该边界。 +- 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 +- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝自动生成权限目录缺失、权限文件集合漂移、多余 command、权限列表顺序漂移和残留的自动生成权限文件。 +- 2026-06-18 桌面壳 capability 最小化:Tauri 主窗口 capability 只授予 `allow-host-bridge-request`,不得授予 `core:default`、`core:*:default`、任意 core 子权限或 dialog / fs / notification / opener / clipboard / deep-link / window-state 等插件权限。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再经 `host_bridge_request` 分发。 +- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 +- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Expo 壳捕获原生异常时只透传共享 `HostBridgeError.code` 白名单内且 `message` 为字符串的协议错误,Tauri 壳的 `failed(...)` 出口也必须先校验同一错误码白名单;未知原生错误对象或非法错误码统一归一为 `host_error` 和固定失败文案,不把 native 私有字段、任意错误码或非字符串 message 回传给 H5。 +- 2026-06-20 桌面 HostBridge command facade 单测边界:Tauri 唯一 `host_bridge_request` command 必须先通过 `prepare_host_bridge_request(...)` 做 envelope、method 和 request id 校验,再进入 `HostBridgeReplayState` reserve / wait / execute;`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs` 的单测必须覆盖非法 envelope 在 replay 前返回 `invalid_request` 且不会占用对应 request id 的 replay slot,桌面配置检查会反查该测试存在。 +- 2026-06-20 桌面 HostBridge replay 内部失败边界:Tauri `HostBridgeReplayState` 的 cache lock、slot lock 和 condvar wait 异常不得 panic,也不得把 Rust 内部错误细节回传给 H5;桌面壳只写 `desktop host bridge replay failed for ...` 固定阶段标签,不把 mutex / condvar 错误文本写入 stderr,并统一返回 `host_error: desktop host bridge request failed`。桌面配置检查反查 `reserve(...)` 的 `Result` 出口、稳定错误响应、label-only 诊断和 poison lock 单测。 +- 2026-06-20 移动 HostBridge runtime 能力回包边界:Expo `host.getRuntime` 回包里的 `capabilities` 必须直接等于共享契约 `HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES` 或 `HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES`,并使用与 `platform` 字段一致的归一平台值选择 profile;移动壳 runtime 单测和配置检查反查精确 profile 断言,避免 H5 实际消费的能力回包与入口 URL 能力 query 或共享 profile 分叉。 +- 2026-06-20 移动 production bundle 宿主上下文边界:`apps/mobile-shell/scripts/check-expo-export.mjs` 必须读取 iOS / Android Metro export bundle,确认可分发 bundle metadata 是 Metro version 0、只包含当前平台 `fileMetadata`、指向 Hermes `AppEntry-*.hbc`,且 bundle 包含共享生产 H5 URL、`native_app`、`expo_mobile`、`hostCapabilities`、`hostVersion` 和 `bridgeVersion`,并不包含本机开发 H5 URL;移动壳配置检查反查 export smoke 的这些 token 和 metadata 结构门禁,避免 production bundle 丢失宿主上下文、混入本机入口或导出形态漂移。 +- 2026-06-21 移动壳本机 H5 入口边界:`EXPO_PUBLIC_GENARRATIVE_WEB_URL` 只允许生产主站或开发态显式本机 H5 联调地址;`ShellApp` 必须用 `__DEV__` 控制 `allowLocalDevelopment`,production runtime 遇到 `127.0.0.1`、`localhost` 或 `::1` 时回退到共享生产主站。`buildMobileShellUrl(...)` 默认不得隐式放行本机入口,Deep Link 和 `navigation.openNativePage` 必须显式传递同一基准 URL 归一选项,避免可分发移动壳被环境变量、deep link 或 HostBridge 导航带到本机调试页面。 +- 2026-06-20 桌面 release 主窗口宿主上下文边界:Tauri release 配置只保留基础 `index.html`,Rust app 装配层必须在主窗口启动配置单测里断言补齐 `clientRuntime`、`clientType`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;桌面单端配置检查反查这些断言存在,避免首屏 H5 丢失桌面壳运行态 query 后只靠 runtime 回读补救。 +- 2026-06-20 移动壳协议 helper 单测边界:`apps/mobile-shell/src/host-bridge/protocol.test.ts` 直接覆盖 Expo 移动壳 HostBridge JSON 解析、envelope 和 request id 校验、未知 method 拒绝、ok / failure 响应包装、unsupported / invalid_request 错误构造,以及 native helper 错误归一时只透传共享错误码与字符串 message,不泄露非法错误码、nativeStack 或其它私有字段;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免协议边界只靠完整 bridge 流程间接覆盖。 +- 2026-06-20 移动扫码 overlay 单测边界:`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx` 直接覆盖移动扫码 overlay 的相机权限请求、二维码扫码成功、权限拒绝失败和关闭取消;单端配置检查会反查该组件测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动 shell 层结构清单,避免扫码 UI 容器只靠 `ShellApp.test.tsx` 的完整 HostBridge 流程间接覆盖。 +- 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。 +- 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。 +- 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,拒绝 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`,并要求两者 `script-src` 精确保持为 `'self'`。 +- 2026-06-19 桌面壳 macOS 媒体权限说明:Tauri 桌面壳不新增摄像头 / 麦克风 HostBridge method,但同源 H5 可以继续通过浏览器标准 `getUserMedia` 承接儿童动作热身 Demo 的实时摄像头输入和汪汪声浪正式 runtime 的实时麦克风输入;macOS 分发包必须通过 `bundle.macOS.infoPlist="Info.plist"` 合并 `NSCameraUsageDescription` 与 `NSMicrophoneUsageDescription`,文案只描述同源 H5 实时动作 / 声音玩法。桌面壳配置检查会校验 plist 路径与文案,防止缺少系统授权说明或把媒体权限扩成通用宿主采集能力。 +- 2026-06-18 壳生产代码禁用临时替身:微信 / Expo / Tauri 三端壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造 / 未实现 / 临时 等脚手架或替身词;测试文件仍可使用 mock。两端原生壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 会统一扫描 `miniprogram`、`apps/mobile-shell`、`apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身、占位文案或伪实现带进可分发壳或真实调用链。 +- 2026-06-19 H5 HostBridge 调用链自动扫描:根级 `npm run check:native-shells` 从 `src/` 生产文件自动收集真实宿主能力 facade 的直接消费者,以及 `useHostLifecycleActive`、`useHostNetworkOnline`、`platformProfileHostClipboard` 等薄 wrapper 的消费者。H5 业务文件允许正常表单 `placeholder` 属性、业务占位图文案和真实兼容 / 故障语义中的“未实现”“临时”表述,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹;壳源码和配置仍继续禁用 placeholder / 占位 / 未实现 / 临时。 +- 2026-06-18 原生壳本地生成物边界:Expo `.expo/`、Expo export smoke 临时目录、Tauri `target/`、Tauri schema `gen/` 和 Tauri 自动生成权限目录都必须保持 gitignored,不作为生产源码敏感词扫描输入;手写 capability / 权限配置仍在扫描范围内。 +- 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。 +- 2026-06-18 桌面壳 bundle 图标集:Tauri 桌面壳从现有真实品牌 PNG `apps/desktop-shell/src-tauri/icons/icon.png` 派生 `32x32.png`、`128x128.png`、`128x128@2x.png`、`icon.ico` 和 `icon.icns`,并在 `bundle.icon` 中同时声明这些平台图标。检查脚本会校验 PNG 尺寸、ICO 多尺寸头部、ICNS 容器长度和 bundle 图标列表,避免后续退回单图标或替换为非品牌 / 占位素材。 +- 2026-06-18 移动壳网络安全元数据:Expo 移动壳默认包配置显式禁用 Android 明文流量 `usesCleartextTraffic=false`,iOS ATS 禁用任意加载 `NSAllowsArbitraryLoads=false`,并设置 `ITSAppUsesNonExemptEncryption=false` 作为当前未接入自定义加密能力的出口合规声明;本地 Vite 联调只通过 development build 显式环境变量进入,不把任意明文流量开关带进默认包配置。 +- 2026-06-19 移动壳同源 H5 麦克风权限:Expo 移动壳允许 `RECORD_AUDIO` 和 iOS 麦克风用途文案,仅用于同源主站 H5 中需要实时声音输入的正式玩法,例如汪汪声浪 `published` runtime 的 `getUserMedia({ audio: true })` 音量采样;WebView 必须保持 `mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"`,外域页面仍不能留在带 HostBridge 的 WebView 内。该权限不新增 HostBridge method,不代表后台录音、远程语音 SDK 或 AI H5 sandbox 能直接访问宿主能力;`expo-camera` 与 `expo-image-picker` 的麦克风用途文案、Android `RECORD_AUDIO`、Expo public config 和 WebView 媒体捕获策略由移动壳配置检查统一约束。 +- 2026-06-18 移动壳 Android 自动备份关闭:Expo 移动壳必须保持 `android.allowBackup=false`,避免 WebView cookie、localStorage、缓存文件和宿主文件导入导出中间态进入 Google Drive 自动备份 / 恢复链路;正式业务事实仍以后端账号、作品、钱包和草稿状态为准。配置检查会拒绝恢复 Android 默认允许备份的包配置。 +- 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。 +- 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 +- 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。 +- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/` 下同名职责文件承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Expo `protocol.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`scanner.ts`、`share.ts` 和 facade `bridge.ts` 分别对齐 Tauri `host_bridge/protocol.rs`、`capabilities.rs`、`dispatch.rs`、`files.rs`、`share.rs`、`mod.rs`,根 `App.tsx` 只装配 `src/shell/ShellApp.tsx`,不直接进口 HostBridge。Tauri `shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、窗口状态持久化和 WebView 门面。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 +- 2026-06-19 桌面壳单端结构门禁:`apps/desktop-shell/scripts/check-config.mjs` 与根级 `npm run check:native-shells` 同步校验 `src-tauri/src` 根目录、`host_bridge/` 和 `shell/` 的生产模块清单,并要求 `main.rs` 保持薄入口、`app.rs` 承接 Tauri builder / plugin / window 装配。后续新增桌面宿主能力必须先按 HostBridge / shell 职责边界登记文件和测试,不能只靠根门禁或把能力逻辑塞回 `main.rs`。 +- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 +- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + +## 2026-06-17 H5 宿主壳能力统一走 HostBridge + +- 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。 +- 决策:前端宿主运行态识别、微信小程序 JS SDK 加载、原生页跳转、支付跳转、登录跳转、九宫切图和 `postMessage` 统一收口到 `src/services/host-bridge/hostBridge.ts`,业务层优先调用 `getHostRuntime`、`requestHostLogin`、`requestHostPayment`、`navigateHostNativePage`、`setHostShareTarget` 和 `openHostShareGrid`。`authService`、分享服务、订阅授权和个人中心充值可保留兼容导出或业务编排,但不再自行加载微信 JS SDK 或直接判断 `wx.miniProgram`。固定内置玩法不走代码包下载流程;AI 生成 H5 沙箱后续单独定义受限 `GameBridge`,不得直接暴露完整 `HostBridge`。 +- 影响范围:`src/services/host-bridge/`、`src/services/authService.ts`、`src/services/payment/paymentPlatform.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/usePlatformProfileCenterController.ts`、微信小程序壳和未来原生 App 壳接入。 +- 验证方式:微信小程序首点登录仍打开原生登录页;小程序支付仍跳转 `/pages/wechat-pay/index` 并保留 hash 回灌确认;订阅授权仍跳转 `/pages/subscribe-message/index` 且返回不阻断生成;普通浏览器分享、H5 支付和 Native 二维码支付不受影响。前端验证运行 HostBridge、auth、payment、分享、订阅和个人中心充值相关定向测试,并执行 `npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + ## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust - 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。 @@ -2656,3 +2887,805 @@ - 补充:带 `dialogId` 的 `canvasCompletion` 必须读取后端当前 layout 中的最新 generation dialog placeholder;等待期间用户移动占位时,结果层要跟随最新占位。无 dialog 的重绘 / UI 素材提取等入口使用明确的右侧完成占位;生成器已删除时不把结果重新塞回画布。 - 影响范围:`server-rs/crates/api-server/src/editor_generation_queue.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`。 - 验证方式:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳 WebView 刷新能力只保留受控当前页刷新 + +- 背景:Expo 移动壳和 Tauri 桌面壳都需要一个真实的宿主级刷新入口,供 H5 在检测到资源、登录态或运行态需要重新载入时请求宿主刷新当前容器;该能力不能演变成任意 URL 导航或原生 WebView ref 透传。 +- 决策:新增 HostBridge method `app.reloadWebView` 和 H5 facade `reloadHostWebView()`。移动端只调用当前 `react-native-webview` 的 `reload()`,桌面端只调用 Tauri 主 `WebviewWindow.reload()`;该 method 不接受 payload,成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。继续把同源跳转留给 `navigation.openNativePage`,外链离开容器留给 `app.openExternalUrl`。 +- 2026-06-18 追加:Expo 移动壳的外链离开容器路径必须在协议白名单后再调用 `Linking.canOpenURL`;WebView 外域导航只有当前设备确认可打开时才调用 `Linking.openURL`,`app.openExternalUrl` 在系统不可打开时返回 `host_error`。该收紧不新增 HostBridge method,不把危险协议、相对路径或设备不可处理的外链留在带完整 HostBridge 的 WebView 内。 +- 2026-06-18 追加:`AuthGate` 登录态身份边界刷新改为优先调用 `reloadHostWebView()`,用于登录成功、退出登录或从已登录变为未登录后的主站重新初始化;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`,普通 token refresh、账号资料更新、主题和音量变化仍不触发整页刷新。 +- 2026-06-20 追加:Expo 移动壳的 iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 首次触发时复用当前 WebView 的受控 `reload()` 路径;短时间内连续进程恢复失败必须记录 `mobile WebView process failed` 日志,并转入既有原生加载失败兜底层。用户重试会清空进程失败窗口并再次刷新当前 WebView;全程不改写 URL、不注入额外脚本、不新增宿主恢复页面。 +- 2026-06-18 追加:Expo 移动壳的 `onError` / `onHttpError` 只对同源 H5 主页面展示原生加载失败兜底层,用户重试时仍复用当前 WebView `reload()`;兜底不接管外域、危险协议、`about:blank` 或 favicon 失败,也不向 H5 注入错误事件。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳音频文件导入只返回受控内容副本 + +- 背景:木鱼等固定玩法的音频上传面板需要在 Expo 移动壳和 Tauri 桌面壳内走真实系统选择器;如果直接暴露设备 URI、本机路径或通用文件系统能力,会把一次用户选择扩大成长期本地文件权限。 +- 决策:新增 HostBridge method `file.importAudio`、H5 facade `importHostAudioFile()` 和通用音频输入面板接入。移动端通过 Expo DocumentPicker,picker 展示范围包含 `audio/*` 和当前允许的精确音频 MIME;桌面端通过 Tauri 系统文件选择框。HostBridge 返回 H5 前,两端仍只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,并要求音频 bytes 与归一后的 MIME 匹配,单次不超过 20 MiB。宿主成功时只返回清洗后的文件名、MIME、base64 内容和字节数,不返回设备 URI 或本机绝对路径,也不开放通用文件系统。H5 将宿主结果转换成现有浏览器 `File`,继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路。Expo 音频导入的 DocumentPicker 调用、大小校验、base64 读取和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/components/common/CreativeAudioInputPanel.tsx`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳音频导出只写入 H5 已持有字节 + +- 背景:木鱼创作的本地录音 / 上传音频会在浏览器侧处理成 `Blob`,原生壳需要能把这份本地处理结果交给系统保存 / 分享;但宿主不能替 H5 读取任意本地音频文件,也不能把文件系统能力扩成通用读写。 +- 决策:新增 HostBridge method `file.exportAudio`、H5 facade `exportHostAudioFile()` 和通用音频输入面板导出入口。H5 只传当前页面已持有的 `base64Data`、清洗后的文件名和允许的 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` MIME;移动端写入 Expo 缓存音频后交给系统分享 / 保存面板,桌面端打开 Tauri 系统保存对话框并写入音频字节。单次不超过 20 MiB,成功只返回文件名和字节数。Expo 音频导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。`CreativeAudioInputPanel` 只在当前资产包含本地 `Blob`、`fileName`、允许 MIME 且宿主声明 `file.exportAudio` 时显示导出入口;远端已上传音频不展示导出。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/components/common/CreativeAudioInputPanel.tsx`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳 dev 端口固定 + +- 背景:Linux 多用户 dev 脚本会把 `npm run dev:web` 自动映射到用户端口段,而 Tauri `devUrl` 固定为 `http://127.0.0.1:3000/`;如果桌面壳直接运行 `tauri dev`,`beforeDevCommand` 启动的 H5 可能不在 Tauri 加载的端口上。 +- 决策:桌面壳 `apps/desktop-shell/package.json` 的 `dev` 脚本显式设置 `WEB_PORT=3000 tauri dev`,让根 H5 dev server 与 Tauri `devUrl` 使用同一固定本地入口。该固定只作用于桌面壳调试,不改变普通 `npm run dev` / `npm run dev:web` 的 Linux 多用户端口段机制。 +- 影响范围:`apps/desktop-shell/package.json`、`apps/desktop-shell/scripts/check-config.mjs`、桌面壳方案文档。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run check:native-shells`。 + +## 2026-06-18 创作 Agent 文档上传接入原生壳文本导入 + +- 背景:创作 Agent 工作台已有“上传文档”入口,但在 Expo / Tauri 壳内仍只触发浏览器隐藏文件输入;移动和桌面壳已经具备 `file.importText` 的真实系统文档选择能力。 +- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.importDocument` 时优先调用 `importHostDocumentFile()`,把宿主返回的文本类文档或 DOCX base64 副本转换成浏览器 `File` 后继续调用现有 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才调用 `importHostTextFile()` 兜底。原生壳只负责受控选择和返回文档副本,不暴露设备 URI、本机路径或通用文件系统,也不在前端绕过后端文档解析、256KB 解析限制、docx 支持或错误口径。普通浏览器、小程序和未声明能力的裁剪壳继续使用原 `` 路径。 +- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 创作 Agent 会话导出接入原生壳文本导出 + +- 背景:Expo / Tauri 壳已经实现 `file.exportText`,但 H5 创作 Agent 工作台还没有消费该能力;原生壳内又禁止网页自动下载和 `` 直接落盘,需要通过受控 HostBridge 保存用户可带走的文本。 +- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.exportText` 时显示“导出会话”图标按钮,把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装为 `text/markdown`,并在 H5 侧按共享 5 MiB 上限计算 UTF-8 byte 后再调用 `exportHostTextFile()`。Expo 文本导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。普通浏览器、小程序、旧壳或裁剪壳不展示该入口;宿主取消或 unsupported 不触发浏览器下载回退,错误统一显示在 composer 上方现有状态条。 +- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 反馈凭证上传接入原生壳图片导入 + +- 背景:帮助与反馈页的上传凭证入口在原生壳内仍只能触发浏览器隐藏文件输入;Expo / Tauri 壳已具备受控 `file.importImage` 图片选择能力,Expo 移动壳还具备真实 `file.captureImage` 相机拍摄能力。 +- 决策:`PlatformFeedbackView` 在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有凭证预览和提交逻辑;移动壳声明 `file.captureImage` 时额外展示“拍摄凭证”入口,调用 `captureHostImageFile()` 后复用同一转换、校验和提交链路。反馈页仍保留最多 4 张、单张 1MB、总 4MB、图片 MIME 和 data URL payload 校验;宿主不暴露设备 URI、本机绝对路径或通用文件系统。普通浏览器、小程序、Tauri 桌面壳和未声明拍摄能力的裁剪壳不显示拍摄入口。 +- 影响范围:`src/components/platform-entry/PlatformFeedbackView.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 个人头像上传接入原生壳图片导入 + +- 背景:个人资料头像上传在 Expo / Tauri 壳内仍只触发浏览器隐藏文件输入,移动端相册选择和桌面系统选择框能力没有被头像流程复用。 +- 决策:`RpgEntryHomeView` 的头像上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有头像读取、类型校验、5 MiB 大小限制、方形裁剪和 `updateAuthProfile({ avatarDataUrl })` 链路。宿主只负责受控图片选择,不暴露设备 URI、本机绝对路径或通用文件系统,也不新增 React Native / Tauri 专属头像编辑页;普通浏览器、小程序和未声明能力的裁剪壳继续使用原 ``。 +- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile avatar upload"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 邀请码和兑换码填入接入原生壳剪贴板读取 + +- 背景:Expo / Tauri 壳已经具备真实 `clipboard.readText` 纯文本读取能力,但 H5 个人中心的邀请码填写和兑换码弹窗仍只能手输;用户从聊天、短信或活动页复制代码后在原生壳内缺少受控粘贴入口。 +- 决策:个人中心的邀请码填写弹窗和兑换码弹窗在 `native_app` 且宿主声明 `clipboard.readText` 时显示“粘贴”动作,通过 H5 facade 读取宿主返回的纯文本并填入现有受控输入框。该动作不自动提交,不代表兑换成功,不把剪贴板读取扩展成图片、HTML、文件或监听事件,也不绕过既有 `redeemRpgProfileReferralInviteCode` / `redeemRpgProfileRewardCode` 后端接口。 +- 影响范围:`src/components/platform-entry/PlatformProfileReferralModal.tsx`、`src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`、`src/components/platform-entry/platformProfileHostClipboard.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳身份与 capability 作用域门禁 + +- 背景:Expo 移动壳和 Tauri 桌面壳已经具备首批真实 HostBridge 能力,但如果包身份、桥版本、Android 默认权限、Tauri 窗口列表或 capability 文件在后续迭代中漂移,会把 H5 主站装进更宽的宿主权限面。 +- 决策:移动壳配置门禁固定 Expo `name`、`slug`、`userInterfaceStyle`、`assetBundlePatterns`、`extra.genarrativeHostBridgeVersion`;Android 源 `app.json.permissions` 只能手写 `POST_NOTIFICATIONS` 供 `notification.showLocal` 即时本地通知使用、手写 `RECORD_AUDIO` 供同源 H5 实时声音玩法使用,`CAMERA` 必须只由真实 `expo-camera` / `expo-image-picker` 插件为扫码和拍摄能力生成到最终 Expo public config,仍通过 `blockedPermissions` 阻断当前不需要的高风险权限;相册、相机、麦克风、通知权限说明必须锁定为对应真实能力的最小描述,不能退化成泛化采集、后台或远程推送能力说明;`apps/mobile-shell/scripts/check-config.mjs` 检查源 `app.json`,`apps/mobile-shell/scripts/check-expo-config.mjs` 检查 Expo CLI 最终解析出的 public config,防止 config plugin 或解析阶段引入身份、资源、权限和权限文案漂移。新增权限必须先有真实宿主能力、系统权限说明和 H5 fallback。桌面壳配置门禁固定唯一 `label=main` 主窗口,`src-tauri/capabilities/` 只能存在 `main.json`,且该 capability 只能绑定 `windows=["main"]`、`permissions=["allow-host-bridge-request"]`,继续只暴露 `host_bridge_request` 一个受控入口。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/mobile-shell/scripts/check-expo-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 桌面壳 JS guest 依赖收口 + +- 背景:Tauri 桌面壳的生产前端实际只通过 `HostBridge` 和注入的 `window.__TAURI__.core.invoke('host_bridge_request')` 与 Rust 通信;如果根 H5 包或桌面壳包安装 `@tauri-apps/api` / `@tauri-apps/plugin-*` JS 客户端包,后续容易绕过唯一 command 与 capability 边界。 +- 决策:根 H5 `package.json` 和 `apps/desktop-shell/package.json` 不安装 `@tauri-apps/api` 或任何 `@tauri-apps/plugin-*` JS guest 包;opener、clipboard、dialog、notification 等桌面系统能力只保留 Rust Cargo 插件,由 `host_bridge_request` 内部分发。`apps/desktop-shell/scripts/check-config.mjs` 对两个 package 都做依赖门禁,Tauri CLI 仅作为构建工具保留。 +- 影响范围:根依赖、桌面壳依赖、桌面壳配置检查和 Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 HostBridge request id replay + +- 背景:H5 transport 会为每个 HostBridge 请求生成 id 并设置超时,但系统分享、外链打开、文件选择 / 保存、本地通知和窗口导航等宿主副作用如果收到重复 id,不应因为消息重试、快速双发或 WebView 事件抖动被执行两次。 +- 决策:Expo 移动壳缓存已完成响应,并让进行中的同 id 请求共用同一个执行 Promise;Tauri 桌面壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 让同 id 请求等待 / 回放首次结果。重复 id 只返回首次响应,不二次执行宿主能力。已完成响应缓存上限由 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_RESPONSE_CACHE_MAX` 统一声明,Expo 壳直接导入,Tauri 壳保留 Rust 镜像并由桌面配置检查反查共享常量。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/desktop-shell/src-tauri/src/host_bridge/`、两端配置检查、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts apps/mobile-shell/src/host-bridge/bridge.test.ts`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 HostBridge request envelope 校验 + +- 背景:HostBridge 请求来自 H5 WebView / Tauri 注入通道,TypeScript 类型不能替代宿主运行时校验;空 id、控制字符 id、过长 id 或未知 method 如果进入 replay / 能力分发,可能污染缓存、绕过方法白名单或造成错误语义混乱。 +- 决策:共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 `HOST_BRIDGE_METHODS` 和 request id 规则。request id 归一后必须为 1-120 字符且不含控制字符;未知 method 在进入能力分发前返回 `invalid_request`,只有白名单内但当前壳未实现的登录 / 支付等 method 返回 `unsupported_method`。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 HostBridge method 白名单跨壳门禁 + +- 背景:Expo 壳直接引用 TypeScript 共享契约,Tauri 壳必须在 Rust 中镜像 HostBridge method 白名单;如果只靠人工同步,后续新增 method 时容易出现 H5 已发送、Expo 已处理、Tauri 仍按未知 method 拒绝,或 Tauri 额外接受共享契约外 method 的漂移。 +- 决策:`HOST_BRIDGE_METHODS` 以 `packages/shared/src/contracts/hostBridge.ts` 为唯一协议来源。移动壳配置检查解析 `handleRequest` 的 method case,拒绝共享契约外 method;桌面壳配置检查解析 Rust `HOST_BRIDGE_METHODS`,要求与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再在 Expo / Tauri 中实现或明确返回 `unsupported_method`。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 HostBridge event 白名单跨壳门禁 + +- 背景:HostBridge request method 已有共享白名单和跨壳检查,但宿主注入给 H5 的 event 名如果仍是裸字符串,AI sandbox 或壳层新增事件时可能绕过契约,导致 H5 订阅到共享协议外事件,或 Tauri Rust 镜像与 TypeScript 契约漂移。 +- 决策:`HOST_BRIDGE_EVENTS` 以 `packages/shared/src/contracts/hostBridge.ts` 为唯一事件名来源,当前只包含 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须存在于 capability 白名单,各宿主壳只声明自身真实发射的事件 capability。Expo 移动壳事件注入函数必须使用共享 `HostBridgeEventName` 类型;Tauri 桌面壳 `shell/events.rs` 镜像同一事件清单并在脚本生成前拒绝未知事件;H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/nativeAppHostBridge.test.ts`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 H5 HostBridge 事件订阅必须同时声明事件通道 + +- 背景:`navigation.canGoBack` 订阅已经同时要求 `host.events` 和具体事件 capability,但 `app.lifecycle`、`network.statusChanged` 与 `file.imageDropped` 一度只校验具体事件 capability;旧壳或裁剪壳如果缺少 `host.events`,H5 仍可能绑定到不存在或不受控的事件通道。 +- 决策:H5 所有 HostBridge 事件订阅入口统一使用“双能力门控”:必须同时声明 `host.events` 和对应事件 capability,才允许 `subscribeNativeAppHostBridgeEvent(...)` 绑定监听;缺任一能力时返回空取消函数。事件类 capability 继续不要求 request handler,`host.events` 只表示宿主会通过 HostBridge message 注入受控事件,不作为 request method。 +- 影响范围:`src/services/host-bridge/hostBridge.ts`、`src/services/host-bridge/hostBridge.test.ts`、`scripts/check-native-shells.mjs`、宿主壳能力协议文档和 Expo / Tauri 宿主壳方案文档。 +- 验证方式:`npm run test -- src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;根级原生壳门禁会反查共享事件清单、四个 H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,避免后续事件订阅绕过 `host.events`。 + +## 2026-06-18 HostBridge capability / handler 关系门禁 + +- 背景:`HOST_BRIDGE_CAPABILITIES` 同时包含可请求 method 和事件类 capability。壳如果声明了 request method capability 但没有 handler,H5 会展示入口后收到 unsupported;壳如果处理了未声明 method,H5 又无法根据 capability 决定是否调用,容易形成隐藏能力或跨端漂移。 +- 决策:移动壳配置检查展开 `MOBILE_HOST_CAPABILITIES` / `IOS_MOBILE_HOST_CAPABILITIES` 并解析 `handleRequest` case;桌面壳配置检查解析 `capabilities()` 与 Rust request 分发 match。凡共享契约中属于 request method 的 capability,被壳声明后必须有对应 handler;handler 处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events`、`app.lifecycle`、`network.statusChanged`、`file.imageDropped`、`navigation.canGoBack` 等事件 capability 不要求 request handler。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生壳 capability profile 来源收口 + +- 背景:Expo 移动壳、Tauri 桌面壳和方案文档都需要维护真实 capability 子集;如果移动端源码、桌面 Rust 镜像和文档各自手写完整清单,后续新增能力时容易出现入口 URL、`host.getRuntime` 回包、文档和门禁漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 中的 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`、`HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES`、`HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 和 `HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES` 是三端宿主壳 capability profile 来源。Expo 移动壳只通过 `apps/mobile-shell/src/host-bridge/capabilities.ts` 引用共享 profile 并选择平台差异;微信小程序 `miniprogram/host-bridge/protocol.js` 和 Tauri 桌面壳 `capabilities.rs` 仍保留运行时镜像,但 `miniprogram/host-bridge/protocol.test.js`、`apps/desktop-shell/scripts/check-config.mjs` 和 `npm run check:native-shells` 必须反查对应共享 profile。根级门禁同时反查宿主壳方案文档里的微信 / Expo / Tauri 能力清单,新增 capability 必须先进入共享白名单和对应平台 profile,再补真实壳实现、H5 fallback、测试和文档。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/protocol.test.js`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts miniprogram/host-bridge/protocol.test.js`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳本地生成物边界 + +- 背景:`npm run check:native-shells` 会生成 Expo `.expo/` 日志、Expo export smoke 临时目录、Tauri schema、Tauri 自动生成权限和 Rust `target/` 产物。这些文件是本机工具输出,不是生产源码;如果进入生产壳敏感词扫描或被误提交,会让门禁受工具版本、构建日志或自动生成格式影响。 +- 决策:`.gitignore` 显式忽略 Expo `.expo/`、Expo export smoke、Tauri `target/`、Tauri `gen/` 和 Tauri `permissions/autogenerated/`;根级 `check:native-shells` 的生产壳扫描同样排除这些本地生成目录,只扫描可提交的壳源码和配置。手写 capability / 权限配置仍保留在扫描范围内。 +- 影响范围:`.gitignore`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`git check-ignore -v` 检查本地生成目录,`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 移动壳渠道 SDK 依赖收口 + +- 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。 +- 决策:`apps/mobile-shell/scripts/check-config.mjs` 同时检查移动壳包和根 H5 包的直接依赖;在真实发布通道、采集字段、用户授权、隐私披露、签名 / 回滚策略和团队发布流程落地前,两处都不得安装上述移动渠道 SDK。现有即时本地通知、系统分享、文件导入导出等真实宿主能力不受影响。 +- 影响范围:移动壳配置检查、根依赖边界和 Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 移动壳系统深链声明收口 + +- 背景:移动壳已经通过运行时归一限制 deep link 只能进入同源 H5 路径,但 iOS associated domains 和 Android intent filter 也属于安装包级接管范围;如果后续只做“包含主站”校验,安装包可能额外接管外域、明文协议或更宽路径。 +- 决策:Expo 源配置和 Expo CLI public config 都必须把 iOS `associatedDomains` 固定为唯一 `applinks:www.genarrative.world`;Android `intentFilters` 固定为唯一 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能包含 `scheme=https` 与 `host=www.genarrative.world`,不得声明额外 domain、protocol、pathPattern 或其它接管范围。配置检查从共享 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` 解析 expected host 后反查这些平台 manifest 字段,避免移动壳脚本把主站域名维护成第二来源;运行时 deep link 继续只映射同源路径并附加 HostBridge 上下文。 +- 影响范围:`apps/mobile-shell/app.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/mobile-shell/scripts/check-expo-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳命令入口收口 + +- 背景:Expo / Tauri 壳的源码、权限和构建配置已经进入门禁,但 package scripts 仍属于真实开发和验收入口;如果后续把根级或壳级 dev / build / typecheck / test 命令改成临时快捷命令,就可能绕过 Expo public config、Metro export、Tauri dev、Tauri release build smoke 或根 H5 构建。 +- 决策:移动壳 `apps/mobile-shell/package.json` 的 `dev`、`android`、`ios`、`test`、`config:smoke`、`export:smoke`、`typecheck` 和根 `mobile-shell:*` 入口必须保持指向真实 Expo / RN / Vitest / Expo config / Metro export / 配置检查流程;桌面壳 `apps/desktop-shell/package.json` 的 `dev`、`build`、`typecheck`、根 `desktop-shell:*` 入口,以及 Tauri `beforeDevCommand` / `beforeBuildCommand` 必须保持指向真实 Tauri dev / build、根 H5 `dev:web` 和桌面壳配置检查流程。两端配置检查负责拒绝命令入口漂移。 +- 影响范围:根 `package.json`、`apps/mobile-shell/package.json`、`apps/desktop-shell/package.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 H5 Tauri command 入口收口 + +- 背景:桌面壳 Rust 侧已经只暴露 `host_bridge_request` 一个 command,但 H5 `nativeAppHostBridge` 如果直接写死 command 名或以后调用其它 Tauri command,会绕过共享 HostBridge 契约和桌面 capability 审计。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_TAURI_COMMAND='host_bridge_request'` 作为 H5 到 Tauri 的唯一 command 名;`src/services/host-bridge/nativeAppHostBridge.ts` 必须通过该常量调用 `window.__TAURI__.core.invoke`。桌面壳配置检查同时对齐共享常量、Tauri build manifest、Rust `generate_handler!` 和 H5 transport,拒绝 H5 侧写死 command 字符串或调用其它 Tauri command。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts packages/shared/src/contracts/hostBridge.test.ts`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 H5 Tauri HostBridge 请求超时 + +- 背景:共享 HostBridge request 已包含 `timeoutMs`,React Native WebView transport 会在 H5 侧释放超时请求,但 Tauri transport 如果直接等待 `core.invoke`,Rust command 卡住时 H5 也会一直等待,违背“每个请求必须有超时”的壳层约束。 +- 决策:`src/services/host-bridge/nativeAppHostBridge.ts` 的 Tauri transport 必须通过前端侧超时封装调用 `HOST_BRIDGE_TAURI_COMMAND`,与 React Native WebView transport 共享 `timeoutMs` 归一化和 `timeout / host_bridge_timeout` 错误语义。桌面宿主迟到返回时不能改写 H5 已拒绝的请求结果;`apps/desktop-shell/scripts/check-config.mjs` 锁定 Tauri transport 的超时封装,避免后续退回裸 `invoke`。 +- 影响范围:`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生壳请求超时边界单一来源 + +- 背景:H5 的 React Native WebView transport 和 Tauri transport 已共享请求超时语义,但默认超时与最大超时如果继续留在 H5 transport 本地常量中,后续共享契约、测试和壳配置门禁容易出现边界漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` 与 `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS`,作为原生壳请求默认超时和最大超时的唯一声明来源;`src/services/host-bridge/nativeAppHostBridge.ts` 必须导入共享常量做 `timeoutMs` 归一化,不得在 H5 transport 本地重声明默认 / 最大超时。`npm run check:native-shells` 和桌面壳配置检查会拒绝回退到本地超时边界。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/nativeAppHostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生宿主 runtime 回读短超时单一来源 + +- 背景:H5 主 App 进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主能力,但该回读只用于补齐能力缓存,不应该沿用普通宿主请求默认超时,也不应该在 H5 facade 里散落本地毫秒数。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS`,作为 `refreshNativeAppHostRuntime()` / `getNativeAppHostRuntime()` 请求 `host.getRuntime` 时的短超时唯一来源;`src/services/host-bridge/hostBridge.ts` 必须导入共享常量,不得本地声明 `HOST_RUNTIME_REFRESH_TIMEOUT_MS`。根级原生壳门禁和桌面壳配置检查会拒绝回退到本地 runtime 回读超时。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/services/host-bridge/hostBridge.test.ts`、`scripts/check-native-shells.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生宿主用户交互超时单一来源 + +- 背景:文件导入 / 导出、图片选择 / 拍摄等 H5 HostBridge facade 请求需要等待系统面板或用户选择,不能使用普通短请求默认超时;如果每个调用点手写 `timeoutMs: 30000`,后续调整壳层交互超时时容易遗漏。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS`,作为 H5 facade 发起文件导入 / 导出、图片选择 / 拍摄等用户交互型 HostBridge 请求的长超时唯一来源;`src/services/host-bridge/hostBridge.ts` 必须导入共享常量,不得继续手写 `timeoutMs: 30000`。根级原生壳门禁和桌面壳配置检查会拒绝回退到本地字面量。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/services/host-bridge/hostBridge.test.ts`、`scripts/check-native-shells.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生壳关键依赖版本收口 + +- 背景:Expo / React Native WebView / Tauri / Cargo 插件版本会直接影响 WebView 安全默认值、managed config 解析、production bundle、Tauri capability、插件初始化和 release 构建行为;如果只改依赖声明,壳行为可能绕过 HostBridge 门禁和现有验收口径静默漂移。 +- 决策:移动壳配置检查锁定 `apps/mobile-shell/package.json` 和根 `package.json` 中当前 Expo SDK 56、React 19、React Native 0.86、`react-native-webview`、`react-native-safe-area-context`、Expo Clipboard / DocumentPicker / FileSystem / Haptics / ImagePicker / Linking / Network / Notifications / Sharing / StatusBar、TypeScript 与 Vitest 版本,并检查根 `package-lock.json` 的实际解析版本。桌面壳配置检查锁定 `apps/desktop-shell/package.json` 与根 `package.json` 的 Tauri CLI / TypeScript 版本,检查根 `package-lock.json` 的实际解析版本,并锁定 `src-tauri/Cargo.toml` 中 `tauri-build`、`tauri`、`base64`、`serde`、`serde_json` 和 clipboard、dialog、notification、opener、single-instance 插件版本及 Tauri `tray-icon` feature,同时检查 `src-tauri/Cargo.lock` 中桌面壳 direct dependency 的实际解析版本。后续升级这些依赖必须同步更新配置门禁、方案文档、lockfile 和验证结果。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 原生 HostBridge 入站消息来源收口 + +- 背景:H5 主站会同时承载原生壳 HostBridge 和后续 AI H5 sandbox / GameBridge;如果 H5 侧只按 JSON envelope 识别 HostBridge response / event,sandbox iframe 可以构造同形 `postMessage` 干扰待处理宿主请求或伪造宿主事件。 +- 决策:Expo 和 Tauri 注入给 H5 的 HostBridge response / event 统一带 `origin: window.location.origin` 和 `source: window`;`nativeAppHostBridge` listener 只接受无外部 source 或当前窗口 source 的消息,并拒绝非当前页面 origin。AI sandbox 后续继续使用独立 GameBridge allowlist,不允许直接结算 HostBridge 请求。 +- 追加:Expo 移动壳事件注入必须在运行时调用共享 `isHostBridgeEventName()` 校验事件名,只有 `HOST_BRIDGE_EVENTS` 中的事件才能生成注入脚本;普通 response 仍可复用统一 message script,但不能绕过事件 allowlist 伪造新的宿主事件类型。`apps/mobile-shell/scripts/check-config.mjs` 必须反查该校验。 +- 影响范围:`apps/mobile-shell/App.tsx`、`apps/desktop-shell/src-tauri/src/main.rs`、`src/services/host-bridge/nativeAppHostBridge.ts`、两端壳配置检查和 HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 三端宿主桥接层文件结构对齐 + +- 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 +- 决策:三端桥接层按职责对齐,但保留各宿主真实边界。微信小程序页面路由不改,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留生命周期和装配,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`scanner.ts`、`share.ts` 和 facade `bridge.ts`,分别负责 envelope / request 校验 / ok-failure 响应 / replay 基础类型、能力清单与 iOS 差异、method 分发、文件能力、扫码能力、分享能力和 WebView message 入口 / request id replay 编排;根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,`apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/app.rs`、`host_bridge/protocol.rs`、`capabilities.rs`、`dispatch.rs`、`files.rs`、`share.rs` 和 command facade `mod.rs`,分别负责 Tauri builder / plugin / window 装配、envelope / method 白名单 / request 校验 / replay 状态、能力清单、method 分发、文件能力、分享能力和 `host_bridge_request` command / replay 编排;`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、窗口状态持久化和 WebView 门面,`main.rs` 只做薄入口并调用 `app::run()`。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口和桌面根入口重新承接宿主能力装配。 +- 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 +- 验证方式:`npm run test -- miniprogram/host-bridge/webView.test.js miniprogram/host-bridge/payment.test.js miniprogram/host-bridge/shareGrid.test.js miniprogram/host-bridge/subscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 三端宿主桥接层结构文档反查 + +- 背景:三端桥接层已经拆出移动 `scanner.ts`、桌面 `window_state.rs` 等职责文件,但如果只更新代码和目录门禁,`宿主壳能力统一协议` 与 `ExpoReactNative与Tauri宿主壳方案` 可能继续保留旧清单,后续开发者按文档扩展时仍会把能力放回错误 owner。 +- 决策:`scripts/check-native-shells.mjs` 的三端桥接层目录清单同时作为文档反查来源。根级门禁会确认两份前端架构文档都按完整相对路径列出微信桥接层、微信 shell、微信页面包装层、移动源码根 `env.d.ts`、移动桥接层、移动 shell、桌面入口、桌面桥接层和桌面 shell 的当前生产文件,并拒绝这些目录出现未登记子目录或生产入口;`apps/mobile-shell/scripts/check-config.mjs` 必须用精确移动壳源码清单拦截 `src/host-bridge` / `src/shell` 和 `src` 根入口单端结构漂移,`apps/desktop-shell/scripts/check-config.mjs` 必须用 Rust 根目录 entry、`host_bridge/` 文件清单、`shell/` 文件清单和 Rust 模块清单共同拦截桌面壳单端结构漂移。新增、删除或改名这些职责文件时,必须同时更新脚本清单、两份架构文档、单端门禁和相关实现,不允许只改一端。 +- 影响范围:`scripts/check-native-shells.mjs`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`、`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、三端宿主壳源码布局。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 HostBridge 载荷边界单一来源 + +- 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、窗口标题长度、外链 URL payload、分享 payload、剪贴板文本长度、触觉反馈 style 和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。H5 facade 消费 `file.importText` / `file.importDocument` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.imageDropped` 返回结果时必须分别通过共享导入结果 normalizer 再校验文件名、MIME、内容、base64、字节数和图片拖拽坐标,避免旧壳或异常壳返回超界数据被业务层消费。`share.open` 必须通过共享 `normalizeHostBridgeShareOpenPayload()` 把 `url`、`href`、`path`、`targetPath` 和 `work` 归一到公开 H5 同源 URL,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一规则。`app.openExternalUrl` 必须先通过共享 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }`,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一协议清单。`app.setTitle` 必须拒绝空值和控制字符,并按共享 80 字符上限截断;H5 facade 和 Tauri 壳都执行该边界。`clipboard.writeText` / `clipboard.readText` 两个方向都必须执行同一个 100000 字符上限;H5 facade 发起 `clipboard.writeText` 前先按共享上限归一化 payload,Expo 与 Tauri 壳仍必须再次执行同一边界,不允许只信 H5 facade 的预校验。H5 facade 发起 `haptics.impact` 前也必须按共享 style 清单归一化,未知 style 不发往宿主,Expo 壳仍二次拒绝未知值。`file.exportText` 必须在 H5 facade 发起请求前通过 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;可选 `mimeType` 只能来自 `HOST_BRIDGE_TEXT_MIME_TYPES`,缺省为 `text/plain`,Expo 与 Tauri 都必须拒绝图片、音频或二进制 MIME,避免 H5 通过文本导出通道伪装落盘;`file.exportImage` / `file.exportAudio` 必须在 H5 facade 发起请求前分别通过 `normalizeHostBridgeExportImagePayload()` / `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和共享导出上限,Expo 与 Tauri 壳仍必须按真实字节和 MIME 二次校验,不允许只信 H5 预检。两端 config check 必须反查该边界。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 +- 追加:Tauri 桌面壳 `host.getRuntime` 回包必须由 `host_bridge/runtime.rs` 的 `desktop_runtime()` 单一函数生成,内部统一读取 `desktop_platform()`、`env!("CARGO_PKG_VERSION")`、`HOST_BRIDGE_VERSION` 和 `capabilities()`;同文件的 `desktop_host_bridge_runtime_response(&request)` 负责把该结构包装成 HostBridge 成功响应,`resolve_host_bridge_request` 只做 method 委托。`apps/desktop-shell/scripts/check-config.mjs` 必须拒绝把 `HostBridgeRuntime` 或 `ok(json!(desktop_runtime()))` 重新内联到 match arm,避免平台、版本、capability 来源或响应形状分叉。 +- 追加:Expo 移动壳 `host.getRuntime` 返回的 `platform` 与 `capabilities` 必须来自同一个归一化平台值,避免 iOS/Android 能力清单与上报平台分开计算后漂移。`apps/mobile-shell/src/host-bridge/dispatch.ts` 先通过 `getMobileRuntimePlatform()` 得到 `ios` / `android`,再把同一个值传给 `resolveMobileHostCapabilities(platform)`;`apps/mobile-shell/scripts/check-config.mjs` 必须拒绝恢复为无参 `resolveMobileHostCapabilities()`。 +- 追加:Expo 移动壳的 DocumentPicker 调用也属于 HostBridge 文件边界的一部分。文本、文档和音频导入必须固定 `copyToCacheDirectory: true`、`multiple: false`,并分别使用文本导入清单、`MOBILE_DOCUMENT_PICKER_TYPES` 和 `MOBILE_AUDIO_DOCUMENT_PICKER_TYPES`;宿主只读取用户本次选择后复制到缓存的单个文件副本,不扩展为多选、目录访问或长期设备 URI 访问。`apps/mobile-shell/scripts/check-config.mjs` 必须按函数精确反查这些 picker option。 +- 追加:Tauri 桌面壳的系统文件对话框过滤器也是 HostBridge 文件边界的一部分:文本导出只允许 `txt/json/md/csv` 保存,文本导入只允许 `txt/md/markdown/csv/json` 选择,文档导入额外允许 `docx`,图片导入导出只允许 `png/jpg/jpeg/webp`,音频导入允许 `mp3/m4a/mp4/wav/ogg/webm`,音频导出只允许 `mp3/m4a/wav/ogg/webm`。`apps/desktop-shell/scripts/check-config.mjs` 必须按 method 精确检查 `.add_filter(...)` 与 `blocking_save_file` / `blocking_pick_file` 归属,避免把一次用户选择扩大成任意本地文件访问。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 创作 Agent 参考图复用原生图片导入 + +- 背景:Creation Agent 的文档导入和会话导出已接入原生壳 HostBridge,但参考图上传仍只触发浏览器隐藏文件输入;在 Expo / Tauri 壳内这会绕开已实现的受控 `file.importImage` 系统选择器体验。 +- 决策:`CreationAgentWorkspace` 的参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的 base64 图片副本转换成浏览器 `File` 后继续交给现有 `onReferenceImageChange` 校验、预览和上传链路。用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 汪汪声浪结果页三图槽位复用原生图片导入 + +- 背景:汪汪声浪结果页的玩家形象、对手形象和 UI 背景槽位已经支持浏览器文件输入、单槽上传、单槽重生成、试玩和发布,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 系统选择器体验。 +- 决策:`BarkBattleResultView` 的三图槽位上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的 base64 图片副本转换成浏览器 `File` 后继续交给 `uploadBarkBattleAsset` 上传和当前槽位写回链路。用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/bark-battle-creation/BarkBattleResultView.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/bark-battle-creation/BarkBattleResultView.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 视觉小说结果页复用原生图片和音频导入 + +- 背景:视觉小说结果页素材选择弹窗已经支持封面、角色立绘、场景背景、音乐和环境音上传,以及历史素材选择和 AI 图片生成;但在 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控图片 / 音频系统选择器体验。 +- 决策:`VisualNovelResultView` 的素材上传在 `native_app` 且宿主声明 `file.importImage` 或 `file.importAudio` 时优先调用 `importHostImageFile()` / `importHostAudioFile()`,把宿主返回的 base64 副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和当前封面、角色、场景素材字段写回链路。用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入;历史素材选择和 AI 图片生成保持原链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/visual-novel-result/VisualNovelResultView.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 方洞结果页图片槽位接入原生壳图片导入 + +- 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。 +- 决策:`SquareHoleResultView` 图片槽位弹窗在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换为 `data:;base64,` 并写回当前槽位 `imageSrc`。该动作继续走现有 result edit state、自动保存、试玩和发布链路,不新增后端上传路径,不暴露设备 URI、本机绝对路径或通用文件系统;普通浏览器、小程序和未声明能力的裁剪壳继续使用原浏览器文件输入。 +- 影响范围:`src/components/square-hole-result/SquareHoleResultView.tsx`、`src/services/host-bridge/hostBridge.ts`、方洞玩法链路文档与 Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`npm run check:native-shells`、`git diff --check`。 + +## 2026-06-18 移动壳主动导航保留宿主上下文 + +- 背景:Expo 移动壳启动 URL 和 deep link 已经会给 H5 追加 `native_app`、`expo_mobile`、平台、版本和 capability query;但 H5 通过 HostBridge 调用 `navigation.openNativePage` 主动跳转同源 route 时,壳层只把裸同源 URL 交给 WebView,目标页首屏可能短暂或持续按普通浏览器运行态识别。 +- 决策:`navigation.openNativePage` 仍只接受同源 H5 route,不新增真实原生页面、不放宽外域导航;通过校验后的目标 URL 在进入 WebView 前统一调用 `buildMobileShellUrl(...)` 重新附加当前 `MobileShellUrlOptions`,确保主动导航后的页面继续带 `clientRuntime=native_app`、`hostShell=expo_mobile`、真实平台、宿主版本和当前 capability 清单。 +- 2026-06-20 追加:`buildMobileShellUrl(...)` 对启动 URL、deep link 和主动导航目标补写宿主上下文时,必须先覆盖旧 `clientRuntime`、`hostShell`、`hostCapabilities` 等宿主 query,确保输出只包含当前 Expo 壳真实上下文;移动壳配置检查反查 URL 单测中的旧 query 清洗边界。 +- 影响范围:`apps/mobile-shell/src/host-bridge/`、`apps/mobile-shell/src/shell/ShellApp.tsx`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:test`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 桌面壳主动导航保留宿主上下文 + +- 背景:Tauri 桌面壳 release / dev 入口和 deep link 都会给 H5 追加 `native_app`、`tauri_desktop`、当前平台、版本和 capability query;但 H5 通过 HostBridge 调用 `navigation.openNativePage` 主动跳转同源 route 时,如果只把裸同源 URL 交给主窗口,目标页可能按普通浏览器运行态启动。 +- 决策:`navigation.openNativePage` 仍只接受 `https://www.genarrative.world` 同源 H5 route,不新增真实原生页面、不放宽外域导航;通过校验后的目标 URL 必须复用 `desktop_h5_url_with_host_context(...)`,与桌面 deep link 一样重写宿主上下文 query,确保主动导航后的页面继续带 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、宿主版本和真实 capability 清单。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 H5 原生壳返回锚点与完整运行态保留 + +- 背景:Expo / Tauri 壳已经通过 `navigation.canGoBack` 事件告知 H5 当前可回退状态,但 H5 如果直达二级页且本地 history 没有应用导航条目,Android 返回键或桌面后退菜单会缺少可落回的平台首页;同时 H5 页面内导航若只保留小程序 query,会让原生壳中的后续页面丢失 `hostShell`、平台、版本、桥接版本和 capability 清单。 +- 决策:`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities`;`pushAppHistoryPath()` / `replaceAppHistoryPath()` 写入应用 history state 并保留完整宿主上下文。H5 通过 `useHostNavigationCanGoBack()` 只在宿主同时声明 `host.events` 与 `navigation.canGoBack` 时消费返回栈事件;原生壳内直达非平台首页、非 runtime 的二级 H5 route 且当前 history state 没有应用导航标记时,App 先把当前条目替换成 `/` 返回锚点,再把当前路径推回 history。H5 不读取任意原生 back-forward list。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/routing/appPageRoutes.ts`、`src/hooks/useHostNavigationCanGoBack.ts`、`src/App.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/routing/appPageRoutes.test.ts src/hooks/useHostNavigationCanGoBack.test.tsx src/App.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 桌面壳窗口状态持久化 + +- 背景:Tauri 桌面壳已经具备系统托盘、单实例、深链和受控 HostBridge 能力,但用户调整主窗口尺寸、位置或最大化状态后,重启桌面 App 仍回到固定初始窗口配置;如果直接保存完整窗口状态,又可能把托盘隐藏后的可见性状态带到下次启动。 +- 决策:桌面壳接入 `tauri-plugin-window-state`,并把插件配置收口到 `apps/desktop-shell/src-tauri/src/shell/window_state.rs`。只保存 `SIZE`、`POSITION` 和 `MAXIMIZED`,不保存 `VISIBLE`、`FULLSCREEN` 或 `DECORATIONS`;该能力属于宿主壳自身体验,不进入 HostBridge capability,不暴露窗口状态插件 command 给 H5。插件注册顺序固定为 single-instance 优先,其后才是 window-state、deep-link 和其它系统插件。`window_state.rs` 必须保留直接 Rust 单测证明这组 flags 边界,桌面壳配置门禁会反查该测试。 +- 影响范围:`apps/desktop-shell/src-tauri/Cargo.toml`、`apps/desktop-shell/src-tauri/Cargo.lock`、`apps/desktop-shell/src-tauri/src/main.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run desktop-shell:build -- --no-bundle`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 桌面壳应用菜单 + +- 背景:桌面壳方案要求 Tauri 承接系统菜单,但当前桌面壳只有系统托盘菜单和 HostBridge 受控能力;可分发桌面包缺少常规应用菜单会让刷新、退出、系统编辑和窗口操作只能依赖 WebView 或托盘。 +- 决策:新增 `apps/desktop-shell/src-tauri/src/shell/menu.rs` 注册 Tauri 应用菜单。应用菜单只复用宿主壳级显示主窗口、后退、前进、刷新主窗口和退出应用动作;后退 / 前进只执行固定 `window.history.back(); true;` 与 `window.history.forward(); true;`,无历史记录时按浏览器 no-op 处理,不新增 HostBridge method,也不接收 H5 payload 或任意脚本;编辑菜单和窗口菜单使用 Tauri 原生预定义项承接剪切、复制、粘贴、全选、最小化、最大化和关闭窗口。该能力不进入 HostBridge capability,不开放菜单 API、shell API 或任意窗口控制给 H5;菜单注册失败直接阻断启动,避免生产桌面壳缺少系统菜单仍静默运行。 +- 影响范围:`apps/desktop-shell/src-tauri/src/main.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 + +## 2026-06-22 桌面壳取消原生菜单栏 + +- 背景:桌面壳当前不需要 Tauri 原生菜单栏;继续保留 `shell/menu.rs` 会让窗口顶部出现额外菜单,并让目录门禁与实际 UI 目标产生漂移。 +- 决策:删除 `apps/desktop-shell/src-tauri/src/shell/menu.rs`,启动流程不再调用 `register_desktop_app_menu(app)?`,桌面 shell 清单和配置门禁同步移除 `menu.rs` 与应用菜单相关强制片段。显示主窗口、刷新主窗口、退出应用和窗口恢复仍由系统托盘、单实例唤醒、deep link 唤醒与现有 WebView 导航能力承接;不新增 HostBridge method,也不向 H5 暴露菜单 API 或任意窗口控制。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run desktop-shell:build -- --no-bundle`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 移动壳 WebView 下载协议阻断 + +- 背景:移动壳已经通过 WebView 注入脚本阻断 `` 点击,并丢弃 iOS `onFileDownload` 事件;但 `blob:`、`data:`、`file:`、`filesystem:` 等下载协议导航仍可能在 `onShouldStartLoadWithRequest` 中进入普通同源 / 外链分流,脚本创建的下载链接也缺少行为级测试覆盖。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 是移动 WebView 禁止下载协议清单的唯一来源。`apps/mobile-shell/src/shell/webViewPolicy.ts` 统一承接移动壳下载策略,导航拦截和 WebView 注入脚本都必须复用该共享清单,阻断下载链接点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`ShellApp` 在同源 / 外链分流前调用 `shouldBlockMobileWebViewNavigationRequest(...)`,命中 `blob:`、`data:`、`file:` 或 `filesystem:` 直接拒绝,不进入带完整 HostBridge 的 WebView,也不交给系统外部应用。移动端文件保存仍只能走受控 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method。 +- 影响范围:`apps/mobile-shell/src/shell/webViewPolicy.ts`、`apps/mobile-shell/src/shell/webViewPolicy.test.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:test -- src/shell/webViewPolicy.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 桌面壳通知权限门禁 + +- 背景:桌面壳已经声明并实现 `notification.showLocal`,且 Tauri capability 只授权 `allow-host-bridge-request`;但 Rust handler 在清洗 payload 后直接调用 `notification.show()`,没有显式检查系统通知权限,也没有把权限拒绝固定成 HostBridge 失败语义。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 在发送即时本地通知前先调用 `permission_state()`,已授权才发送;处于 prompt / prompt-with-rationale 时只在 Rust 侧调用 `request_permission()` 后复判;最终未授权返回 `host_error: notification permission denied`。`notifications.rs` 统一承接 payload 校验、权限检查、系统通知调用和 HostBridge 成功 / 失败响应映射,`dispatch.rs` 只保留 method 委托。桌面壳仍不把 `notification:*` 插件命令加入 capability permissions,不向 H5 暴露 notification 插件 JS guest API、远程推送、定时提醒或通知 token。 +- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 桌面壳主窗口启动门禁 + +- 背景:Tauri 的手动窗口创建示例容易从 `app.config().app.windows[0]` 取配置;如果后续配置顺序变化或缺少 `label="main"`,桌面壳可能静默创建错误窗口,甚至在无主 WebView 的状态下完成启动,导致 HostBridge、生命周期、托盘、菜单和拖拽事件都挂不到真实主窗口。 +- 决策:`apps/desktop-shell/src-tauri/src/app.rs` 启动时必须通过 `desktop_main_window_config(app)?` 按 `label="main"` 解析主窗口配置,并在创建 `WebviewWindowBuilder` 前调用 `desktop_window_config_with_runtime_platform(...)` 补写宿主上下文。缺少 `main` 时返回 Tauri `WindowNotFound` 阻断启动;配置门禁拒绝按 `windows[0]` / `get(0)` 兜底或 `if let Some(config)` 静默跳过主窗口创建。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run desktop-shell:test`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 移动壳 HostBridge 版本运行时来源 + +- 背景:移动壳 H5 入口 query 和 `host.getRuntime` 回包都需要稳定 `hostVersion`。如果 `runtime.ts` 手写版本字符串,即使配置检查能对比 `app.json`,发布时仍存在多处版本源需要人工同步。 +- 决策:移动壳 `MOBILE_SHELL_HOST_VERSION` 必须通过移动壳 `app.json` 的 Expo `version` 配置解析,异常配置只回退到与 `app.json` / `package.json` 一致的受检 fallback。不新增 `expo-constants`、OTA 更新、渠道分发、应用安装信息业务或发布通道 SDK;配置检查拒绝 `MOBILE_SHELL_HOST_VERSION` 重新写死字符串。 +- 影响范围:`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:test`、`npm run mobile-shell:typecheck`、`npm run mobile-shell:config`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 移动壳角标上限共享契约来源 + +- 背景:`app.setBadgeCount` 的数量上限已经由共享 HostBridge 契约声明,但移动壳 iOS 角标错误文案仍可能手写边界数字,后续调整上限时会让壳层提示与契约漂移。 +- 决策:Expo 移动壳 `app.setBadgeCount` 的校验和错误文案都必须消费 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_BADGE_COUNT_MAX`;配置检查拒绝移动壳本地重声明角标上限。 +- 影响范围:`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/bridge.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/bridge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 儿童动作热身入口接入原生壳受控导航 + +- 背景:平台首页寓教于乐频道的儿童动作热身 Demo 入口仍直接调用 `window.location.assign('/child-motion-demo')`,在原生壳中绕过了已落地的 `navigation.openNativePage` facade,无法由 Expo / Tauri 壳统一附加宿主上下文和导航策略。 +- 决策:`PlatformEntryFlowShellImpl` 的儿童动作热身入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先调用 `navigateHostNativePage('/child-motion-demo')`;宿主未声明、返回失败、普通浏览器或小程序运行态才回退原浏览器跳转。`/child-motion-demo` 仍是固定内置 H5 体验,不走代码包下载流程,也不新增真实原生页面。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "native app opens child motion demo through host navigation bridge"`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 创作 Agent 轻输入参考图接入原生壳图片导入 + +- 背景:`CreativeAgentInputComposer` 仍通过隐藏浏览器文件输入读取参考图;在 Expo / Tauri 壳中会绕过已经落地的受控 `file.importImage` 能力,移动壳也无法复用真实 `file.captureImage` 拍摄能力。 +- 决策:轻输入 composer 在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路;移动壳声明 `file.captureImage` 时额外展示拍摄参考图入口,调用 `captureHostImageFile()` 后复用同一链路。用户取消宿主选择或拍摄时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/creative-agent/CreativeAgentInputComposer.tsx`、`src/components/creative-agent/CreativeAgentInputComposer.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 抓大鹅发布封面接入原生壳图片导入 + +- 背景:抓大鹅结果页发布弹窗的封面图和封面参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。 +- 决策:`Match3DResultView` 发布封面图和封面参考图在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readPuzzleReferenceImageAsDataUrl`、AI 重绘开关、参考图集合和 `generateMatch3DCoverImage` payload 链路。用户取消宿主选择时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-result/Match3DResultView.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 RPG 角色参考图接入原生壳图片导入 + +- 背景:RPG 角色资产工作室的角色参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。 +- 决策:`RpgCreationRoleAssetStudioModal` 的角色参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readFileAsDataUrl`、参考图集合和角色形象生成 payload 链路。用户取消宿主选择时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx`、`src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx`、`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 RPG 作品封面上传接入原生壳图片导入 + +- 背景:RPG 作品封面编辑器的上传封面仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器,也无法在用户取消原生选择时停留在壳流程内。 +- 决策:`WorldCoverEditor` 的作品封面上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 10 MiB 校验、data URL 读取、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路。用户取消宿主选择时不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`、`src/components/CustomWorldEntityEditorModal.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面"`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 RPG 作品封面参考图接入原生壳图片导入 + +- 背景:RPG 作品封面 AI 生成弹层的封面参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。 +- 决策:`CoverImageGenerationModal` 的封面参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readImageFileAsDataUrl` 读取、参考图预览和 `generateCustomWorldCoverImage` payload 链路。用户取消宿主选择时不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`、`src/components/CustomWorldEntityEditorModal.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面"`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 RPG 场景参考图接入原生壳图片导入 + +- 背景:RPG 场景图片 AI 生成弹层的自定义参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。 +- 决策:`SceneImageGenerationModal` 的场景图片参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readImageFileAsDataUrl` 读取、参考图预览和 `rpgCreationAssetClient.generateSceneImage` payload 链路。用户取消宿主选择时不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`、`src/components/CustomWorldEntityEditorModal.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 H5 支付跳转接入原生壳外链入口 + +- 背景:个人中心充值的微信 H5 支付链接仍直接调用 `window.location.assign(...)`,在 Expo / Tauri 壳中会把承载主站的 WebView 导向外部支付页;同时原生壳尚未接真实支付 SDK,不能声明或伪造 `payment.request` 成功。 +- 决策:`redirectToPaymentUrl(...)` 先调用 `openHostExternalUrl()`,在 `native_app` 且宿主声明 `app.openExternalUrl` 时把 H5 支付 URL 交给宿主系统浏览器;宿主未声明、拒绝或失败时才回退原浏览器跳转。该流程不改变后端到账事实,不新增原生支付能力。 +- 影响范围:`src/services/payment/paymentRedirect.ts`、`src/components/platform-entry/usePlatformProfileCenterController.ts`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/services/payment/paymentRedirect.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "jumps to h5 payment"`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 微信登录授权跳转接入原生壳外链入口 + +- 背景:`startWechatLogin()` 拿到后端微信 OAuth 授权 URL 后仍直接调用 `window.location.assign(...)`,在 Expo / Tauri 壳中会把承载主站的 WebView 导向外部授权页;同时原生壳尚未接真实登录 SDK,不能声明或伪造 `auth.requestLogin` 成功。 +- 决策:`startWechatLogin()` 先调用 `openHostExternalUrl()`,在 `native_app` 且宿主声明 `app.openExternalUrl` 时把微信授权 URL 交给宿主系统浏览器;宿主未声明、拒绝或失败时才回退原浏览器跳转。该流程只收口外链打开方式,不改变后端微信 OAuth 回调、绑定手机号或登录态刷新事实。 +- 影响范围:`src/services/authService.ts`、`src/services/authService.test.ts`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/services/authService.test.ts -t "wechat login"`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 登录与支付外链纳入 HostBridge 必扫调用链 + +- 背景:`check:native-shells` 已能自动发现直接导入 HostBridge facade 的 H5 生产文件,但登录授权和 H5 支付跳转属于敏感外部跳转路径,后续如果被重构到薄包装层或兼容导出,单纯自动发现可能让它们脱离临时替身词扫描和调用链漂移门禁。 +- 决策:`scripts/check-native-shells.mjs` 的 H5 HostBridge 真实调用链必扫清单固定包含 `src/services/authService.ts` 和 `src/services/payment/paymentRedirect.ts`。这两个文件必须持续通过 `openHostExternalUrl()` 承接原生壳外链打开,不得绕回未受控的登录 / 支付伪实现。 +- 影响范围:`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档、共享开发流程记忆。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 登录状态异常重试纳入受控 WebView 刷新 + +- 背景:`AuthGate` 的登录成功、退出登录和身份边界变化已优先调用 `reloadHostWebView()`,但登录状态异常页的“重新尝试”仍直接执行浏览器刷新,在 Expo / Tauri 壳内会绕过 `app.reloadWebView` 受控入口。 +- 决策:登录状态异常页重试复用 `reloadCurrentPageForAuthStateChange()`,先请求原生宿主刷新当前 WebView,宿主未声明、失败或不可用时再回退浏览器刷新。`AuthGate.test.tsx` 进入 `check:native-shells` 的 H5 HostBridge 测试清单,避免认证页刷新路径再次分叉。 +- 影响范围:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档和共享开发流程记忆。 +- 验证方式:`npm run test -- src/components/auth/AuthGate.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 微信壳 capability 绑定真实流程门禁 + +- 背景:Expo / Tauri 壳已有单端配置检查,能反查声明的 request capability 是否有真实 handler;微信小程序壳不使用统一 request dispatcher,而是通过 WebView 登录页、支付页、分享目标消息和九宫切图页承接 `auth.requestLogin`、`payment.request`、`share.setTarget` 和 `share.open`,此前根级门禁只确认 capability profile 与页面路由一致,未显式绑定每个 capability 的真实流程和测试。 +- 决策:`scripts/check-native-shells.mjs` 新增微信 capability flow contract。每个微信 capability 必须对应真实 `miniprogram/host-bridge/*`、`miniprogram/shell/*`、`miniprogram/pages/*` 文件,源码中必须保留关键页面工厂或 `wx.login` / `wx.requestPayment` / `wx.requestVirtualPayment` / `wx.saveImageToPhotosAlbum` 等真实宿主调用,并且对应测试必须在 `check:native-shells` 的微信壳测试清单内。 +- 影响范围:`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档、共享开发流程记忆。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 Tauri Info.plist 进入生产替身词扫描 + +- 背景:桌面壳 macOS 权限说明通过 `apps/desktop-shell/src-tauri/Info.plist` 合并进分发包;此前配置检查会校验 plist 路径和媒体权限文案,但生产替身词扫描扩展名未包含 `.plist`,会让该分发配置绕过 mock / fake / placeholder / 临时 等替身词门禁。 +- 决策:桌面单端配置检查和根级 `npm run check:native-shells` 都把 `.plist` 纳入生产源码扩展名集合;`Info.plist` 既要通过媒体权限专项校验,也要和其它可分发壳配置一样禁止脚手架或替身文本。 +- 影响范围:`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档、宿主壳能力统一协议文档和共享开发流程记忆。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳文档导入边界门禁补齐 + +- 背景:Expo 移动壳文档导入实现已经从共享 HostBridge 契约导入 `HOST_BRIDGE_DOCUMENT_MIME_TYPES` 和 `HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES`,但单端配置检查的共享 payload 边界清单此前只强制文本、图片、音频等边界,未显式覆盖文档导入,后续容易把文档 MIME 或大小限制改成本地常量。 +- 决策:`apps/mobile-shell/scripts/check-config.mjs` 的 `sharedPayloadBoundaryImports` 必须包含文档 MIME 清单和文档导入大小上限;移动壳文本 / 文档 / 图片 / 音频文件导入边界都要持续来自 `packages/shared/src/contracts/hostBridge.ts`。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档和共享开发流程记忆。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳导入文件名归一来源收口 + +- 背景:HostBridge 导出文件名已由共享 `normalizeHostBridgeExportFileName()` 清洗路径字符、非法字符、空白和长度;导入结果此前只在共享契约里 trim,Expo 移动壳却复用导出清洗器返回清洗后的导入文件名,导致 H5 复核与壳返回语义存在隐性差异。 +- 决策:共享契约新增导出 `normalizeHostBridgeImportFileName()`,导入文本、文档、图片和音频结果都通过该函数清洗文件名;Expo 移动壳文件导入实现必须直接使用该导入专用函数,配置检查强制反查,避免继续混用导出函数或本地文件名规则。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`packages/shared/src/contracts/hostBridge.test.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs` 和宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test`、`npm run mobile-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 本地通知结果语义收口 + +- 背景:Expo 和 Tauri 壳的 `notification.showLocal` 此前成功时返回裸 `true`,H5 只能理解为调用成功,容易被误读成“用户实际看见通知”;移动壳巡检也指出该能力真实语义应是系统调度 / 交付,而不是展示确认。 +- 决策:共享契约新增 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT`,结果为 `{ action: 'delivered_to_system' }`;Expo 和 Tauri 壳成功后返回该结构,H5 facade 接受结构化结果并暂兼容旧壳 `true`。该结果只表示通知已交给系统通知层,不承诺用户可见、点击或送达回执。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、两端配置门禁、测试和宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run mobile-shell:test`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生宿主二维码扫码超时单一来源 + +- 背景:二维码扫码属于真实相机交互,等待时间应长于普通宿主请求;此前 H5 `scanHostQrCode()` 直接手写 `timeoutMs: 60000`,会让扫码等待边界和共享 HostBridge 契约漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_SCANNER_TIMEOUT_MS`,作为 H5 facade 发起 `scanner.scanQrCode` 请求的唯一超时来源;`src/services/host-bridge/hostBridge.ts` 必须导入共享常量,不得继续手写 `timeoutMs: 60000`。根级原生壳门禁和桌面壳配置检查会拒绝回退到本地字面量。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档和共享开发流程记忆。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 桌面网络探测超时单一来源 + +- 背景:Tauri 桌面壳的 `network.status` 会从主站 origin 解析 host / port 后做短超时 TCP 可达性查询;此前 `DESKTOP_NETWORK_CHECK_TIMEOUT_MS` 只留在 Rust 本地,后续调整网络探测节奏时可能与共享 HostBridge 文档和门禁漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS`,作为桌面壳主站可达性探测超时的声明来源;Tauri Rust 侧保留同名职责镜像 `DESKTOP_NETWORK_CHECK_TIMEOUT_MS`,由 `apps/desktop-shell/scripts/check-config.mjs` 反查共享值并拒绝漂移。该边界只服务桌面宿主内部网络状态,不新增 H5 任意网络探测能力。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生壳导航桥接边界 + +- 背景:`app.openExternalUrl`、`navigation.openNativePage` 和 `app.reloadWebView` 已是受控 HostBridge 能力,但移动端和桌面端 `dispatch` 仍直接承接外链归一、同源 H5 跳转、宿主上下文补写和 WebView 刷新细节,后续继续补壳能力时容易让分发层重新变厚。 +- 决策:Expo 移动壳新增 `apps/mobile-shell/src/host-bridge/navigation.ts`,统一承接 HostBridge 外链打开、受控 H5 跳转、WebView 刷新和成功响应包装,底层继续复用 `src/shell/navigation.ts` 与 `src/shell/url.ts`;Tauri 桌面壳新增 `apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`,统一承接外链打开、同源 H5 跳转和主窗口刷新,底层继续复用 `shell::navigation` 的 URL 归一和宿主上下文补写。两端 `dispatch` 只保留 method 委托,配置检查会拒绝分发层直接调用外链 opener、URL 归一、WebView navigate / reload 细节或包装导航成功响应。 +- 影响范围:`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run mobile-shell:test -- src/host-bridge/bridge.test.ts`、`npm run desktop-shell:typecheck`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生壳网络查询桥接边界 + +- 背景:`network.status` 已由 Expo shell network 和 Tauri shell network 承接真实系统 / 主站可达性查询,但两端 HostBridge `dispatch` 仍直接调用底层 network helper 或异步阻塞包装,继续补壳能力时容易让分发层重新承接宿主细节。 +- 决策:Expo 移动壳新增 `apps/mobile-shell/src/host-bridge/network.ts`,统一承接 `network.status` HostBridge 查询和成功响应包装,并复用 `src/shell/network.ts`;Tauri 桌面壳新增 `apps/desktop-shell/src-tauri/src/host_bridge/network.rs`,统一承接 `network.status` HostBridge 查询、成功响应和 `host_error` 失败映射,并复用 `shell::network::resolve_desktop_network_status`。两端 `dispatch` 只保留 method 委托,配置检查会拒绝分发层直接导入 shell network、直接执行 `resolve_desktop_network_status`、包装移动网络成功响应或重新映射桌面网络错误。 +- 影响范围:`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run mobile-shell:test -- src/host-bridge/bridge.test.ts`、`npm run desktop-shell:typecheck`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面角标与通知响应边界 + +- 背景:Tauri `app.setBadgeCount` 和 `notification.showLocal` 的系统调用已分别收在 `badge.rs` 与 `notifications.rs`,但 `dispatch.rs` 仍把 `Result<(), HostBridgeResponse>` 映射成 `ok(true)`,继续让分发层知道能力成功响应形状。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs` 和 `apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 统一返回 `HostBridgeResponse`,各自承接 payload 校验、系统调用、成功响应和失败响应映射;`dispatch.rs` 只保留 method 委托。桌面壳配置检查会拒绝 `dispatch.rs` 重新对这两个 method 做 `match` 或包装 `ok(true)`。 +- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`docs/project-memory/shared-memory/decision-log.md`。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳 HostBridge 事件失败不可静默 + +- 背景:Tauri 桌面壳已经声明 `host.events`、`app.lifecycle`、`navigation.canGoBack` 和 `file.imageDropped`,H5 会据此订阅生命周期、返回栈和拖拽图片事件;如果 WebView 事件脚本注册或发射失败仍被静默忽略,H5 会误以为宿主能力可用。桌面网络变化事件在 Rust 侧具备真实事件源前不声明 `network.statusChanged`。 +- 决策:桌面壳启动阶段安装 `navigation.canGoBack` 脚本失败时直接阻断启动;生命周期首发、页面加载重放、窗口生命周期事件和拖拽图片事件阶段的 `app.lifecycle`、`navigation.canGoBack`、`file.imageDropped` 失败必须通过统一 helper 记录日志,不允许 `let _ = register_desktop_*`、`let _ = emit_current_*` 或 `let _ = emit_desktop_image_drop_event` 静默吞错。配置检查反查该错误处理路径,并禁止桌面网络事件回退到 WebView `online` / `offline`。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳深链打开失败不可静默 + +- 背景:`genarrative://` 和同源 HTTPS 深链是桌面壳的生产入口;如果深链归一成功后 `window.navigate(...)` 或恢复主窗口失败却被静默忽略,用户会看到深链无反应且没有可排查日志。 +- 决策:桌面壳深链打开必须让 `open_desktop_deep_link_url(...)` 返回 `tauri::Result<()>`,窗口导航和 `show_main_window(...)` 失败统一记录日志;配置检查拒绝深链模块继续使用 `let _ = window.navigate(...)` 或 `let _ = show_main_window(...)` 静默吞错。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳菜单托盘与单实例动作失败不可静默 + +- 背景:桌面壳托盘菜单和单实例唤醒是生产用户恢复、刷新和导航主窗口的宿主入口;如果这些动作失败仍被 `let _ = ...` 静默忽略,用户会看到托盘或二次启动无反应且无法排查。 +- 决策:后退、前进、刷新主窗口,托盘显示、刷新主窗口,以及单实例唤醒主窗口动作失败必须走统一桌面宿主事件日志;配置检查拒绝这些入口继续对主窗口动作使用 `let _ = ...` 静默吞错。 +- 2026-06-21 调整:托盘注册失败日志只记录 `desktop tray registration failed` 固定标签,不输出 Tauri tray 插件错误详情;配置检查拒绝重新拼接 `: {error}`。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳外链与托盘关闭失败不可静默 + +- 背景:桌面壳 HostBridge 外链、WebView 新窗口外链接管和托盘关闭隐藏都会改变用户当前窗口状态或离开主 WebView;如果 opener、生命周期注入或窗口隐藏失败仍被静默忽略,用户会看到外链、关闭或托盘行为无反应且没有可排查日志。 +- 决策:`open_normalized_desktop_external_url(...)` 返回 `tauri::Result<()>`,HostBridge 外链打开和 WebView 新窗口外链接管失败都必须通过统一桌面宿主事件日志记录;托盘关闭主窗口前的 `app.lifecycle` 注入和 `hide()` 失败也必须记录日志。配置检查拒绝这些路径继续使用 `let _ = ...` 静默吞错。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳深链注册失败不可静默 + +- 背景:Tauri 桌面壳深链 scheme 注册决定已安装桌面包能否从系统链接唤醒;如果 `register_all()` 失败后静默继续,用户会看到深链无反应且没有可排查日志。 +- 决策:`register_desktop_deep_link_schemes(...)` 必须把 `app.deep_link().register_all()` 结果交给同日志格式的 `log_desktop_deep_link_register_result(...)`,并返回布尔结果供测试和门禁反查;失败日志只记录 `desktop host event failed for deep_link.register` 固定标签,不输出 deep-link 插件错误详情;桌面壳配置检查拒绝重新出现 `let _ = app.deep_link().register_all()`。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::deep_link`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳冷启动深链读取失败不可静默 + +- 背景:Tauri deep-link 插件在桌面壳冷启动时通过 `get_current()` 交出系统传入的初始 URL;如果读取失败后只回到默认首页,用户会看到深链启动目标丢失,开发侧也无法区分是链接被拒绝、插件不支持还是系统读取异常。 +- 决策:桌面壳冷启动当前 deep link 读取必须经过 `log_desktop_deep_link_current_result(...)`;读取失败只记录 `desktop host event failed for deep_link.current` 固定标签后安全回到默认入口,不输出 deep-link 插件错误详情;读取为空继续无声 no-op,读取成功再逐条执行受控 URL 归一和打开。桌面壳配置检查拒绝重新出现 `if let Ok(Some(urls)) = app.deep_link().get_current()` 静默分支。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::deep_link`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动 HostBridge 外链打开异常不可静默 + +- 背景:Expo 移动壳的 `app.openExternalUrl` 会调用系统 `Linking.canOpenURL` / `openURL` 离开 WebView;如果系统 API reject 后只返回 H5 稳定失败,真机上外链无反应时缺少宿主侧排查线索。 +- 决策:`openMobileHostBridgeExternalUrl(...)` 捕获系统外链打开异常时必须记录 `mobile HostBridge navigation failed for external.open`,HostBridge 回包仍只暴露稳定 `host_error: external URL cannot be opened`,不透传系统异常细节。配置检查反查日志 helper、`catch (error)` 和对应测试断言。 +- 影响范围:`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/navigation.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/navigation.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳生命周期窗口状态读取失败不可静默 + +- 背景:Tauri 桌面壳 `app.lifecycle` 事件会驱动 H5 游戏循环、背景音乐和固定玩法音频暂停 / 恢复;如果 `is_visible()`、`is_minimized()` 或 `is_focused()` 读取失败后静默使用默认值,生命周期状态可能错误且难以排查。 +- 决策:`emit_current_desktop_lifecycle_event(...)` 必须通过 `resolve_desktop_lifecycle_window_flag(...)` 读取窗口可见、最小化和焦点状态;读取失败时记录 `desktop host event failed for app.lifecycle.`,再使用保守默认值。桌面壳配置检查拒绝重新出现 `window.is_visible().unwrap_or(...)`、`window.is_minimized().unwrap_or(...)` 或 `window.is_focused().unwrap_or(...)`。 +- 2026-06-21 调整:生命周期窗口状态读取失败和 `app.lifecycle` / `navigation.canGoBack` 等桌面宿主事件注入失败只记录固定阶段标签,不把 Tauri 错误详情写入可分发桌面壳 stderr;配置检查拒绝生命周期日志重新输出 `: {error}` 详情。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/scripts/check-config.mjs`。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::lifecycle`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳返回栈状态同步失败不可静默 + +- 背景:Tauri 桌面壳 `navigation.canGoBack` 注入脚本会把当前 H5 history index 写入 `window.history.state`;如果 `replaceState(...)` 因页面状态异常或浏览器限制失败后静默吞掉,H5 返回栈事件可能漂移且无排查线索。 +- 决策:`desktop_navigation_state_script()` 的 `replaceCurrentState()` catch 路径必须输出 `desktop navigation state sync failed` 浏览器 console 警告;配置检查拒绝该注入脚本重新出现空 catch。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/scripts/check-config.mjs`。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::navigation`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳网络探测失败不可静默 + +- 背景:Tauri 桌面壳 `network.status` 会把主站 TCP 可达性作为当前宿主网络状态;如果探测目标解析、DNS 或 TCP 连接失败后只折叠为 offline,H5 可以收到离线状态,但开发侧无法判断是配置、解析还是连接问题。 +- 决策:`resolve_desktop_network_status()` 必须通过 `resolve_desktop_network_reachability(...)` 得到可达性或失败原因;失败时只记录 `desktop network reachability probe failed` 固定标签,再继续按离线 payload 返回,不把解析目标、DNS 或 TCP 错误详情写入可分发桌面壳 stderr。配置检查拒绝网络探测重新用 `.to_socket_addrs().ok()` 或 `connect_timeout(...).map(...).unwrap_or(false)` 静默吞错,也拒绝重新拼接 `: {reason}`。 +- 影响范围:`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/scripts/check-config.mjs`。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::network`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 EAS 原生包构建 profile + +- 背景:移动壳已能通过 Expo managed config 和 Metro production bundle smoke,但缺少原生安装包构建 profile;如果只保留 `expo export`,无法证明 Android / iOS 壳有进入原生分发链路的配置。 +- 决策:新增 `apps/mobile-shell/eas.json` 和 `eas-cli` devDependency。Android `production` profile 使用本地 EAS build 产出内部 APK;iOS `production-simulator` profile 产出 simulator release 包,避免在没有真实签名凭据时写入伪证书配置。两端 profile 都用本地 `app.json` 版本字段并设置 `EXPO_NO_DOTENV=1`,真实本地构建输出固定到根目录 `build/native/mobile/` 下的 Android APK 和 iOS simulator 压缩包。当前不配置商店提交、签名凭据来源、自动递增、OTA runtimeVersion 或 releaseChannel;`check-eas-build-config.mjs` 必须从 `apps/mobile-shell` 目录执行本地 EAS CLI 版本检查,确认解析到受检版本,并校验固定输出路径和扩展名。`apps/mobile-shell/scripts/check-config.mjs` 必须反查根级门禁仍保留 EAS build profile、Expo config 和 Metro export 三个移动分发烟测。 +- 影响范围:`apps/mobile-shell/eas.json`、`apps/mobile-shell/package.json`、`apps/mobile-shell/scripts/check-eas-build-config.mjs`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、原生壳方案和验收文档。 +- 验证方式:`npm run mobile-shell:build-config`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 iOS Privacy Manifest 门禁 + +- 背景:移动壳使用 React Native、Expo FileSystem、Notifications 等原生依赖,这些依赖包含 required reason API 的隐私清单;如果 `app.json` 不显式声明并由配置检查反查,iOS 分发时可能因为合并缺失或依赖升级导致隐私声明漂移。 +- 决策:`apps/mobile-shell/app.json` 在 `expo.ios.privacyManifests` 声明当前依赖需要的 `FileTimestamp`、`DiskSpace`、`SystemBootTime` 和 `UserDefaults` required reason API;不声明数据采集和 tracking domain。`apps/mobile-shell/scripts/check-config.mjs` 必须精确反查 API category、reason、空 collected data 和 tracking=false。`apps/mobile-shell/scripts/check-expo-config.mjs` 额外确认当前安装的 `@expo/config-plugins` 仍包含消费 `config.ios?.privacyManifests` 并写入 `PrivacyInfo.xcprivacy` 的 `withPrivacyInfo` 插件,避免该字段变成源配置里的死声明。 +- 影响范围:`apps/mobile-shell/app.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/mobile-shell/scripts/check-expo-config.mjs`、原生壳方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run mobile-shell:config`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳 release 二进制产物验收 + +- 背景:桌面壳统一验收已经执行 `tauri build --no-bundle`,但如果只看命令退出码,后续产物路径、二进制名称或平台输出发生漂移时,可能无法证明本机确实产出了可执行桌面壳。 +- 决策:`npm run check:native-shells` 在桌面 release build smoke 后必须运行 `desktop-shell:stage-release-binary`,把 `apps/desktop-shell/src-tauri/target/release/genarrative-desktop-shell` 或 Windows `.exe` 复制到根目录 `build/native/desktop/`,再检查 staged 二进制存在、体积非空,并按当前平台校验 Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位。`apps/desktop-shell/scripts/check-config.mjs` 必须反查根级门禁仍保留 release build smoke、staging 步骤和二进制产物检查。该检查不启动 GUI,也不生成平台安装包。 +- 影响范围:`scripts/check-native-shells.mjs`、`apps/desktop-shell/package.json`、`apps/desktop-shell/scripts/stage-release-binary.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、原生壳方案文档。 +- 验证方式:`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 smoke 脚本进入生产扫描 + +- 背景:移动壳 `check-eas-build-config.mjs`、`check-expo-config.mjs` 和 `check-expo-export.mjs` 已经成为原生包构建、Expo managed config 和 Metro production bundle 的验收入口;如果它们不进入单端结构清单和替身词扫描,后续可能在验收脚本里留下临时绕过逻辑而不被发现。 +- 决策:`apps/mobile-shell/scripts/check-config.mjs` 必须把上述三个 smoke 脚本登记为受控生产验收脚本,并纳入 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造 / 未实现 / 临时 扫描;`check-config.mjs` 本身继续由根级门禁调用,不自扫自身。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、原生壳方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 ShellApp HostBridge 事件注入必须可执行覆盖 + +- 背景:Expo 移动壳声明 `host.events`、`app.lifecycle`、`network.statusChanged` 和 `navigation.canGoBack`,但 ShellApp 真实 AppState、Network 和 WebView 返回栈注入链路需要和扫码链路一样有可执行测试覆盖,不能只靠字符串门禁。 +- 决策:`apps/mobile-shell/src/shell/ShellApp.test.tsx` 必须覆盖 AppState 到 `app.lifecycle`、Expo Network listener 到 `network.statusChanged`、WebView native / H5 history 合成到 `navigation.canGoBack` 的真实注入脚本;页面 load 后网络状态重放失败必须记录日志,不允许静默 `.catch(() => undefined)`。移动壳配置检查反查这些测试片段和失败日志 helper。 +- 影响范围:`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/shell/ShellApp.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 WebView 外链打开失败不可静默 + +- 背景:Expo WebView 外域导航会离开带 HostBridge 的主站容器并交给系统浏览器或系统应用;如果 `Linking.openURL(...)` reject 后静默吞掉,用户会看到点击外链无反应且开发侧无法区分协议、系统能力或原生模块失败。 +- 决策:`ShellApp` 的 WebView 外链分流必须继续复用 `openMobileShellExternalNavigation(Linking, request.url)`,但 Promise reject 路径必须调用 `logMobileShellNavigationFailure('external_navigation.open', error)` 记录错误;配置检查拒绝 `ShellApp` 重新出现 `catch(() => undefined)`,并反查外链失败日志测试。 +- 影响范围:`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/ShellApp.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳返回栈状态同步失败不可静默 + +- 背景:Expo 移动壳通过 WebView 注入脚本追踪当前 H5 文档 history index 并回传 `navigation.canGoBack`;如果 `replaceState(...)` 写入 index 失败后静默吞掉,Android 返回键和 H5 返回按钮状态可能漂移且难以排查。 +- 决策:`TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT` 的 `replaceCurrentState()` catch 路径必须输出 `mobile navigation state sync failed` 浏览器 console 警告,同时继续回传当前可返回状态;移动壳配置检查拒绝该注入脚本重新出现空 catch。 +- 影响范围:`apps/mobile-shell/src/shell/webViewPolicy.ts`、`apps/mobile-shell/src/shell/webViewPolicy.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/webViewPolicy.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳生命周期映射门禁 + +- 背景:Expo `AppState` 的 `active`、`background`、`inactive` 以及未知状态都会进入 `app.lifecycle` 事件;如果归一化映射漂移,H5 游戏循环、背景音乐和固定玩法音频会错误恢复或暂停。 +- 决策:`apps/mobile-shell/src/shell/lifecycle.test.ts` 必须直接覆盖 `active`、`background`、`inactive` 和未知状态到统一 `state`、`focused`、`nativeState` 的映射;`apps/mobile-shell/scripts/check-config.mjs` 反查映射函数和测试片段,确保未知状态继续归为 `inactive` 且只有 `active` 视为 focused。 +- 影响范围:`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/lifecycle.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/lifecycle.test.ts`、`npm run mobile-shell:config`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动扫码权限请求失败不可静默 + +- 背景:Expo 移动壳 `scanner.scanQrCode` 会通过真实 `expo-camera` 权限 API 启动扫码;如果权限请求 API 自身失败后只返回通用 `host_error` 而不记录原始错误,用户会看到扫码不可用但开发侧难以区分系统拒绝、原生模块异常或设备能力问题。 +- 决策:`QrScannerOverlay` 的 `Camera.requestCameraPermissionsAsync()` reject 路径必须调用 `logQrScannerPermissionFailure(...)` 输出 `mobile QR scanner permission request failed` 日志,再通过 `failQrCodeScan()` 以稳定 `host_error: qr scanner unavailable` 结束当前请求;`QrScannerOverlay.test.tsx` 必须覆盖该 reject 路径,移动壳配置检查反查日志 helper、稳定错误语义和测试断言。 +- 影响范围:`apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/QrScannerOverlay.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动分享单测边界 + +- 背景:Expo 移动壳 `share.open` / `share.setTarget` 已经由 `share.ts` 承接共享 HostBridge 分享 URL 归一和缓存目标,但关键边界主要压在巨型 `bridge.test.ts` 中,后续拆分桥接 helper 时容易遗漏非法显式 payload 不回退缓存、空分享拒绝和缓存目标保留语义。 +- 决策:新增 `apps/mobile-shell/src/host-bridge/share.test.ts`,直接覆盖显式分享 payload、缓存作品目标、同源路径归一、非法 URL / 协议相对 URL 拒绝、非法显式 payload 不回退缓存、空分享拒绝和无效 `share.setTarget` 不清空已有目标;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。 +- 影响范围:`apps/mobile-shell/src/host-bridge/share.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/share.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动网络 HostBridge 单测边界 + +- 背景:Expo 移动壳 `network.status` 已经由 `src/host-bridge/network.ts` 包装真实 Expo Network 查询,但可执行测试主要在 shell network 归一化和巨型 bridge 测试中,缺少对 HostBridge response 形状、离线状态和底层网络失败传播的直接 helper 覆盖。 +- 决策:新增 `apps/mobile-shell/src/host-bridge/network.test.ts`,直接覆盖 `network.status` 成功响应、断网响应和原生查询失败传播;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不新增 capability,不改变 shell network 归一化逻辑,也不在分发层包装网络状态。 +- 影响范围:`apps/mobile-shell/src/host-bridge/network.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/network.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动外观 HostBridge 单测边界 + +- 背景:Expo 移动壳 `appearance.getColorScheme` 是 H5 判断宿主配色的只读系统能力,但此前直接 helper 边界主要压在巨型 bridge 测试中;后续拆分桥接 helper 时需要固定 light / dark / unknown 归一和 HostBridge response 形状。 +- 决策:新增 `apps/mobile-shell/src/host-bridge/appearance.test.ts`,直接覆盖 React Native `Appearance.getColorScheme()` 的 light / dark、空值 / 未知值归一为 `unknown`,以及 `appearance.getColorScheme` HostBridge 成功响应形状;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不改变 H5 主题策略,也不让壳层覆盖系统或用户偏好。 +- 影响范围:`apps/mobile-shell/src/host-bridge/appearance.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/appearance.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动角标 HostBridge 单测边界 + +- 背景:Expo 移动壳 `app.setBadgeCount` 只在 iOS capability profile 中声明,Android 请求到达时必须明确返回 unsupported;此前 iOS 设置 / 清除、非法 payload 和 Android unsupported 主要压在巨型 bridge 测试中,缺少对 `badge.ts` helper 的直接覆盖。 +- 决策:新增 `apps/mobile-shell/src/host-bridge/badge.test.ts`,直接覆盖 iOS badge 权限已存在、权限缺失时请求 `allowBadge`、权限拒绝不触碰系统角标、`setBadgeCountAsync(false)` / reject 映射为稳定失败、非法数量和缺少 payload 时不触碰系统角标,以及 Android 在 payload 校验前返回 `unsupported_capability` 的顺序;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不把 `app.setBadgeCount` 加入 Android base capability。 +- 影响范围:`apps/mobile-shell/src/host-bridge/badge.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/badge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动运行态 HostBridge 单测边界 + +- 背景:Expo 移动壳 `host.getRuntime` 是 H5 回读宿主版本、平台和 capability profile 的入口;此前 iOS / Android 平台差异和回包形状主要压在巨型 bridge 测试中,缺少对 `runtime.ts` helper 的直接覆盖。 +- 决策:新增 `apps/mobile-shell/src/host-bridge/runtime.test.ts`,直接覆盖 iOS runtime 的 `hostVersion`、`bridgeVersion`、能力清单和 `app.setBadgeCount`,Android runtime 不声明 iOS 专属角标能力,以及 `host.getRuntime` HostBridge 成功响应形状;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不新增 capability,不改变入口 query 或 H5 runtime 回读策略。 +- 影响范围:`apps/mobile-shell/src/host-bridge/runtime.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/runtime.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 原生壳能力声明必须绑定真实链路 + +- 背景:桌面壳 capability profile 已声明一组 HostBridge request 能力,Expo 移动壳也声明本地通知、拍照、扫码、网络事件和分享等原生能力,H5 平台入口还会通过 `navigation.openNativePage` 打开 `/child-motion-demo` 这类受控内置玩法路由;如果门禁只检查“有 method case”或“文件被扫描”,未来可能退化成 fallback-only 分支或普通 Web 跳转而不被发现。 +- 决策:桌面壳配置检查必须反查每个已声明 request capability 对应的真实模块委托,并拒绝由 `unsupported_method`、`unsupported_capability` 或 fallback-only case 支撑的声明能力;根级 `check:native-shells` 新增 H5 native app route flow 合约,锁定 `/child-motion-demo` 的 `navigateHostNativePage` 调用、浏览器 fallback、路由表和命名交互测试;Expo 移动壳关键 capability flow 合约必须反查共享移动 profile、真实 Expo / React Native API、权限或配置片段、宿主分发文件和对应测试清单;Tauri 桌面壳关键 capability flow 合约必须反查共享桌面 profile、真实 Tauri 插件 / Rust 系统 API、宿主分发文件、事件注入链路和对应单端检查片段。 +- 影响范围:`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 原生壳结构门禁清单必须自检唯一性 + +- 背景:`scripts/check-native-shells.mjs` 依赖显式期望清单锁定微信、移动和桌面壳文件结构;如果期望清单自身混入重复项,目录比对仍可能失去清晰错误定位。 +- 决策:根级原生壳结构门禁在比对真实目录前必须先检查所有期望清单和微信页面文件清单的唯一性,发现重复项直接失败。 +- 影响范围:`scripts/check-native-shells.mjs`。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 Deep Link 失败必须可观测 + +- 背景:Expo 移动壳已声明 `genarrative://` scheme、iOS associated domain 和 Android app link,冷启动 / 热启动 deep link 会决定用户是否落到作品详情、创作页或邀请码页;如果 `Linking.getInitialURL()` 读取失败或运行时 URL 被拒绝后静默回首页,真实安装包会表现为“能打开 App 但目标丢失”,排障也缺少证据。 +- 决策:移动壳 deep link 解析必须返回 `default` / `mapped` / `rejected` 状态;外域、危险协议或非法路径继续回到安全默认主站入口,但必须记录拒绝日志。`Linking.getInitialURL()` reject 必须记录 `initial_url.read` 错误且不替换当前 WebView URL;运行时 URL 被拒绝必须记录 `runtime_url.rejected`,并继续落安全默认入口。配置检查反查 ShellApp 日志路径、deep link 状态 resolver 和对应测试。 +- 影响范围:`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run mobile-shell:test -- src/shell/deepLink.test.ts src/shell/ShellApp.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳外链失败与扫码超时边界 + +- 背景:Expo 移动壳外链导航会离开带 HostBridge 的主 WebView,扫码能力也会打开原生相机 overlay;如果系统外链 API 异常被 helper 吞掉,或扫码 pending 没有共享超时清理,用户会看到点击无反应或后续扫码一直提示通道占用。 +- 决策:`openMobileShellExternalNavigation(...)` 只在非法 URL 或系统明确不能打开时返回 `false`,原生 `canOpenURL` / `openURL` 异常必须抛给 `ShellApp` 的 `logMobileShellNavigationFailure(...)` 记录;`scanner.scanQrCode` pending 状态必须使用共享 `HOST_BRIDGE_SCANNER_TIMEOUT_MS` 自动拒绝并清理,成功、取消、失败和测试 reset 都必须清理 timer。 +- 影响范围:`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/navigation.test.ts`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/scanner.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/navigation.test.ts src/shell/ShellApp.test.tsx src/host-bridge/scanner.test.ts src/shell/QrScannerOverlay.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳门禁脚本与网络状态测试边界 + +- 背景:Tauri 桌面壳 `network.status` 已由 `host_bridge/network.rs` 统一包装,但成功响应和底层 resolver 失败映射主要靠字符串门禁;同时桌面单端检查没有把 `apps/desktop-shell/scripts/check-config.mjs` 自身纳入脚本清单和生产替身词扫描。 +- 决策:`host_bridge/network.rs` 新增可注入映射 helper,Rust 单测直接覆盖 `network.status` 成功 response shape 和 resolver 失败不暴露原生细节;桌面壳单端配置检查登记并扫描 `scripts/check-config.mjs`,根级文档门禁改为只从“结构门禁按完整相对路径”canonical 段反查文件清单,短清单只保留指针文案。 +- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::network shell::network`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳文件能力系统异常必须可观测 + +- 背景:Expo 移动壳已声明文本 / 文档 / 图片 / 音频导入导出、拍照和相册能力;这些能力会打开系统分享面板、DocumentPicker、相册、相机或读取缓存文件。如果原生 API reject 后只返回稳定 HostBridge 错误,H5 语义是安全的,但开发侧难以区分系统能力缺失、权限 API 异常、文件读取失败或分享面板失败。 +- 决策:`apps/mobile-shell/src/host-bridge/files.ts` 必须在 Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时记录 `mobile HostBridge file failed for ...` 日志;HostBridge 对 H5 仍只返回稳定 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不透传原生异常明细。移动壳配置检查反查日志 helper、关键 label 和对应单测。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/files.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳通知与角标系统异常必须可观测 + +- 背景:Expo 移动壳已声明即时本地通知,iOS 额外声明应用角标;这些能力会触发系统权限读取、权限请求、Android channel 设置、通知调度和角标更新。如果原生 API 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分权限模块异常、系统通知调度失败或角标 API 拒绝。 +- 决策:`apps/mobile-shell/src/host-bridge/notifications.ts` 必须在权限读取 / 请求和通知投递失败时记录 `mobile notification failed for ...` 日志;`apps/mobile-shell/src/host-bridge/badge.ts` 必须在角标权限读取 / 请求、`setBadgeCountAsync` reject 和返回 `false` 时记录 `mobile app badge failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标 capability。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/notifications.test.ts src/host-bridge/badge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳剪贴板触觉网络异常必须可观测 + +- 背景:Expo 移动壳已声明剪贴板读写、触觉反馈和网络状态查询;这些能力会触发 Expo Clipboard、Haptics 和 Network 原生模块。如果原生 API 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统剪贴板不可用、触觉模块异常或网络模块查询失败。 +- 决策:`apps/mobile-shell/src/host-bridge/clipboard.ts` 必须在剪贴板写入 / 读取失败时记录 `mobile clipboard failed for ...` 日志;`apps/mobile-shell/src/host-bridge/haptics.ts` 必须在触觉派发失败时记录 `mobile haptics failed for ...` 日志;`apps/mobile-shell/src/host-bridge/network.ts` 必须在 HostBridge 网络状态查询失败时记录 `mobile network failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增后台网络探测、任意系统能力或 H5 业务兜底路径。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/clipboard.test.ts src/host-bridge/haptics.test.ts src/host-bridge/network.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳剪贴板与通知异常必须可观测 + +- 背景:Tauri 桌面壳已声明剪贴板读写和即时本地通知;这些能力会触发 Tauri clipboard-manager 和 notification 插件。如果插件异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统剪贴板不可用、通知权限查询异常或系统通知投递失败。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs` 必须在剪贴板写入 / 读取失败时记录 `desktop clipboard failed for ...` 日志;`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 必须在通知权限状态读取、权限请求和通知投递失败时记录 `desktop notification failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增遥测 SDK、后台通知、自动更新或 H5 直连 Tauri JS 插件。 +- 2026-06-21 调整:桌面剪贴板和本地通知失败日志只记录 `desktop clipboard failed for ` / `desktop notification failed for ` 固定标签,不输出 clipboard-manager 插件错误、notification permission / delivery 插件错误、系统剪贴板细节或其它平台异常字符串;配置检查拒绝 `clipboard.rs` / `notifications.rs` 重新拼接 `: {error}` 或把写入 / 读取 / 权限 / 投递错误传给日志函数。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::clipboard host_bridge::notifications`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳文件能力系统异常必须可观测 + +- 背景:Tauri 桌面壳已声明文本 / 文档 / 图片 / 音频导入导出;这些能力会打开系统文件对话框、转换系统路径并在后台线程读写文件。如果系统路径转换、后台读写或任务 join 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统对话框路径异常、文件系统失败或后台任务失败。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 必须在导出路径转换、导出写入、导入路径转换和导入后台读取 join 失败时记录 `desktop file export failed for ...` 或 `desktop file import failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义,不透传本地路径、系统错误或线程细节;用户取消系统文件对话框仍返回 `cancelled`,不记录为异常。 +- 2026-06-21 调整:桌面文件导入导出失败日志只记录 `desktop file export failed for ` / `desktop file import failed for ` 固定标签,不输出本地路径转换错误、文件读写错误、后台任务 join 错误或其它系统细节;配置检查拒绝 `files.rs` 重新拼接 `: {error}` 或把路径 / 读写 / join 错误传给日志函数。 +- 2026-06-21 调整:桌面文件导入的 MIME、类型和大小校验错误继续以稳定 `invalid_request` 返回 H5;`fs::metadata`、`fs::read`、`fs::read_to_string` 等原生读取失败统一折叠为 `host_error` / `file import unavailable`,只记录 `read.text`、`read.document`、`read.image`、`read.audio` 固定阶段标签,不把系统 IO 错误字符串作为 HostBridge 错误消息或 stderr 明细输出。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::files`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳窗口状态小能力系统异常必须可观测 + +- 背景:Tauri 桌面壳的外观、角标和窗口标题能力都依赖主窗口和平台系统 API;这些能力对 H5 必须保持稳定错误语义,但开发侧也需要知道是主窗口缺失、主题读取失败还是系统 API 调用失败。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs` 和 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs` 必须在主窗口缺失、主题读取失败、角标设置失败和窗口标题设置失败时分别记录 `desktop appearance failed for ...`、`desktop app badge failed for ...` 或 `desktop window title failed for ...` 日志。HostBridge 对 H5 仍只返回稳定 `appearance unavailable`、`badge unavailable` 或 `window title unavailable`,不透传系统错误、窗口内部信息或平台细节。 +- 2026-06-21 调整:桌面外观、角标和窗口标题能力失败日志只记录 `desktop appearance failed for ` / `desktop app badge failed for ` / `desktop window title failed for ` 固定标签,不输出主窗口缺失文本、Tauri `theme()` / `set_badge_count` / `set_title` 错误或其它平台细节;配置检查拒绝 `appearance.rs` / `badge.rs` / `title.rs` 重新拼接 `: {error}` 或 `&error.to_string()`。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::appearance`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::badge`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::title`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳导航系统异常必须可观测 + +- 背景:Tauri 桌面壳的外链打开、同源 H5 route 导航和 WebView reload 都直接影响原生壳内 H5 的完整流程;这些系统调用失败时,H5 只应得到稳定错误语义,但开发侧需要能区分外链打开失败、窗口导航失败、reload 失败和主窗口缺失。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs` 必须在外链打开失败、同源 H5 route 导航失败、WebView reload 失败和主窗口缺失时记录 `desktop navigation failed for ...` 日志。HostBridge 对 H5 仍只返回稳定 `external URL cannot be opened`、`native page unavailable` 或 `webview reload unavailable`,不透传系统错误、窗口内部信息或平台细节。 +- 2026-06-21 调整:桌面导航失败日志只记录 `desktop navigation failed for ` 固定标签,不输出 opener、window.navigate、WebView reload 错误或主窗口缺失说明;配置检查拒绝 `navigation.rs` 重新拼接 `: {error}`、`&error.to_string()` 或把主窗口缺失文本传给日志函数。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::navigation`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳网络状态系统异常必须可观测 + +- 背景:桌面壳 `network.status` 通过后台任务解析系统网络状态,H5 只需要稳定在线 / 离线语义;但后台任务 join 失败时,如果只返回稳定 `host_error`,开发侧无法区分正常离线、解析任务失败和系统异常。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/network.rs` 必须在网络状态解析失败时记录 `desktop network failed for status.resolve` 日志。HostBridge 对 H5 仍只返回稳定 `network status unavailable`,不透传 resolver、线程或系统错误细节。 +- 2026-06-21 调整:桌面网络状态解析失败日志只记录 `desktop network failed for status.resolve` 固定标签,不输出后台任务 join 错误、resolver 异常或其它平台细节;配置检查拒绝 `network.rs` 重新拼接 `: {error}` 或把解析错误传给日志函数。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::network`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳系统分享异常必须可观测 + +- 背景:移动壳 `share.open` 调用 React Native 系统分享面板,失败时 H5 只需要知道分享不可用并保留复制链接等回退;但如果原生分享面板 reject 没有日志,开发侧无法区分分享面板不可用、系统取消异常或平台分享模块异常。 +- 决策:`apps/mobile-shell/src/host-bridge/share.ts` 必须在 `Share.share(...)` reject 时记录 `mobile share failed for open.share` 日志。HostBridge 对 H5 仍只返回稳定 `share unavailable`,不透传原生分享面板异常明细。 +- 验证方式:`npm run mobile-shell:test -- --run src/host-bridge/share.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳分享缓存内部异常不得透传 + +- 背景:桌面壳 `share.setTarget` 和 `share.open` 会读写 Rust 侧分享目标缓存;如果缓存锁异常直接返回 `share target lock poisoned`,H5 会看到 Rust 内部同步原语细节,且开发侧没有统一日志标签定位读缓存还是写缓存失败。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/share.rs` 必须在分享目标缓存读写失败时分别记录 `desktop share failed for target.lock` 或 `desktop share failed for target.store` 日志。HostBridge 对 H5 仍只返回稳定 `share unavailable`,不透传锁状态、内部缓存状态或 Rust 同步原语细节。 +- 2026-06-21 调整:桌面分享缓存失败日志只记录 `desktop share failed for target.lock` / `desktop share failed for target.store` 固定标签,不输出锁污染说明、内部缓存状态或其它 Rust 同步原语细节;配置检查拒绝 `share.rs` 重新拼接 `: {error}` 或把锁异常字符串传给日志函数。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::share`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳门禁脚本必须自登记自扫描 + +- 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。 +- 决策:`apps/mobile-shell/scripts/check-config.mjs` 必须登记 `check-config.mjs`、`check-eas-build-config.mjs`、`check-expo-config.mjs` 和 `check-expo-export.mjs` 的完整脚本清单,并将 `check-config.mjs` 自身纳入生产替身词扫描;脚本内反查测试 mock 片段时使用字符串拼接保留测试约束,不让门禁自身违反生产规则。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 原生分享动作按宿主真实表现展示 + +- 背景:`share.open` 是原生壳受控分享动作,不等同于每个宿主都打开系统分享面板;Expo 移动壳会打开系统分享面板,Tauri 桌面壳则把归一后的分享文本写入系统剪贴板并返回 `copied_to_clipboard`。 +- 决策:H5 发布分享弹窗必须按 `hostShell` 展示分享动作文案:`expo_mobile` 继续显示“系统分享 / 已打开 / 分享失败”,`tauri_desktop` 显示“复制分享文案 / 已复制 / 复制失败”;根级原生壳门禁反查 `PublishShareModal` 源码和测试,防止桌面剪贴板动作再次被包装成系统分享面板。 +- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/PublishShareModal.test.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-21 H5 原生能力必须来自真实 runtime 回包 + +- 背景:Expo / Tauri 壳会把 `clientRuntime`、`hostShell` 和 `hostCapabilities` 写入 H5 URL,用于保留宿主上下文和路由状态。如果 H5 在 `host.getRuntime` 异步回包前把 URL query 中的 `hostCapabilities` 作为真实能力来源,深链旧参数或伪造 query 会让首屏短暂展示或触发原生动作。 +- 决策:H5 仍可用 URL query 判断宿主类型和保留上下文,但 `canUseNativeHostCapability` 只能信任真实 native bridge 存在且 `host.getRuntime` 已缓存的 capability;query 中的 `hostCapabilities` 不再参与能力门控。`host.getRuntime` 刷新只依赖真实 Expo WebView / Tauri invoke 注入,不依赖 query capability。桌面 Tauri 事件白名单必须等于桌面 capability 中已声明的事件子集,不得包含未声明的 `network.statusChanged`。 +- 2026-06-21 调整:H5 生产代码不得直接 import `src/services/host-bridge/nativeAppHostBridge.ts` 低层 transport;业务层、组件层和 wrapper 必须经 `src/services/host-bridge/hostBridge.ts` facade 使用原生能力,保证真实 runtime 回读、capability 门控、payload 归一和事件订阅门控始终生效。`scripts/check-native-shells.mjs` 负责扫描生产 H5 源码并拒绝绕过 facade 的直接 transport 依赖。 +- 影响范围:`src/services/host-bridge/hostBridge.ts`、H5 HostBridge 消费测试、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`scripts/check-native-shells.mjs`。 +- 验证方式:`npm run test -- src/services/host-bridge/hostBridge.test.ts src/services/runtimeAudioFeedback.test.ts src/App.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PublishShareModal.test.tsx src/components/platform-entry/platformHostBridgeSync.test.ts`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::events`、`npm run check:native-shells`。 + +## 2026-06-21 声明的原生壳能力必须有真实能力流证据 + +- 背景:Expo 移动壳和 Tauri 桌面壳的 capability 清单会直接影响 H5 是否展示和调用原生能力。如果新增 capability 只写进共享契约或 Rust / TS 能力清单,却没有登记真实宿主 API、分发入口、payload 边界和测试证据,H5 可能认为能力可用,但运行时没有对应真实链路。 +- 决策:`scripts/check-native-shells.mjs` 必须对 Expo 移动壳和 Tauri 桌面壳做反向覆盖:共享契约中声明的每个移动端基础能力、iOS 额外能力和桌面能力,都必须在 `mobileCapabilityFlowContracts` 或 `desktopCapabilityFlowContracts` 中登记真实能力流证据。证据必须来自生产实现、宿主配置、边界测试或单端配置检查,不能用占位、生产 mock、fallback unsupported 分支或文档愿景替代真实链路。 +- 2026-06-21 调整:Tauri 桌面能力流必须由根级 `desktop-shell:test` 保护,且每个 `desktopCapabilityFlowContracts` 条目至少关联一个带 Rust 单测的真实桌面壳模块;新增桌面 capability 时不能只登记 dispatch / 配置片段而没有 Rust 单元测试覆盖。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/`、`apps/mobile-shell/src/shell/`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/src-tauri/src/shell/`、`scripts/check-native-shells.mjs`。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-21 三端 HostBridge 模块必须先分类再扩展 + +- 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳已经按相近目录结构拆出桥接层,但同名能力并不总是三端共享;如果后续只靠文件清单约束,新增模块可能在某一端随意落点,破坏“三端尽量一致、端专属能力明确隔离”的管理目标。 +- 决策:`scripts/check-native-shells.mjs` 必须把 HostBridge 模块分成三端共同、Expo / Tauri 原生 App 共同、移动端专属、桌面端专属和微信端专属五类,并从现有文件清单反推实际分类。新增、拆分或迁移桥接模块时,必须先更新分类归属,再同步目录清单、文档和能力流证据。 +- 影响范围:`miniprogram/host-bridge/`、`apps/mobile-shell/src/host-bridge/`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-21 Tauri devUrl 与 Vite 端口必须显式对齐 + +- 背景:`npm run desktop-shell:dev` 通过 Tauri `devUrl` 固定加载 `http://127.0.0.1:3000/`,但 Linux dev 端口段逻辑会把未显式指定的 `dev:web` 主站端口映射到用户端口段,例如 `10000+`。只在桌面壳 package script 里设置 `WEB_PORT=3000` 不会让 `scripts/dev.mjs` 把 Web 端口视为显式 CLI 参数,结果 Tauri 仍打开 3000,而 Vite 实际监听其它端口。 +- 决策:桌面壳 `beforeDevCommand` 必须执行 `npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port`,用 CLI 参数锁定主站 Vite 端口并禁止静默漂移;`devUrl` 继续固定 `http://127.0.0.1:3000/`。如果 3000 被占用,应该释放端口后再启动桌面壳,而不是让 Vite 漂移后继续由 Tauri 加载旧端口。由于主窗口设置了 `create=false` 并由 Rust 手动创建,`app.rs` 在 dev build 下必须把主窗口 URL 替换为 `build.devUrl` 后再补写 HostBridge query;release 仍从 `index.html` 打包资源进入。 +- 2026-06-22 调整:release 打包资源在 Windows WebView 内可能以 `http://tauri.localhost/index.html` 出现,这仍是 Tauri 内部资源,不允许被导航拦截交给系统浏览器;`shell/navigation.rs` 必须允许 `http` / `https` 的 `*.localhost` 留在 WebView。Windows release 二进制必须使用 GUI subsystem,避免正式包启动时额外弹出控制台窗口。 +- 影响范围:`apps/desktop-shell/src-tauri/tauri.conf.json`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/dev.test.ts`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 5a76fb868..cdff5605e 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -211,6 +211,31 @@ npm run test npm run build ``` +原生壳验收: + +```bash +npm run check:native-shells +``` + +该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、微信 capability 到真实 WebView / 支付 / 分享页面流程和测试清单的映射门禁、H5 HostBridge 事件订阅双能力门控反查、H5 `navigation.canGoBack` 消费 hook 与直达二级页返回锚点测试、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / EAS build config smoke / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 EAS 原生包构建 profile、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口、H5 页面内导航保留完整原生宿主上下文和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、Tauri `Info.plist`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件,但不扫描 Expo export、Tauri `target/`、Cargo / Metro 缓存或 release 构建产物。移动壳配置检查必须反查 EAS 生产 profile、文本 / 文档 / 图片 / 音频导入边界都来自共享 HostBridge 契约。登录与支付外链跳转必须保持在该调用链扫描内,`src/services/authService.ts` 和 `src/services/payment/paymentRedirect.ts` 是必扫文件;`AuthGate` 的登录成功、退出登录、身份边界刷新和登录状态异常重试都必须通过 `app.reloadWebView` 优先路径,并由 `src/components/auth/AuthGate.test.tsx` 进入该门禁。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造 / 未实现 / 临时;H5 业务调用链允许正常表单 `placeholder` 属性、业务占位图文案和真实兼容 / 故障语义中的“未实现”“临时”表述,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 +创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。 +创作 Agent 参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给既有 `onReferenceImageChange` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。 +创作 Agent 轻输入 composer 的参考图按钮在原生壳声明 `file.importImage` 时必须优先走宿主图片导入;移动壳声明 `file.captureImage` 时才显示拍摄参考图入口,并把宿主图片同样转为 `File` 后复用 `readPuzzleReferenceImageAsDataUrl` 的类型、大小、压缩和预览链路。 +反馈页上传凭证在原生壳声明 `file.importImage` 时必须优先走宿主图片导入;移动壳声明 `file.captureImage` 时才显示拍摄凭证入口,并把拍摄图片同样转为 `File` 后复用反馈页原有数量、大小、MIME、data URL 预览和提交 payload 校验。 +固定内置 H5 体验入口在原生壳声明 `navigation.openNativePage` 时必须优先走 `navigateHostNativePage()`;例如儿童动作热身 Demo 从平台首页进入 `/child-motion-demo` 时应由 HostBridge 发出 `navigation.openNativePage`,宿主不可用时才回退浏览器跳转。 +H5 支付链接跳转在原生壳声明 `app.openExternalUrl` 时必须优先走宿主系统浏览器;原生壳未接真实支付 SDK 前不得声明 `payment.request`,也不得把外部 H5 支付跳转伪装成原生支付成功。 +微信 OAuth 登录授权 URL 在原生壳声明 `app.openExternalUrl` 时必须优先走宿主系统浏览器;原生壳未接真实登录 SDK 前不得声明 `auth.requestLogin`,也不得把网页登录跳转伪装成原生登录成功。 +汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。 +抓大鹅结果页发布封面图和封面参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。 +RPG 角色资产工作室的角色参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。 +RPG 作品封面上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 10 MiB 校验、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路;用户取消原生选择不应再连带弹出浏览器文件输入。 +RPG 作品封面参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readImageFileAsDataUrl` 读取、预览和 `generateCustomWorldCoverImage` payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。 +RPG 场景图片参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readImageFileAsDataUrl` 读取、预览和 `rpgCreationAssetClient.generateSceneImage` payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。 +视觉小说结果页封面 / 角色 / 场景图片和音乐 / 环境音上传在原生壳声明 `file.importImage` / `file.importAudio` 时必须优先走宿主受控导入,并把 H5 base64 转 `File` 后继续交给 `uploadVisualNovelAsset` 与当前素材字段写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,历史素材选择和 AI 图片生成保持原链路。 +该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。 +该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 +该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 + 内容检查: ```bash diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 3952997e7..ea8afce2b 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -13,6 +13,7 @@ | 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` | | 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` | | 创作流程统一阶段计划 | `docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md` | +| 宿主壳、移动 App、桌面 App 与 AI H5 沙箱边界 | `docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`、`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md` | | 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` | | 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6a5e3f533..9a6048138 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2490,6 +2490,30 @@ - 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。 - 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md`、`server-rs/crates/api-server/src/jump_hop.rs`。 +## Tauri devUrl 不会自动跟随 dev:web 端口漂移 + +- 现象:运行 `npm run desktop-shell:dev` 时终端显示主站 Vite 实际启动在 `10000+` 端口,但 Tauri 窗口仍加载 `http://127.0.0.1:3000/`,桌面壳表现为白屏、连接失败或加载到旧页面。 +- 原因:Linux dev 端口段只把 CLI `--web-port` 视为显式端口;桌面壳 package script 里的 `WEB_PORT=3000` 会被端口段映射覆盖。Tauri `devUrl` 是静态配置,不会读取 `scripts/dev.mjs` 最终解析出的漂移端口。 +- 处理:桌面壳 `beforeDevCommand` 必须使用 `npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port`,让 Vite 实际监听端口和 Tauri `devUrl` 一致,并在 3000 被占用时直接失败。若 3000 被占用,先释放占用进程再启动桌面壳,不要依赖 Vite 漂移。 +- 验证:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"`、`npm run desktop-shell:typecheck`、实际启动时终端应显示 `[dev] web: http://127.0.0.1:3000`。 +- 关联:`apps/desktop-shell/src-tauri/tauri.conf.json`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/dev.mjs`。 + +## Tauri 手动创建主窗口时 devUrl 不会自动套到 index.html + +- 现象:`npm run desktop-shell:dev` 启动后窗口地址显示 `http://tauri.localhost/index.html` 或 `tauri://localhost/index.html`,即使 Vite 已经在 `http://127.0.0.1:3000/` 正常监听。 +- 原因:桌面壳为了注册导航、下载、生命周期和托盘行为,把 Tauri 配置里的主窗口设为 `create=false`,再在 Rust `app.rs` 中用 `WebviewWindowBuilder::from_config(...)` 手动创建窗口。此时如果只读取 `app.windows[].url = index.html` 并补 HostBridge query,手动窗口会沿 release 入口走打包资源协议;Tauri CLI 的 `build.devUrl` 不会自动替换这份手动克隆后的窗口 URL。 +- 处理:`app.rs` 在 dev build 下必须先把主窗口 URL 替换为 `config.build.dev_url`,再调用 `desktop_window_config_with_runtime_platform(...)` 补写宿主上下文;`shell/navigation.rs` 也必须允许 dev build 下的 `http://127.0.0.1:3000` 留在 WebView 内,不要把自己的 Vite 首页当外链交给系统浏览器。release build 保持 `index.html` 打包入口。 +- 验证:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages`,实际启动时窗口应加载 `http://127.0.0.1:3000/...` 而不是 `tauri.localhost/index.html`。 +- 关联:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/tauri.conf.json`。 + +## Tauri release 的 tauri.localhost 不要交给系统浏览器 + +- 现象:Windows / release 包启动桌面壳时,系统默认浏览器被打开到 `http://tauri.localhost/index.html`。 +- 原因:release 打包资源在 WebView 内可能表现为 `tauri://localhost/index.html`、`https://tauri.localhost/index.html` 或 `http://tauri.localhost/index.html`;如果导航白名单只允许 `tauri:` 和 `https://*.localhost`,`http://tauri.localhost` 会被误判成普通外链并交给 `opener.open_url`。 +- 处理:桌面壳导航策略必须把 `http` / `https` 的 `*.localhost` 都视为 Tauri 内部打包资源,只允许真正外部 `http` / `https`、`mailto`、`tel` 走系统浏览器。Windows release 入口还必须使用 `windows_subsystem = "windows"`,避免正式包额外弹出控制台窗口;dev build 保留控制台日志。 +- 验证:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_webview_navigation_stays_on_packaged_or_same_origin_pages`、`npm run desktop-shell:typecheck`、Windows release 启动时不应打开系统浏览器或控制台窗口。 +- 关联:`apps/desktop-shell/src-tauri/src/main.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/scripts/check-config.mjs`。 + ## 自动试玩退出不要回到生成页 - 现象:拼图草稿生成完成后自动进入试玩,用户从试玩退出或使用系统返回时落回生成进度页,页面还暴露“重新生成”按钮。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 23ae3d6e9..441a1dddd 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -108,12 +108,12 @@ - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。 -- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。 -- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入默认素材文件夹并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。 +- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 +- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。 - 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `` 触发的落盘动作;用户保存文本、图片、音频等内容必须走已声明的 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method,由 Rust 侧执行 MIME、大小、文件名清洗和系统保存对话框确认。 +- 主 WebView 显式关闭 DevTools,Cargo 不启用 Tauri `devtools` feature;本地调试通过普通浏览器和 Vite 完成,不把可分发桌面壳变成调试容器。 +- 崩溃上报、前端 analytics、桌面遥测日志、自动更新和渠道分发 SDK 都必须等真实端点、采集字段、用户同意、隐私策略、签名和发布流程确定后逐项接入;当前桌面壳不安装 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等相关依赖。 +- 桌面壳和根 H5 包不安装 `@tauri-apps/api` 或 `@tauri-apps/plugin-*` JS guest 包;生产 H5 只通过 Tauri 注入的 `window.__TAURI__.core.invoke('host_bridge_request', request)` 进入 HostBridge。opener、clipboard、dialog、notification 等能力只保留 Rust Cargo 插件,由 Rust 内部分发并受 capability 白名单约束。 +- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://www.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。`navigation.openNativePage` 的同源主动跳转也必须复用同一宿主上下文补写逻辑,避免新页面按普通浏览器运行态启动。 +- 桌面窗口状态持久化属于宿主壳自有体验,不进入 HostBridge capability,也不开放窗口状态插件 command 给 H5。Tauri 壳只保存主窗口大小、位置和最大化状态,不保存可见性、全屏或装饰状态,避免托盘隐藏窗口后下次启动被恢复成隐藏状态;`shell/window_state.rs` 必须保留 Rust 单测证明这组 flags 边界。 +- 桌面壳外链打开、WebView 新窗口外链接管、托盘关闭前生命周期注入和窗口隐藏都属于用户可见宿主动作;这些动作失败必须走统一桌面宿主事件日志,配置检查拒绝 `let _ = ...` 静默吞错。 +- 桌面壳托盘注册失败只允许记录 `desktop tray registration failed` 固定标签,不把 Tauri tray 插件错误详情写入可分发桌面壳 stderr;托盘不可用时主窗口关闭仍按无托盘路径退出。 + +桌面 release 和 dev 模式: + +```text +release: + Tauri binary + -> packaged web assets + -> /index.html?clientRuntime=native_app&hostShell=tauri_desktop... + +dev: + Tauri binary + -> http://127.0.0.1:/?clientRuntime=native_app&hostShell=tauri_desktop... +``` + +如果未来希望桌面端加载远端 H5 URL,必须额外做 origin allowlist、版本协商和 Tauri API 暴露限制;不能让任意远端页面拿到桌面命令。 + +## AI H5 沙箱边界 + +移动端和桌面端统一后,AI 生成 H5 游戏仍不能直接接入 `HostBridge`。 + +AI H5 游戏运行结构: + +```text +平台 H5 runtime + -> sandbox iframe + -> AI 生成 H5 游戏 + -> window.parent.postMessage(GameBridgeRequest) +``` + +GameBridge 只允许: + +- 读取启动参数和只读资产 URL。 +- 上报 ready、progress、score、event、error。 +- 提交候选结果给父页面,由父页面和后端裁决。 +- 请求有限的音频、震动、全屏等运行态能力。 + +GameBridge 禁止: + +- 登录、支付、订阅授权。 +- 读取 token、cookie、完整用户资料。 +- 任意网络代理。 +- 任意本地文件、剪贴板和系统命令。 +- 直接调用 Expo / Tauri / 小程序宿主能力。 + +## 安全约束 + +- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape;`id` 归一后必须是 1-120 字符且不含控制字符,`method` 必须来自共享白名单,未知 method 作为非法 request 拒绝。 +- 壳层只接受来自允许 origin / packaged asset 的消息。 +- H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 +- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,默认请求超时、最大请求超时和用户交互长操作超时以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS` 为唯一来源,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、受控分享动作、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果,已完成响应缓存上限以共享契约 `HOST_BRIDGE_RESPONSE_CACHE_MAX` 为唯一来源。 +- HostBridge 的 capability profile、宿主上下文 query 字段和值、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度、二维码文本长度、本地通知标题 / 正文长度、移动端 Android 本地通知 channel id 和桌面网络探测超时都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,微信小程序壳和 Tauri 壳分别保留小程序 CommonJS / Rust 运行时代码镜像并由测试和配置门禁反查共享契约。 +- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景,该回读请求的短超时以共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 为唯一来源。不能只凭 `native_app` 宿主类型假设能力可用。 +- 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须来自共享 HostBridge profile 并存在于共享白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、微信小程序页面路由、WebView source query、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段、微信壳 H5 / API HTTPS 域名格式、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。微信小程序壳不使用 Expo / Tauri 式统一 request dispatcher,但每个声明 capability 都必须在根级门禁中映射到真实流程文件、关键 `wx.*` 或页面工厂调用和对应测试清单;Expo 移动壳和 Tauri 桌面壳的关键能力也必须在根级门禁中映射到真实 Expo / React Native / Tauri API、权限或配置片段、宿主分发文件和对应测试清单。 +- Expo SDK、React Native、`react-native-webview`、Tauri CLI、Tauri Rust crate 和桌面 Cargo 插件版本属于宿主壳行为边界。升级这些依赖前必须同步更新壳配置检查、`package-lock.json` / `Cargo.lock` 解析版本、本文档和对应验证结果,不能只改 package / Cargo 版本让生产壳行为静默漂移。 +- 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin` 和 `payment.request` 的伪声明。 +- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。 +- 桌面壳不得提前安装或初始化崩溃上报、analytics、遥测日志、自动更新或渠道分发 SDK;这类能力必须先补齐真实后端 / 第三方端点、采集口径、用户授权、隐私披露、签名和发布流程,再进入 Tauri 配置、Cargo 依赖、Node 依赖或 Rust 初始化代码。 +- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。 +- Tauri 主 WebView 禁止默认下载落盘;桌面文件保存只能通过受控 HostBridge 导出能力进入系统保存对话框。 +- Tauri 主 WebView 禁止默认打开 DevTools;不得通过配置或 Cargo feature 为分发壳启用浏览器检查器。 +- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。 +- RN WebView 禁止网页自动下载、下载协议导航和 `` 直接落盘;禁止下载协议清单以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 为唯一来源,移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。 +- Expo 移动壳的通知能力只覆盖即时本地通知;Android 包配置必须阻断重启后通知恢复和精确定时权限,前端代码不得注册 Expo push token、远程推送监听或通知响应跳转流程。 +- AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。 + +## 分阶段落地 + +### Phase 1:补齐 H5 native_app adapter + +- 在 `src/services/host-bridge/` 增加 `nativeAppHostBridge` transport。 +- 定义 HostBridge envelope、method、错误码和超时策略。 +- `getHostRuntime()` 继续识别 `clientRuntime=native_app`。 +- 现有业务入口只通过 HostBridge 调用登录、支付、分享、原生页跳转。 +- 增加 H5 单测覆盖:支持、超时、不支持、错误回包、浏览器 fallback。 + +当前状态:已新增 `src/services/host-bridge/nativeAppHostBridge.ts`,支持 React Native WebView `postMessage` 和 Tauri `invoke('host_bridge_request')` 两种真实 transport。两条 transport 都会按 `timeoutMs` 在 H5 侧释放请求,超时统一抛出 `timeout / host_bridge_timeout`。登录、支付和原生页跳转如果宿主明确返回 `unsupported_method` / `unsupported_capability`,H5 回退到原有路径;生产代码不返回 mock 成功。 + +2026-06-19 追加:微信小程序壳当前真实能力完整清单为 `auth.requestLogin`、`payment.request`、`share.setTarget`、`share.open` 和 `navigation.openNativePage`。 + +### Phase 2:Expo 移动壳 MVP + +- 新增 `apps/mobile-shell/`。 +- 接入 `react-native-webview`,加载 H5 URL 并附加宿主 query。 +- 实现 HostBridge RN transport:runtime、openExternalUrl、share、clipboard、haptics。 +- Android 返回键与 H5 history 对齐。 +- iOS / Android 深链打开作品详情、创作页和邀请码。 +- 登录和支付先 fallback 到 H5;只把能力边界跑通。 + +当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口并记录拒绝日志;系统初始 URL 读取失败也会记录错误且保留当前安全入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.importText`、`file.importDocument`、`file.exportImage`、`file.importImage`、`file.captureImage`、`scanner.scanQrCode`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `` 点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`onShouldStartLoadWithRequest` 会在同源 / 外链分流前拒绝 `blob:`、`data:`、`file:` 和 `filesystem:` 导航,避免下载 URL 进入带完整 HostBridge 的 WebView 或交给系统外部应用;iOS `onFileDownload` 事件只丢弃不落盘,Android 包配置阻断外部存储读写、管理外部存储和请求安装包权限;H5 文本、图片、音频保存继续只能走 `file.exportText`、`file.exportImage`、`file.exportAudio` 的受控 HostBridge 导出能力。 + +2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。 + +2026-06-18 追加:`app.openExternalUrl` 的协议白名单以共享 HostBridge 契约 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 为唯一来源,当前只允许 `http:`、`https:`、`mailto:`、`tel:`。Expo 壳直接复用共享归一化逻辑,Tauri 壳 Rust 侧用 URL parser 镜像同一清单;`npm run check:native-shells` 会反查共享契约与桌面壳协议清单,防止某一端单独放宽外链协议。 + +2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`filePayloads.ts`、`files.test.ts`、`navigation.test.ts`、`navigation.ts`、`scanner.test.ts`、`scanner.ts`、`share.test.ts`、`share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs`、`capabilities.rs`、`dispatch.rs`、`files.rs`、`file_payloads.rs`、`share.rs`、`mod.rs` 对齐,其中移动 `files.ts` 只承接 Expo 系统文件交互和 HostBridge 响应包装,`filePayloads.ts` 承接 MIME、大小、base64、文件名和 picker payload 边界;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期、安全区、扫码 overlay 和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/app.rs`、`host_bridge/*.rs` 与 `shell/*.rs`,其中 `app.rs` 承接 Tauri builder / plugin / window 装配,`runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明模块并调用 `app::run()`。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 + +2026-06-19 追加:HostBridge 载荷边界以共享契约为单一声明来源。`packages/shared/src/contracts/hostBridge.ts` 导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback 与长度上限,以及 request id、角标、剪贴板和本地通知文本长度边界;Expo 移动壳必须直接导入这些共享常量,不再本地重声明文件大小或 MIME 清单,并且文本 / 音频导入必须在读取内容前通过 picker `size` 或 Expo `File.size` 完成大小门禁,无法拿到可信 byte count 时直接拒绝导入;Tauri 桌面壳的配置检查会反查 Rust 镜像实现,拒绝文件大小、MIME 清单、文件名、通知、剪贴板或 request id 边界与共享契约漂移。新增文件类型或调整体积上限必须先更新共享契约、壳实现和门禁,再进入玩法或 H5 facade。 + +### Phase 4:宿主能力扩展 + +- 移动端接入系统分享、推送、原生登录和渠道支付。 +- 移动端和桌面端的自动更新、崩溃上报、analytics、渠道分发、原生登录和渠道支付都必须等真实 SDK、后端契约、发布流程和隐私口径确定后逐项接入;文件导出、图片拖拽导入、系统托盘、即时本地通知和受控分享动作已按真实宿主能力逐项接入。 +- Tauri 桌面壳的文件导入导出执行边界分为两层:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 统一承接系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应,`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs` 统一承接 MIME、大小、base64、文件名清洗、本地副本读写和 payload 组装;`dispatch.rs` 只按 method 委托文件模块。 +- 所有新增能力先更新 HostBridge 契约和测试,再落壳实现。 + +### Phase 5:AI H5 sandbox + +- 定义 `GameBridge` 契约。 +- 生成代码包只进入 sandbox iframe。 +- 父页面负责资产授权、事件转发和后端裁决。 +- Expo / Tauri 壳只感知父页面 HostBridge,不直接感知 AI 游戏代码。 + +## 验收清单 + +- 普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`。 +- 未支持的宿主能力不会阻断主流程,H5 fallback 可用。 +- 固定玩法在四类宿主里读取同一作品数据和运行态 snapshot,不走代码包下载。 +- 支付、登录、分享都有幂等、超时和错误回包。 +- AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。 +- Tauri release 包不允许任意远端页面调用桌面命令。 +- Expo WebView 外链离开主站后不保留完整 HostBridge。 +- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、宿主上下文 query 契约、微信小程序页面路由与来源 query 反查、三端桥接层文件结构门禁、Expo 壳 typecheck / test / EAS build config smoke / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。 + +## 参考资料 + +- Expo development builds:`https://docs.expo.dev/develop/development-builds/introduction/` +- Expo custom native code:`https://docs.expo.dev/workflow/customizing/` +- Expo config plugins:`https://docs.expo.dev/config-plugins/introduction/` +- React Native WebView guide:`https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md` +- Tauri commands:`https://v2.tauri.app/develop/calling-rust/` +- Tauri capabilities:`https://v2.tauri.app/security/capabilities/` +- Tauri permissions:`https://v2.tauri.app/security/permissions/` + +## 关联文档 + +- `docs/【前端架构】宿主壳能力统一协议-2026-06-17.md` +- `src/services/host-bridge/hostBridge.ts` diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md new file mode 100644 index 000000000..8c95934f9 --- /dev/null +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -0,0 +1,121 @@ +# 宿主壳能力统一协议 + +更新时间:`2026-06-19` + +## 背景 + +当前主站已经同时运行在普通浏览器、微信小程序 `web-view` 和后续可能出现的原生 App WebView 中。登录、支付、分享、订阅授权、运行态分享目标同步等能力散落在业务组件和服务文件里,后续如果新增原生 App 壳,容易出现同一业务按宿主重复分叉。 + +本方案先建立 `HostBridge` 宿主壳协议,把浏览器、微信小程序壳和未来原生 App 壳统一成能力 adapter。固定玩法运行态仍作为平台内置 runtime,AI 生成 H5 仍走独立 sandbox 和受限 GameBridge;二者都不能直接拿完整宿主壳能力。 + +## 目标 + +1. H5 业务层只判断宿主能力,不直接散落判断 `wx.miniProgram`、`MicroMessenger`、`clientRuntime`。 +2. 微信小程序壳先作为 `wechat_mini_program` adapter 接入,保留现有登录、支付、分享、订阅授权行为。 +3. 未来原生 App 壳只新增 `native_app` adapter,不重写 H5 业务。 +4. 固定玩法继续读取作品数据、素材、运行态 snapshot 和后端裁决结果,不走代码包下载流程。 +5. AI H5 sandbox 只能通过受限 GameBridge 请求资产、上报事件和提交候选结果,不暴露登录、支付、token、完整用户资料。 + +## 非目标 + +- 不重写 React 主站和现有玩法 runtime。 +- 不把固定玩法迁成远程代码包。 +- 不在 HostBridge 层重写 React Native / Expo 或 Tauri 业务 UI。 +- 不改变支付到账、任务、排行榜、发布、统计等后端裁决口径。 + +## 分层 + +```text +H5 业务层 + -> HostBridge 能力接口 + -> browserHostBridge + -> wechatMiniProgramHostBridge + -> nativeAppHostBridge + +AI H5 sandbox + -> GameBridge 受限协议 + -> parent HostBridge adapter +``` + +桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` 承接 Expo DocumentPicker / ImagePicker / File / Sharing 系统交互、取消语义、读写编排和 HostBridge 响应包装,`filePayloads.ts` 承接文件 MIME、大小、base64、文件名清洗和 picker 结果到 HostBridge payload 的边界,`share.ts` / `scanner.ts` / `notifications.ts` 分别承接分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。`npm run check:native-shells` 会检查这些目录清单。 + +当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径;不要再维护只含文件名的短清单,避免测试文件、`file_payloads.rs` 或新增宿主脚本登记发生文档漂移。 + +`npm run check:native-shells` 还会按桥接模块语义分类三端结构:`dispatch` 和 `protocol` 必须同时存在于微信、移动和桌面壳;`appearance`、`badge`、`capabilities`、`clipboard`、`file-payloads`、`files`、`navigation`、`network`、`notifications`、`runtime`、`share` 是 Expo / Tauri 原生 App 壳共同模块;`bridge`、`haptics`、`scanner` 只属于移动壳,`mod`、`title` 只属于桌面壳,`payment`、`shareGrid`、`subscribeMessage`、`webView` 只属于微信壳。新增、拆分或迁移 HostBridge 模块时必须先在该分类中明确归属,再同步目录清单和能力流证据。 + +生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 + +声明为宿主请求能力的 desktop capability 必须在 `apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs` 命中真实模块委托,不能只由 `unsupported_method`、`unsupported_capability` 或 fallback 分支支撑;`apps/desktop-shell/scripts/check-config.mjs` 负责反查声明能力与委托函数的对应关系。H5 内置玩法如果通过 `navigation.openNativePage` 打开受控原生壳路由,也必须在 `scripts/check-native-shells.mjs` 登记 route flow、H5 fallback、路由表和命名交互测试,避免新增内置入口只停留在普通 Web 跳转。 + +桌面窗口状态持久化只属于 Tauri 宿主壳自身体验,不进入 HostBridge method 或 capability。`apps/desktop-shell/src-tauri/src/shell/window_state.rs` 必须用 Rust 单测证明只保存大小、位置和最大化状态,并排除可见性、全屏和装饰状态;`npm run check:native-shells` 会反查该测试边界。 + +桌面拖拽图片事件只在 Tauri 主窗口拖入真实有效图片时派发 `file.imageDropped`。目录、文本、损坏图片或没有任何有效图片的拖入不得生成 HostBridge payload,也不得把本地路径暴露给 H5;桌面壳配置检查会反查 `file_drop.rs` 的无效拖入单测。 + + 结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.test.ts`、`apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.test.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.test.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.test.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/filePayloads.test.ts`、`apps/mobile-shell/src/host-bridge/filePayloads.ts`、`apps/mobile-shell/src/host-bridge/files.test.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.test.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.test.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.test.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.test.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.test.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.test.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.test.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.test.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx`、`apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 + + 移动端结构门禁同步覆盖测试文件完整相对路径:`apps/mobile-shell/src/host-bridge/bridge.test.ts`、`apps/mobile-shell/src/host-bridge/dispatch.test.ts`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/src/shell/deepLink.test.ts`、`apps/mobile-shell/src/shell/lifecycle.test.ts`、`apps/mobile-shell/src/shell/loadFailure.test.ts`、`apps/mobile-shell/src/shell/navigation.test.ts`、`apps/mobile-shell/src/shell/network.test.ts`、`apps/mobile-shell/src/shell/runtime.test.ts`、`apps/mobile-shell/src/shell/safeArea.test.ts`、`apps/mobile-shell/src/shell/url.test.ts`、`apps/mobile-shell/src/shell/webViewHistory.test.ts`、`apps/mobile-shell/src/shell/webViewPolicy.test.ts`。 + +Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。 + +宿主上下文 query 的字段名和值以 `packages/shared/src/contracts/hostBridge.ts` 为源。`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime 识别可读取的 `clientRuntime`、`clientType`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;`HOST_BRIDGE_NATIVE_APP_QUERY_KEY`、`HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` 与 `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query;`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记;`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要跨路径保留的宿主字段,必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities` 完整运行态字段。Expo 移动壳直接引用共享常量,Tauri Rust 和微信小程序 CommonJS 运行时镜像由 `npm run check:native-shells` 反查;H5 `getHostRuntime()` 和路由保留列表不得重新手写这些字段。 + +移动壳 WebView 下载协议阻断清单以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 为源。Expo 壳导航拦截和 WebView 注入脚本必须复用同一清单,命中后直接拒绝进入带完整 HostBridge 的 WebView;移动端文件保存只通过受控 `file.exportText`、`file.exportImage`、`file.exportAudio` 能力进入系统分享 / 保存面板。 + +## 首批能力 + +- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。 +- `getHostAppearanceColorScheme()`:原生 App 宿主的受控外观查询入口。H5 可通过 `appearance.getColorScheme` 读取宿主当前 `light` / `dark` / `unknown` 配色模式;Expo 移动壳通过 React Native `Appearance.getColorScheme()` 读取系统偏好,Tauri 桌面壳通过主窗口 `theme()` 读取窗口主题。该能力只读,不改变 H5 主题,也不覆盖用户或系统偏好。 +- `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳和 Tauri 桌面壳都声明 `host.events`,表示宿主会通过 HostBridge message 派发事件;其中 Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发同名事件。桌面壳不会把 hidden、minimized 或 tray 扩成新的 `state`,而是读取 `is_visible()`、`is_minimized()`、`is_focused()` 后统一归一为 `active` / `inactive` / `background`,并只把 `hidden`、`minimized`、`focused`、`blurred` 放进 `nativeState` 用于排障。`host.events` 不作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。H5 统一通过 `useHostLifecycleActive()` 把宿主状态折算为运行态可播放状态;WebAudio 背景音乐和固定玩法 `` 语义。H5 支付链接和微信 OAuth 登录授权 URL 也走该入口:原生壳未声明真实 `payment.request` / `auth.requestLogin` 前,微信 H5 支付 URL 和后端返回的微信登录授权 URL 优先交给宿主系统浏览器,宿主未处理时才回退当前 WebView 跳转;不得把 H5 支付或网页登录伪装成已完成的原生支付 / 原生登录。 +- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://www.genarrative.world` 同源 H5 route 并在主窗口内跳转。H5 facade 在 `native_app` 下发送 `navigation.openNativePage` 前先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标,避免把明显不安全的跳转请求交给原生壳;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续由宿主二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。平台首页的儿童动作热身 Demo 入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先走该 facade 跳转 `/child-motion-demo`,普通浏览器、小程序和未声明能力的裁剪壳才回退浏览器跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 +- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,可选 MIME 只能来自共享契约 `HOST_BRIDGE_TEXT_MIME_TYPES`,未传时默认为 `text/plain`,非文本 MIME 必须拒绝,不能借文本导出通道伪装成图片、音频或二进制文件;Expo 与 Tauri 壳仍必须二次校验真实文本字节数和 MIME。成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。创作 Agent 工作台在 `native_app` 且声明该能力时提供会话 Markdown 导出入口,导出内容只来自当前 H5 已持有的会话标题、摘要、进度、锚点、消息、流式回复和输入草稿,并在 H5 侧先按同一 5 MiB 上限做 UTF-8 byte 校验;普通浏览器、小程序和未声明能力的裁剪壳不展示该入口。 +- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回通过共享 `normalizeHostBridgeImportFileName()` 清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportTextResult()` 复核文件名、MIME、文本内容和字节数,非法或超界结果归为 `false`;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 +- `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回通过共享 `normalizeHostBridgeImportFileName()` 清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportDocumentResult()` 复核文件名、MIME、base64 和字节数。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径;移动壳配置检查必须强制文档 MIME 清单、5 MiB 上限和导入文件名清洗函数来自共享 HostBridge 契约。 +- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportImagePayload()` 预校验文件名、MIME、base64 和 5 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 +- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄能力不使用麦克风,移动壳包级麦克风权限只服务同源 H5 实时声音玩法。H5 facade 收到导入、拍摄或拖拽结果后继续通过共享契约 `normalizeHostBridgeImportImageResult()` 复核文件名、MIME、base64、字节数和可选坐标。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传、抓大鹅结果页发布封面 / 封面参考图上传、RPG 角色资产工作室角色参考图上传、RPG 作品封面 / 封面参考图上传、RPG 场景图片参考图上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,抓大鹅结果页继续复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路,RPG 角色资产工作室继续复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路,RPG 作品封面上传继续复用现有 10 MiB 校验、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路,RPG 作品封面参考图继续复用现有 `readImageFileAsDataUrl` 读取、预览和 `generateCustomWorldCoverImage` payload 链路,RPG 场景图片参考图继续复用现有 `readImageFileAsDataUrl` 读取、预览和 `rpgCreationAssetClient.generateSceneImage` payload 链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;H5 facade 的扫码等待上限固定读取共享契约 `HOST_BRIDGE_SCANNER_TIMEOUT_MS`,不得在业务调用点手写毫秒数;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。 + +HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须存在于 capability 白名单,但各宿主壳只声明自身真实发射的事件能力。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 或 `file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。 +- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportAudioResult()` 复核文件名、MIME、base64 和字节数。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;视觉小说结果页音乐和环境音素材上传同样优先调用宿主音频导入,再把返回副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和 20 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob`、`fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 + +Tauri 桌面壳的文件能力边界分为两层:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 统一持有系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应归一,`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs` 统一持有文件 payload 校验、MIME / 大小 / bytes 边界、文件名清洗、本地副本读写和导入导出 payload 组装;`dispatch.rs` 只按 method 委托文件模块,不直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper。 + +## 迁移顺序 + +1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。 +2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 +3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 +4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。 +5. 每次新增或调整 native capability、HostBridge event 或宿主上下文 query 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile、事件白名单和 query 契约,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / EAS build config smoke / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。移动壳 EAS build config smoke 必须确认 Android 生产 profile 能产出内部 APK,iOS 生产 smoke profile 只产出 simulator 包,且不写商店提交、签名凭据来源、OTA channel 或本机 dotenv;移动壳 Metro export smoke 必须读取 iOS / Android production bundle,确认最终 bundle 使用共享 HostBridge 契约中的生产 H5 URL,且没有混入本机开发 H5 URL。移动壳和桌面壳文档中的主状态段落能力清单与完整能力清单都必须反查共享 capability profile,避免同一文档内部漂移;桌面壳 `host_bridge_request` command facade 必须先做 request 校验再进入 replay / 分发,且单测覆盖非法 envelope 不占用 replay slot;桌面壳 `capabilities.rs` 的 Rust 单测必须同时覆盖能力清单顺序、无重复、真实桌面能力完整包含和未接入能力排除;桌面壳未声明的共享 request method 必须由 Rust 测试从 `HOST_BRIDGE_METHODS - capabilities()` 自动派生为 `unsupported_method` 覆盖清单,当前包括 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact`,不得伪造成功;Expo 移动壳未声明的共享 request method 必须由 `HOST_BRIDGE_METHODS - HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 自动派生测试覆盖,确保未接 SDK / 渠道的 method 返回明确 `unsupported_method`;H5 facade 除 `host.getRuntime` 真实回读外,所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,根级门禁会从共享 `HOST_BRIDGE_METHODS` 自动派生需检查清单;排查单端问题时再单独运行 `npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:build-config`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。 + +## 验收 + +- 微信小程序首点登录仍能打开原生登录页。 +- 小程序分享链接仍生成 `/pages/web-view/index?targetPath=/works/detail&work=...`。 +- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。 +- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。 +- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。 +- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、微信 / Expo / Tauri 共享 capability profile、HostBridge event 白名单、宿主上下文 query 契约、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、微信小程序 `app.json.pages` 与 `protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、WebView source query、微信请求头来源标记、H5 路由保留字段、生产 / 开发 H5 与 API HTTPS 域名格式、三端桥接层结构、两端壳实现、Expo managed config、移动端 EAS build profile、移动端 production bundle 主站 URL、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、Tauri `Info.plist`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性、业务占位图文案和真实兼容 / 故障语义中的“未实现”“临时”表述,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 + +## 后续 + +- 为 AI H5 sandbox 单独定义 GameBridge,禁止直接依赖 HostBridge。 +- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。 +- 原生登录、渠道支付、远程推送、自动更新、崩溃上报和 analytics 等能力必须等真实 SDK、后端契约、发布流程和隐私口径确定后逐项接入。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 0dd894415..ed47b3835 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -381,6 +381,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 工程域:`square-hole`。当前链路具备 agent session、work profile、runtime run、drop shape、restart、stop、time-up 等后端 procedure 与前端 service。新改动应沿用独立 `module-square-hole`、`shared-contracts` 和 `spacetime-client`,不要挂回 RPG 或拼图语义。 +方洞结果页的封面、背景、形状和洞口图片槽位继续归属现有 result edit state 与 `PUT square-hole work` 保存链路;在 Expo / Tauri 原生壳声明 `file.importImage` 时,“上传图片”优先调用 HostBridge 系统图片选择器,并把返回的图片内容副本转换为当前槽位 `imageSrc`,普通浏览器、小程序和未声明能力的壳仍保留浏览器文件输入。 + ## 大鱼吃小鱼 工程域:`big-fish`。当前保留创作会话、素材槽、事件、运行态 run、gallery、like、remix 和 play 记录。新创作和运行态规则应继续通过后端 profile / run 投影,不把运行态结果写成纯前端事实。 diff --git a/miniprogram/app.js b/miniprogram/app.js index c060f609e..dcdc72ddb 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -4,7 +4,7 @@ App({ }, onLaunch(options) { - // 中文注释:保留启动参数,后续如果要把分享路径映射到 H5 深链,可以从这里统一读取。 + // 中文注释:保留启动参数,供分享路径与 H5 深链入口统一读取。 this.globalData.launchOptions = options; }, }); diff --git a/miniprogram/config.js b/miniprogram/config.js index d884a4d07..1c339ad26 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -19,7 +19,7 @@ const MINI_PROGRAM_ENV = 'release'; const GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; -// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 +// 中文注释:给 H5 加一个来源标记,用于识别微信小程序 web-view 宿主。 const WEB_VIEW_SOURCE_QUERY = { clientType: 'mini_program', clientRuntime: 'wechat_mini_program', diff --git a/miniprogram/host-bridge/dispatch.js b/miniprogram/host-bridge/dispatch.js new file mode 100644 index 000000000..86425a270 --- /dev/null +++ b/miniprogram/host-bridge/dispatch.js @@ -0,0 +1,13 @@ +const payment = require('./payment'); +const protocol = require('./protocol'); +const shareGrid = require('./shareGrid'); +const subscribeMessage = require('./subscribeMessage'); +const webView = require('./webView'); + +module.exports = { + payment, + protocol, + shareGrid, + subscribeMessage, + webView, +}; diff --git a/miniprogram/pages/wechat-pay/index.shared.js b/miniprogram/host-bridge/payment.js similarity index 76% rename from miniprogram/pages/wechat-pay/index.shared.js rename to miniprogram/host-bridge/payment.js index d25f527ed..a85188520 100644 --- a/miniprogram/pages/wechat-pay/index.shared.js +++ b/miniprogram/host-bridge/payment.js @@ -8,11 +8,15 @@ function parsePayParams(rawValue) { } return params; } catch (error) { - console.error('[wechat-pay] parse params failed', error); + logWechatPayFailure('parse params failed', error); return null; } } +function logWechatPayFailure(label, _error) { + console.error(`[wechat-pay] ${label}`); +} + function isVirtualPaymentParams(payParams) { return ( typeof payParams.mode === 'string' && @@ -64,21 +68,8 @@ function resolvePayStatus(error) { return errCode === -2 || /cancel/i.test(errMsg) ? 'cancel' : 'fail'; } -function normalizePayError(error) { - if (!error) { - return ''; - } - if (typeof error === 'string') { - return error; - } - try { - return JSON.stringify({ - errCode: error.errCode, - errMsg: error.errMsg, - }); - } catch (_error) { - return String(error.errMsg || error); - } +function normalizePayError() { + return 'wechat payment unavailable'; } function requestOrdinaryPayment(payParams) { @@ -105,10 +96,7 @@ function requestOrdinaryPayment(payParams) { function requestVirtualPayment(payParams) { return new Promise((resolve) => { if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') { - console.error('[wechat-pay] requestVirtualPayment unavailable', { - canUseVirtualPayment: canUseVirtualPayment(), - hasRequestVirtualPayment: typeof wx.requestVirtualPayment === 'function', - }); + logWechatPayFailure('requestVirtualPayment unavailable'); resolve({ status: 'fail', errorMessage: '当前微信基础库不支持 requestVirtualPayment', @@ -124,7 +112,7 @@ function requestVirtualPayment(payParams) { resolve({ status: 'success', errorMessage: '' }); }, fail(error) { - console.error('[wechat-pay] requestVirtualPayment failed', error); + logWechatPayFailure('requestVirtualPayment failed', error); resolve({ status: resolvePayStatus(error), errorMessage: normalizePayError(error), @@ -176,44 +164,13 @@ function notifyPreviousWebView(requestId, orderId, payResult) { } } -function createWechatPayPage(pageContext) { - return { - data: { - title: '正在拉起支付', - errorMessage: '', - }, - - async onLoad(query) { - const requestId = String(query.requestId || ''); - const orderId = String(query.orderId || ''); - const payParams = parsePayParams(query.payParams); - if (!requestId || !payParams) { - const page = pageContext ?? this; - page.setData({ - title: '支付失败', - errorMessage: '缺少支付参数。', - }); - return; - } - - const payResult = await requestWechatPayment(payParams); - notifyPreviousWebView(requestId, orderId, payResult); - wx.navigateBack(); - }, - - handleBack() { - wx.navigateBack(); - }, - }; -} - module.exports = { canUseVirtualPayment, PAY_RESULT_STORAGE_KEY, appendPayResult, buildPayResultValue, - createWechatPayPage, normalizePayError, + notifyPreviousWebView, parsePayParams, safeCompareVersion, requestWechatPayment, diff --git a/miniprogram/pages/wechat-pay/index.test.js b/miniprogram/host-bridge/payment.test.js similarity index 66% rename from miniprogram/pages/wechat-pay/index.test.js rename to miniprogram/host-bridge/payment.test.js index 1407c89ab..2c06785bf 100644 --- a/miniprogram/pages/wechat-pay/index.test.js +++ b/miniprogram/host-bridge/payment.test.js @@ -1,15 +1,17 @@ +import path from 'node:path'; + import { beforeEach, describe, expect, test, vi } from 'vitest'; -import wechatPayBridge from './index.shared.js'; +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; -const { - appendPayResult, - createWechatPayPage, - parsePayParams, - requestWechatPayment, -} = wechatPayBridge; +const payBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/payment.js', +); describe('wechat-pay mini program payment bridge', () => { + let wechatPayBridge; + beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); globalThis.wx = { @@ -20,9 +22,11 @@ describe('wechat-pay mini program payment bridge', () => { navigateBack: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => []); + wechatPayBridge = loadCommonJsModule(payBridgePath); }); test('routes virtual payloads to wx.requestVirtualPayment', async () => { + const { requestWechatPayment } = wechatPayBridge; globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { options.success?.({ errMsg: 'requestVirtualPayment:ok' }); }); @@ -49,6 +53,7 @@ describe('wechat-pay mini program payment bridge', () => { }); test('routes goods virtual payloads to wx.requestVirtualPayment', async () => { + const { requestWechatPayment } = wechatPayBridge; globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { options.success?.({ errMsg: 'requestVirtualPayment:ok' }); }); @@ -75,6 +80,7 @@ describe('wechat-pay mini program payment bridge', () => { }); test('keeps ordinary requestPayment payloads on wx.requestPayment', async () => { + const { requestWechatPayment } = wechatPayBridge; globalThis.wx.requestPayment.mockImplementationOnce((options) => { options.success?.(); }); @@ -101,6 +107,7 @@ describe('wechat-pay mini program payment bridge', () => { }); test('maps virtual payment cancel errCode to cancel result', async () => { + const { requestWechatPayment } = wechatPayBridge; const payError = { errCode: -2, errMsg: 'requestVirtualPayment:fail cancel', @@ -118,41 +125,74 @@ describe('wechat-pay mini program payment bridge', () => { }), ).resolves.toEqual({ status: 'cancel', - errorMessage: JSON.stringify({ - errCode: -2, - errMsg: 'requestVirtualPayment:fail cancel', - }), + errorMessage: 'wechat payment unavailable', }); expect(console.error).toHaveBeenCalledWith( '[wechat-pay] requestVirtualPayment failed', - payError, ); + expect(console.error.mock.calls.flat()).not.toContain(payError); }); - test('page notifies previous web-view after virtual payment', async () => { + test('logs virtual payment unavailable without exposing capability details', async () => { + const { requestWechatPayment } = wechatPayBridge; + globalThis.wx.canIUse = vi.fn(() => false); + globalThis.wx.getSystemInfoSync = vi.fn(() => ({ SDKVersion: '2.18.0' })); + delete globalThis.wx.requestVirtualPayment; + + await expect( + requestWechatPayment({ + mode: 'short_series_coin', + signData: '{}', + paySig: 'pay-sig', + signature: 'user-sig', + }), + ).resolves.toEqual({ + status: 'fail', + errorMessage: '当前微信基础库不支持 requestVirtualPayment', + }); + + expect(console.error).toHaveBeenCalledWith( + '[wechat-pay] requestVirtualPayment unavailable', + ); + expect(console.error.mock.calls).toContainEqual([ + '[wechat-pay] requestVirtualPayment unavailable', + ]); + }); + + test('hides ordinary payment native failure details from H5 result', async () => { + const { requestWechatPayment } = wechatPayBridge; + globalThis.wx.requestPayment.mockImplementationOnce((options) => { + options.fail?.({ + errCode: 1001, + errMsg: 'requestPayment:fail private native detail', + }); + }); + + await expect( + requestWechatPayment({ + timeStamp: '1777110165', + nonceStr: 'nonce', + package: 'prepay_id=wx-prepay', + signType: 'RSA', + paySign: 'signature', + }), + ).resolves.toEqual({ + status: 'fail', + errorMessage: 'wechat payment unavailable', + }); + }); + + test('notifies previous web-view after virtual payment', () => { + const { notifyPreviousWebView } = wechatPayBridge; const previousPage = { data: { webViewUrl: 'https://web.test/#tab=profile' }, setData: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); - globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { - options.success?.({ errMsg: 'requestVirtualPayment:ok' }); - }); - const page = createWechatPayPage({ - setData: vi.fn(), - }); - await page.onLoad({ - requestId: 'request-1', - orderId: 'order-1', - payParams: encodeURIComponent( - JSON.stringify({ - mode: 'short_series_coin', - signData: '{}', - paySig: 'pay-sig', - signature: 'user-sig', - }), - ), + notifyPreviousWebView('request-1', 'order-1', { + status: 'success', + errorMessage: '', }); expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( @@ -162,10 +202,10 @@ describe('wechat-pay mini program payment bridge', () => { expect(previousPage.setData).toHaveBeenCalledWith({ webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1', }); - expect(globalThis.wx.navigateBack).toHaveBeenCalled(); }); test('parsePayParams and appendPayResult keep existing behavior', () => { + const { appendPayResult, parsePayParams } = wechatPayBridge; expect(parsePayParams(encodeURIComponent('{"paySign":"sig"}'))).toEqual({ paySign: 'sig', }); diff --git a/miniprogram/host-bridge/protocol.js b/miniprogram/host-bridge/protocol.js new file mode 100644 index 000000000..2786b378c --- /dev/null +++ b/miniprogram/host-bridge/protocol.js @@ -0,0 +1,34 @@ +const WECHAT_HOST_CAPABILITIES = [ + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', + 'navigation.openNativePage', +]; + +const WECHAT_WEB_VIEW_PAGE_URL = '/pages/web-view/index'; +const WECHAT_AUTH_PAGE_URL = + `${WECHAT_WEB_VIEW_PAGE_URL}?authAction=login&returnTo=previous`; +const WECHAT_PAY_PAGE_URL = '/pages/wechat-pay/index'; +const WECHAT_SHARE_GRID_PAGE_URL = '/pages/share-grid/index'; +const WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL = '/pages/subscribe-message/index'; +const WECHAT_PAY_RESULT_HASH_KEY = 'wx_pay_result'; +const WECHAT_SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result'; +const WECHAT_PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; +const WECHAT_SUBSCRIBE_RESULT_STORAGE_KEY = + 'genarrative:wechat-subscribe-result'; +const WECHAT_SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target'; + +module.exports = { + WECHAT_AUTH_PAGE_URL, + WECHAT_HOST_CAPABILITIES, + WECHAT_PAY_PAGE_URL, + WECHAT_PAY_RESULT_HASH_KEY, + WECHAT_PAY_RESULT_STORAGE_KEY, + WECHAT_SHARE_GRID_PAGE_URL, + WECHAT_SHARE_TARGET_MESSAGE_TYPE, + WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL, + WECHAT_SUBSCRIBE_RESULT_HASH_KEY, + WECHAT_SUBSCRIBE_RESULT_STORAGE_KEY, + WECHAT_WEB_VIEW_PAGE_URL, +}; diff --git a/miniprogram/host-bridge/protocol.test.js b/miniprogram/host-bridge/protocol.test.js new file mode 100644 index 000000000..6bf38f87e --- /dev/null +++ b/miniprogram/host-bridge/protocol.test.js @@ -0,0 +1,78 @@ +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES } from '../../packages/shared/src/contracts/hostBridge.ts'; +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const protocolPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/protocol.js', +); +const dispatchPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/dispatch.js', +); +const paymentPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/payment.js', +); +const shareGridPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/shareGrid.js', +); +const subscribeMessagePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/subscribeMessage.js', +); +const webViewPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/webView.js', +); + +describe('wechat mini program host bridge protocol index', () => { + test('keeps native page routes and result keys centralized', () => { + const protocol = loadCommonJsModule(protocolPath); + + expect(protocol.WECHAT_HOST_CAPABILITIES).toEqual( + HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES, + ); + expect(protocol.WECHAT_AUTH_PAGE_URL).toBe( + '/pages/web-view/index?authAction=login&returnTo=previous', + ); + expect(protocol.WECHAT_PAY_PAGE_URL).toBe('/pages/wechat-pay/index'); + expect(protocol.WECHAT_SHARE_GRID_PAGE_URL).toBe('/pages/share-grid/index'); + expect(protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL).toBe( + '/pages/subscribe-message/index', + ); + expect(protocol.WECHAT_PAY_RESULT_HASH_KEY).toBe('wx_pay_result'); + expect(protocol.WECHAT_SUBSCRIBE_RESULT_HASH_KEY).toBe( + 'wx_subscribe_result', + ); + }); + + test('dispatch index exposes current bridge ability modules', () => { + const payment = loadCommonJsModule(paymentPath); + const protocol = loadCommonJsModule(protocolPath); + const shareGrid = loadCommonJsModule(shareGridPath); + const subscribeMessage = loadCommonJsModule(subscribeMessagePath); + const webView = loadCommonJsModule(webViewPath); + const dispatch = loadCommonJsModule(dispatchPath, { + './payment': payment, + './protocol': protocol, + './shareGrid': shareGrid, + './subscribeMessage': subscribeMessage, + './webView': webView, + }); + + expect(dispatch.protocol.WECHAT_PAY_PAGE_URL).toBe('/pages/wechat-pay/index'); + expect(typeof dispatch.webView.resolveWebViewUrlFromRuntimeConfig).toBe( + 'function', + ); + expect(typeof dispatch.payment.requestWechatPayment).toBe('function'); + expect(typeof dispatch.shareGrid.buildShareGridTilePlan).toBe('function'); + expect(typeof dispatch.subscribeMessage.buildSubscribeResultValue).toBe( + 'function', + ); + }); +}); diff --git a/miniprogram/pages/share-grid/index.shared.js b/miniprogram/host-bridge/shareGrid.js similarity index 100% rename from miniprogram/pages/share-grid/index.shared.js rename to miniprogram/host-bridge/shareGrid.js diff --git a/miniprogram/pages/share-grid/index.test.js b/miniprogram/host-bridge/shareGrid.test.js similarity index 96% rename from miniprogram/pages/share-grid/index.test.js rename to miniprogram/host-bridge/shareGrid.test.js index 832f7890c..201d07f2a 100644 --- a/miniprogram/pages/share-grid/index.test.js +++ b/miniprogram/host-bridge/shareGrid.test.js @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import shareGridBridge from './index.shared.js'; +import shareGridBridge from './shareGrid.js'; const { buildShareGridTileFileName, diff --git a/miniprogram/pages/subscribe-message/index.shared.js b/miniprogram/host-bridge/subscribeMessage.js similarity index 88% rename from miniprogram/pages/subscribe-message/index.shared.js rename to miniprogram/host-bridge/subscribeMessage.js index 04107f64d..660900af7 100644 --- a/miniprogram/pages/subscribe-message/index.shared.js +++ b/miniprogram/host-bridge/subscribeMessage.js @@ -1,6 +1,11 @@ /* global wx */ const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result'; +const WECHAT_SUBSCRIBE_UNAVAILABLE_REASON = 'wechat subscribe unavailable'; + +function logWechatSubscribeFailure(label, _error) { + console.error(`[subscribe-message] ${label}`); +} function appendSubscribeResult(url, result) { const hashIndex = String(url || '').indexOf('#'); @@ -34,7 +39,7 @@ function resolveSubscribeStatus(result, templateId) { : 'skip'; } -function createSubscribeMessagePage(pageContext, options = {}) { +function createSubscribeMessagePageController(pageContext, options = {}) { const templateId = String(options.templateId || '').trim(); const notifyPageResult = (methodThis, status, reason) => { const page = pageContext ?? methodThis; @@ -98,10 +103,11 @@ function createSubscribeMessagePage(pageContext, options = {}) { wx.navigateBack(); }, fail(error) { + logWechatSubscribeFailure('request failed', error); notifyPageResult( page, 'skip', - error && error.errMsg ? error.errMsg : 'failed', + WECHAT_SUBSCRIBE_UNAVAILABLE_REASON, ); wx.navigateBack(); }, @@ -121,8 +127,9 @@ function createSubscribeMessagePage(pageContext, options = {}) { module.exports = { SUBSCRIBE_RESULT_STORAGE_KEY, + WECHAT_SUBSCRIBE_UNAVAILABLE_REASON, appendSubscribeResult, buildSubscribeResultValue, - createSubscribeMessagePage, + createSubscribeMessagePageController, resolveSubscribeStatus, }; diff --git a/miniprogram/pages/subscribe-message/index.test.js b/miniprogram/host-bridge/subscribeMessage.test.js similarity index 57% rename from miniprogram/pages/subscribe-message/index.test.js rename to miniprogram/host-bridge/subscribeMessage.test.js index 0922f9332..0db637527 100644 --- a/miniprogram/pages/subscribe-message/index.test.js +++ b/miniprogram/host-bridge/subscribeMessage.test.js @@ -1,27 +1,35 @@ +import path from 'node:path'; + import { beforeEach, describe, expect, test, vi } from 'vitest'; -import subscribeMessageBridge from './index.shared.js'; +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; -const { - SUBSCRIBE_RESULT_STORAGE_KEY, - appendSubscribeResult, - buildSubscribeResultValue, - createSubscribeMessagePage, -} = subscribeMessageBridge; +const subscribeBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/subscribeMessage.js', +); describe('subscribe-message mini program bridge', () => { + let subscribeMessageBridge; + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); globalThis.wx = { requestSubscribeMessage: vi.fn(), setStorageSync: vi.fn(), navigateBack: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => []); + subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath); }); test('requests subscribe message and stores result before returning', () => { + const { + SUBSCRIBE_RESULT_STORAGE_KEY, + createSubscribeMessagePageController, + } = subscribeMessageBridge; const previousPage = { data: { webViewUrl: 'https://web.test/#tab=create' }, setData: vi.fn(), @@ -32,7 +40,7 @@ describe('subscribe-message mini program bridge', () => { m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept', }); }); - const page = createSubscribeMessagePage( + const page = createSubscribeMessagePageController( { setData: vi.fn(), }, @@ -55,13 +63,47 @@ describe('subscribe-message mini program bridge', () => { expect(globalThis.wx.navigateBack).toHaveBeenCalled(); }); + test('hides requestSubscribeMessage native failure details from H5 result', () => { + const { + SUBSCRIBE_RESULT_STORAGE_KEY, + createSubscribeMessagePageController, + } = subscribeMessageBridge; + const subscribeError = { + errMsg: 'requestSubscribeMessage:fail private native detail', + }; + globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => { + options.fail?.(subscribeError); + }); + const page = createSubscribeMessagePageController( + { + setData: vi.fn(), + }, + { templateId: TEST_TEMPLATE_ID }, + ); + page.onLoad({ requestId: 'request-fail' }); + + page.requestSubscribe(); + + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + SUBSCRIBE_RESULT_STORAGE_KEY, + 'request-fail:skip:wechat%20subscribe%20unavailable', + ); + expect(console.error).toHaveBeenCalledWith('[subscribe-message] request failed'); + expect(console.error.mock.calls.flat()).not.toContain(subscribeError); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); + test('skip action notifies previous web-view', () => { + const { + SUBSCRIBE_RESULT_STORAGE_KEY, + createSubscribeMessagePageController, + } = subscribeMessageBridge; const previousPage = { data: { webViewUrl: 'https://web.test/' }, setData: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); - const page = createSubscribeMessagePage( + const page = createSubscribeMessagePageController( { setData: vi.fn(), }, @@ -80,6 +122,8 @@ describe('subscribe-message mini program bridge', () => { }); test('appendSubscribeResult replaces stale subscribe hash', () => { + const { appendSubscribeResult, buildSubscribeResultValue } = + subscribeMessageBridge; expect( appendSubscribeResult( 'https://web.test/#old=1&wx_subscribe_result=old', diff --git a/miniprogram/pages/web-view/index.shared.js b/miniprogram/host-bridge/webView.js similarity index 100% rename from miniprogram/pages/web-view/index.shared.js rename to miniprogram/host-bridge/webView.js diff --git a/miniprogram/pages/web-view/index.test.js b/miniprogram/host-bridge/webView.test.js similarity index 98% rename from miniprogram/pages/web-view/index.test.js rename to miniprogram/host-bridge/webView.test.js index a04adbc5b..619c76485 100644 --- a/miniprogram/pages/web-view/index.test.js +++ b/miniprogram/host-bridge/webView.test.js @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import webViewBridge from './index.shared.js'; +import webViewBridge from './webView.js'; const { appendLaunchTargetToEntryUrl, diff --git a/miniprogram/pages/share-grid/index.js b/miniprogram/pages/share-grid/index.js index 2cae173ce..d0b7d53df 100644 --- a/miniprogram/pages/share-grid/index.js +++ b/miniprogram/pages/share-grid/index.js @@ -1,206 +1,5 @@ -/* global Page, wx */ -/* eslint-disable no-console */ +/* global Page */ -const { - buildShareGridTileFileName, - buildShareGridTilePlan, - normalizeShareGridQuery, -} = require('./index.shared'); +const { createWechatShareGridPage } = require('../../shell/shareGrid'); -function downloadImage(imageUrl) { - return new Promise((resolve, reject) => { - wx.downloadFile({ - url: imageUrl, - success(response) { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(response.tempFilePath); - return; - } - reject(new Error(`封面下载失败:${response.statusCode}`)); - }, - fail(error) { - reject(new Error(error.errMsg || '封面下载失败')); - }, - }); - }); -} - -function getImageInfo(src) { - return new Promise((resolve, reject) => { - wx.getImageInfo({ - src, - success: resolve, - fail(error) { - reject(new Error(error.errMsg || '读取封面失败')); - }, - }); - }); -} - -function getCanvasNode(page) { - return new Promise((resolve, reject) => { - wx.createSelectorQuery() - .in(page) - .select('#share-grid-canvas') - .fields({ node: true, size: true }) - .exec((results) => { - const canvas = results && results[0] && results[0].node; - if (canvas) { - resolve(canvas); - return; - } - reject(new Error('切图画布初始化失败')); - }); - }); -} - -function canvasToTempFilePath(canvas, width, height) { - return new Promise((resolve, reject) => { - wx.canvasToTempFilePath({ - canvas, - width, - height, - destWidth: width, - destHeight: height, - fileType: 'png', - success(response) { - resolve(response.tempFilePath); - }, - fail(error) { - reject(new Error(error.errMsg || '导出切图失败')); - }, - }); - }); -} - -function saveImageToAlbum(filePath) { - return new Promise((resolve, reject) => { - wx.saveImageToPhotosAlbum({ - filePath, - success() { - resolve(); - }, - fail(error) { - reject(new Error(error.errMsg || '保存到相册失败')); - }, - }); - }); -} - -function copyTempFileWithName(tempFilePath, fileName) { - const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager(); - const userDataPath = wx.env && wx.env.USER_DATA_PATH; - if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') { - return Promise.resolve(tempFilePath); - } - - const targetPath = `${userDataPath}/${fileName}`; - return new Promise((resolve) => { - fileSystem.copyFile({ - srcPath: tempFilePath, - destPath: targetPath, - success() { - resolve(targetPath); - }, - fail() { - resolve(tempFilePath); - }, - }); - }); -} - -async function saveGridTiles(page, params, localImagePath, imageInfo) { - const canvas = await getCanvasNode(page); - const context = canvas.getContext('2d'); - const image = canvas.createImage(); - await new Promise((resolve, reject) => { - image.onload = resolve; - image.onerror = () => reject(new Error('封面绘制失败')); - image.src = localImagePath; - }); - - const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height); - for (const tile of plan) { - canvas.width = tile.sourceWidth; - canvas.height = tile.sourceHeight; - context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight); - context.drawImage( - image, - tile.sourceX, - tile.sourceY, - tile.sourceWidth, - tile.sourceHeight, - 0, - 0, - tile.sourceWidth, - tile.sourceHeight, - ); - - const tempFilePath = await canvasToTempFilePath( - canvas, - tile.sourceWidth, - tile.sourceHeight, - ); - const namedFilePath = await copyTempFileWithName( - tempFilePath, - buildShareGridTileFileName(params, tile.index), - ); - await saveImageToAlbum(namedFilePath); - page.setData({ - savedCount: tile.index + 1, - }); - } -} - -Page({ - data: { - errorMessage: '', - loading: true, - savedCount: 0, - title: '九宫切图', - }, - - async onLoad(query = {}) { - const params = normalizeShareGridQuery(query); - this._shareGridParams = params; - this.setData({ - errorMessage: '', - loading: true, - savedCount: 0, - title: params.title, - }); - - if (!params.imageUrl) { - this.setData({ - errorMessage: '缺少封面图。', - loading: false, - }); - return; - } - - try { - const localImagePath = await downloadImage(params.imageUrl); - const imageInfo = await getImageInfo(localImagePath); - await saveGridTiles(this, params, localImagePath, imageInfo); - this.setData({ - loading: false, - savedCount: 9, - }); - wx.showToast({ - title: '已保存', - icon: 'success', - }); - } catch (error) { - console.error('[share-grid] save failed', error); - this.setData({ - errorMessage: - error && error.message ? error.message : '九宫切图保存失败。', - loading: false, - }); - } - }, - - handleBack() { - wx.navigateBack(); - }, -}); +Page(createWechatShareGridPage()); diff --git a/miniprogram/pages/subscribe-message/index.js b/miniprogram/pages/subscribe-message/index.js index 52ce7ea24..ad5eafa68 100644 --- a/miniprogram/pages/subscribe-message/index.js +++ b/miniprogram/pages/subscribe-message/index.js @@ -1,7 +1,9 @@ /* global Page */ const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config'); -const { createSubscribeMessagePage } = require('./index.shared'); +const { + createSubscribeMessagePage, +} = require('../../shell/subscribeMessage'); Page( createSubscribeMessagePage(null, { diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index b417a4957..1456274e4 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -1,697 +1,5 @@ -/* global Page, wx */ -/* eslint-disable no-console */ +/* global Page */ -const { - API_BASE_URL, - DEV_API_BASE_URL, - DEV_WEB_VIEW_ENTRY_URL, - MINI_PROGRAM_APP_ID, - MINI_PROGRAM_ENV, - WEB_VIEW_ENTRY_URL, - WEB_VIEW_SOURCE_QUERY, -} = require('../../config'); -const { - appendHashParams, - buildWebViewSharePath, - buildWebViewShareTimelineQuery, - resolveShareTargetFromWebViewMessage, - resolveWebViewUrlFromRuntimeConfig, -} = require('./index.shared'); +const { createWechatWebViewPage } = require('../../shell/webView'); -const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; -const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; -const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; -const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; -const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; -const AUTH_ACTION_LOGIN = 'login'; -const PAY_RESULT_RECHECK_DELAY_MS = 120; -const WEB_VIEW_SHARE_TITLE = '陶泥儿'; - -function showWebViewShareMenu() { - if (typeof wx.showShareMenu !== 'function') { - return; - } - - wx.showShareMenu({ - withShareTicket: true, - menus: ['shareAppMessage', 'shareTimeline'], - }); -} - -function resolveNativeShareQuery(page) { - return ( - (page && page._currentShareTarget) || - (page && page._lastLaunchQuery) || - {} - ); -} - -function buildWebViewShareAppMessage(query = {}) { - return { - title: WEB_VIEW_SHARE_TITLE, - path: buildWebViewSharePath(query), - }; -} - -function buildWebViewShareTimeline(query = {}) { - return { - title: WEB_VIEW_SHARE_TITLE, - query: buildWebViewShareTimelineQuery(query), - }; -} - -function isConfiguredEntryUrl(value) { - const trimmed = String(value || '').trim(); - return /^https:\/\/[^/]+/i.test(trimmed); -} - -function trimTrailingSlash(value) { - return String(value || '').trim().replace(/\/+$/u, ''); -} - -function isConfiguredApiBaseUrl(value) { - return /^https:\/\/[^/]+/i.test(String(value || '').trim()); -} - -function parseBooleanQueryFlag(value) { - return value === true || value === '1' || value === 'true' || value === 'yes'; -} - -function normalizeNicknameInput(value) { - return String(value || '').trim(); -} - -function normalizeNicknameForMatch(value) { - return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase(); -} - -function isPhoneLikeDisplayName(value) { - const normalized = normalizeNicknameForMatch(value); - if (!normalized) { - return false; - } - - const digits = normalized.replace(/\D/gu, ''); - return ( - /^(\+?86)?1\d{10}$/u.test(normalized) || - /^1\d{2}\*{4}\d{4}$/u.test(normalized) || - (/[*x]/iu.test(normalized) && digits.length >= 7) || - digits.length >= 11 - ); -} - -function isDefaultDisplayName(value, publicUserCode) { - const normalized = normalizeNicknameForMatch(value); - const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode); - if (!normalized) { - return true; - } - - return ( - normalized === '微信旅人' || - normalized === '玩家' || - normalized === normalizedPublicUserCode || - /^sy-\d{8}$/iu.test(normalized) || - /^user[_-]/iu.test(normalized) || - isPhoneLikeDisplayName(normalized) - ); -} - -function shouldRequestNicknameAfterLogin(authResult) { - const user = authResult && authResult.user ? authResult.user : {}; - const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName); - if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) { - return false; - } - - return ( - authResult && - (authResult.created || - isDefaultDisplayName(user.displayName, user.publicUserCode) || - (wechatDisplayName && - isDefaultDisplayName(wechatDisplayName, user.publicUserCode))) - ); -} - -function normalizeMiniProgramEnv(value) { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'release') { - return 'release'; - } - if (normalized === 'trial') { - return 'trial'; - } - if ( - normalized === 'develop' || - normalized === 'development' || - normalized === 'dev' - ) { - return 'dev'; - } - return ''; -} - -function readMiniProgramEnvVersion() { - if (typeof wx.getAccountInfoSync !== 'function') { - return ''; - } - try { - const accountInfo = wx.getAccountInfoSync(); - return ( - accountInfo && - accountInfo.miniProgram && - accountInfo.miniProgram.envVersion - ); - } catch (error) { - console.warn('[web-view] read mini program env failed', error); - return ''; - } -} - -function resolveMiniProgramRuntimeConfig() { - const miniProgramEnv = - normalizeMiniProgramEnv(readMiniProgramEnvVersion()) || - normalizeMiniProgramEnv(MINI_PROGRAM_ENV) || - 'release'; - const useReleaseChannel = miniProgramEnv === 'release'; - const webViewEntryUrl = useReleaseChannel - ? WEB_VIEW_ENTRY_URL - : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL; - const apiBaseUrl = useReleaseChannel - ? API_BASE_URL - : DEV_API_BASE_URL || API_BASE_URL; - const sourceQuery = { - ...WEB_VIEW_SOURCE_QUERY, - }; - if (!useReleaseChannel) { - sourceQuery.miniProgramEnv = miniProgramEnv; - } - - return { - apiBaseUrl, - miniProgramEnv, - sourceQuery, - webViewEntryUrl, - }; -} - -function shouldStartAuthFromQuery(query) { - return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; -} - -function shouldReturnToPreviousPage(query) { - return String((query && query.returnTo) || '').trim() === 'previous'; -} - -function resolveWebViewUrl(authResult, launchQuery = {}) { - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); - if (!isConfiguredEntryUrl(entryUrl)) { - return ''; - } - - return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { - ...runtimeConfig, - webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), - }); -} - -function persistAuthResult(authResult) { - wx.setStorageSync(AUTH_RESULT_STORAGE_KEY, JSON.stringify(authResult)); -} - -function consumeAuthResult() { - const rawValue = wx.getStorageSync(AUTH_RESULT_STORAGE_KEY); - if (!rawValue) { - return null; - } - - wx.removeStorageSync(AUTH_RESULT_STORAGE_KEY); - try { - const parsed = JSON.parse(String(rawValue)); - if (!parsed || typeof parsed !== 'object') { - return null; - } - - const token = String(parsed.token || '').trim(); - if (!token) { - return null; - } - - return { - token, - bindingStatus: String(parsed.bindingStatus || 'pending_bind_phone'), - }; - } catch (error) { - console.error('[web-view] parse auth result failed', error); - return null; - } -} - -function getClientInstanceId() { - const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY); - if (stored) { - return String(stored); - } - - const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId); - return nextId; -} - -function resolveClientPlatform() { - const info = wx.getSystemInfoSync(); - const platform = String(info.platform || '').toLowerCase(); - if (platform === 'ios') { - return 'ios'; - } - if (platform === 'android') { - return 'android'; - } - return 'unknown'; -} - -function wxLogin() { - return new Promise((resolve, reject) => { - wx.login({ - success(result) { - if (result.code) { - resolve(result.code); - return; - } - reject(new Error('微信登录未返回 code')); - }, - fail(error) { - reject(new Error(error.errMsg || '微信登录失败')); - }, - }); - }); -} - -function requestMiniProgramLogin(code, displayName) { - return new Promise((resolve, reject) => { - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); - if (!isConfiguredApiBaseUrl(apiBaseUrl)) { - reject(new Error('请先配置 API_BASE_URL')); - return; - } - - wx.request({ - url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`, - method: 'POST', - data: { - code, - ...(displayName ? { displayName } : {}), - }, - header: { - 'content-type': 'application/json', - 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, - 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, - 'x-client-platform': resolveClientPlatform(), - 'x-client-instance-id': getClientInstanceId(), - 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': runtimeConfig.miniProgramEnv, - }, - success(response) { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(response.data); - return; - } - const message = - response.data && - response.data.error && - response.data.error.message - ? response.data.error.message - : `微信登录失败:${response.statusCode}`; - reject(new Error(message)); - }, - fail(error) { - reject(new Error(error.errMsg || '微信登录请求失败')); - }, - }); - }); -} - -function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { - return new Promise((resolve, reject) => { - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); - if (!isConfiguredApiBaseUrl(apiBaseUrl)) { - reject(new Error('请先配置 API_BASE_URL')); - return; - } - - wx.request({ - url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, - method: 'POST', - data: { - wechatPhoneCode, - ...(displayName ? { displayName } : {}), - }, - header: { - authorization: `Bearer ${authToken}`, - 'content-type': 'application/json', - 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, - 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, - 'x-client-platform': resolveClientPlatform(), - 'x-client-instance-id': getClientInstanceId(), - 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': runtimeConfig.miniProgramEnv, - }, - success(response) { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(response.data); - return; - } - const message = - response.data && - response.data.error && - response.data.error.message - ? response.data.error.message - : `绑定手机号失败:${response.statusCode}`; - reject(new Error(message)); - }, - fail(error) { - reject(new Error(error.errMsg || '绑定手机号请求失败')); - }, - }); - }); -} - -async function resolveAuthResult(displayName) { - const code = await wxLogin(); - const response = await requestMiniProgramLogin(code, displayName); - if (!response || !response.token) { - throw new Error('服务器未返回登录态'); - } - return { - token: response.token, - bindingStatus: response.bindingStatus || 'pending_bind_phone', - user: response.user || null, - created: response.created === true, - }; -} - -Page({ - data: { - authResult: null, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: true, - nicknameInput: '', - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage: false, - webViewUrl: '', - }, - - async onLoad(query = {}) { - this._lastLaunchQuery = query; - showWebViewShareMenu(); - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 - if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) { - this.setData({ - errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。', - loading: false, - webViewUrl: '', - }); - return; - } - - const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); - const returnToPreviousPage = shouldReturnToPreviousPage(query); - if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { - this.setData({ - authResult: null, - bindingPhone: false, - errorMessage: '', - loading: false, - phoneBindingRequired: false, - returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(null, query), - }); - return; - } - - if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) { - this.setData({ - errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', - loading: false, - webViewUrl: '', - }); - return; - } - - this.setData({ - authResult: null, - bindingPhone: false, - errorMessage: '', - loggingIn: true, - loading: true, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - await this.startAuthFlow(returnToPreviousPage, ''); - }, - - handleNicknameInput(event) { - this.setData({ - nicknameInput: event.detail ? event.detail.value : '', - }); - }, - - async handleStartLogin() { - const displayName = normalizeNicknameInput(this.data.nicknameInput); - if (!displayName) { - this.setData({ - errorMessage: '请先选择或填写微信昵称。', - }); - return; - } - - this.setData({ - errorMessage: '', - loggingIn: true, - }); - await this.startAuthFlow(this.data.returnToPreviousPage, displayName); - }, - - async startAuthFlow(returnToPreviousPage, displayName) { - try { - const authResult = await resolveAuthResult(displayName); - if (!displayName && shouldRequestNicknameAfterLogin(authResult)) { - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: true, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - return; - } - - if (authResult.bindingStatus === 'pending_bind_phone') { - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: true, - returnToPreviousPage, - webViewUrl: '', - }); - return; - } - - if (returnToPreviousPage) { - persistAuthResult(authResult); - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - wx.navigateBack(); - return; - } - - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), - }); - } catch (error) { - this.setData({ - authResult: null, - errorMessage: - error && error.message ? error.message : '微信登录失败,请稍后重试。', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - } - }, - - onShow() { - const authResult = consumeAuthResult(); - if (authResult) { - this.setData({ - authResult, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), - }); - } - - this.consumePayResult(); - setTimeout(() => { - this.consumePayResult(); - }, PAY_RESULT_RECHECK_DELAY_MS); - }, - - consumePayResult() { - const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); - if (result && this.data.webViewUrl) { - wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); - this.setData({ - webViewUrl: appendHashParams(this.data.webViewUrl, { - wx_pay_result: result, - }), - }); - } - }, - - async handleGetPhoneNumber(event) { - if (!this.data.authResult || !this.data.authResult.token) { - this.handleRetryLogin(); - return; - } - - const detail = event.detail || {}; - if (!detail.code) { - this.setData({ - errorMessage: detail.errMsg || '需要授权手机号后才能完成绑定。', - }); - return; - } - - this.setData({ - bindingPhone: true, - errorMessage: '', - }); - try { - const response = await requestMiniProgramBindPhone( - this.data.authResult.token, - detail.code, - normalizeNicknameInput(this.data.nicknameInput), - ); - if (!response || !response.token) { - throw new Error('服务器未返回绑定后的登录态'); - } - const nextAuthResult = { - token: response.token, - bindingStatus: 'active', - }; - if (this.data.returnToPreviousPage) { - persistAuthResult(nextAuthResult); - this.setData({ - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - }); - wx.navigateBack(); - return; - } - this.setData({ - authResult: nextAuthResult, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl( - nextAuthResult, - this._lastLaunchQuery || {}, - ), - }); - } catch (error) { - this.setData({ - bindingPhone: false, - errorMessage: - error && error.message - ? error.message - : '绑定手机号失败,请稍后重试。', - }); - } - }, - - handleRetryLogin() { - this.setData({ - authResult: null, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: true, - nicknameInput: '', - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage: false, - webViewUrl: '', - }); - this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN }); - }, - - handleWebViewLoad(event) { - console.info('[web-view] loaded', event.detail); - }, - - handleWebViewError(event) { - console.error('[web-view] load failed', event.detail); - }, - - handleWebViewMessage(event) { - const shareTarget = resolveShareTargetFromWebViewMessage(event.detail); - if (shareTarget) { - this._currentShareTarget = shareTarget; - } - // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 - console.info('[web-view] message', event.detail); - }, - - onShareAppMessage() { - return buildWebViewShareAppMessage(resolveNativeShareQuery(this)); - }, - - onShareTimeline() { - return buildWebViewShareTimeline(resolveNativeShareQuery(this)); - }, -}); +Page(createWechatWebViewPage()); diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index a54f33f0f..4b8ae6173 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -19,7 +19,6 @@ class="nickname-input" type="nickname" value="{{nicknameInput}}" - placeholder="微信昵称" disabled="{{loggingIn}}" bindinput="handleNicknameInput" bindblur="handleNicknameInput" diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js index ad188c923..02b691b3e 100644 --- a/miniprogram/pages/wechat-pay/index.js +++ b/miniprogram/pages/wechat-pay/index.js @@ -1,3 +1,5 @@ -const { createWechatPayPage } = require('./index.shared'); +/* global Page */ + +const { createWechatPayPage } = require('../../shell/payment'); Page(createWechatPayPage()); diff --git a/miniprogram/shell/payment.js b/miniprogram/shell/payment.js new file mode 100644 index 000000000..d1065adf1 --- /dev/null +++ b/miniprogram/shell/payment.js @@ -0,0 +1,42 @@ +/* global wx */ + +const { + notifyPreviousWebView, + parsePayParams, + requestWechatPayment, +} = require('../host-bridge/payment'); + +function createWechatPayPage(pageContext) { + return { + data: { + title: '正在拉起支付', + errorMessage: '', + }, + + async onLoad(query) { + const requestId = String(query.requestId || ''); + const orderId = String(query.orderId || ''); + const payParams = parsePayParams(query.payParams); + if (!requestId || !payParams) { + const page = pageContext ?? this; + page.setData({ + title: '支付失败', + errorMessage: '缺少支付参数。', + }); + return; + } + + const payResult = await requestWechatPayment(payParams); + notifyPreviousWebView(requestId, orderId, payResult); + wx.navigateBack(); + }, + + handleBack() { + wx.navigateBack(); + }, + }; +} + +module.exports = { + createWechatPayPage, +}; diff --git a/miniprogram/shell/payment.test.js b/miniprogram/shell/payment.test.js new file mode 100644 index 000000000..295d6d213 --- /dev/null +++ b/miniprogram/shell/payment.test.js @@ -0,0 +1,69 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const payBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/payment.js', +); +const paymentShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/payment.js', +); + +describe('wechat payment shell page', () => { + let createWechatPayPage; + + beforeEach(() => { + globalThis.wx = { + getSystemInfoSync: vi.fn(() => ({ SDKVersion: '2.32.0' })), + navigateBack: vi.fn(), + requestPayment: vi.fn(), + requestVirtualPayment: vi.fn(), + setStorageSync: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => []); + const wechatPayBridge = loadCommonJsModule(payBridgePath); + const wechatPaymentShell = loadCommonJsModule(paymentShellPath, { + '../host-bridge/payment': wechatPayBridge, + }); + createWechatPayPage = wechatPaymentShell.createWechatPayPage; + }); + + test('stores payment result and returns to previous web-view', async () => { + const previousPage = { + data: { webViewUrl: 'https://web.test/#tab=profile' }, + setData: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); + globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { + options.success?.({ errMsg: 'requestVirtualPayment:ok' }); + }); + const page = createWechatPayPage({ setData: vi.fn() }); + + await page.onLoad({ + requestId: 'request-1', + orderId: 'order-1', + payParams: encodeURIComponent( + JSON.stringify({ + mode: 'short_series_coin', + signData: '{}', + paySig: 'pay-sig', + signature: 'user-sig', + }), + ), + }); + + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + 'genarrative:wechat-pay-result', + 'request-1:success:order-1', + ); + expect(previousPage.setData).toHaveBeenCalledWith({ + webViewUrl: + 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1', + }); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); +}); diff --git a/miniprogram/shell/shareGrid.js b/miniprogram/shell/shareGrid.js new file mode 100644 index 000000000..96c521116 --- /dev/null +++ b/miniprogram/shell/shareGrid.js @@ -0,0 +1,223 @@ +/* global wx */ +/* eslint-disable no-console */ + +const { + buildShareGridTileFileName, + buildShareGridTilePlan, + normalizeShareGridQuery, +} = require('../host-bridge/shareGrid'); + +const WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE = '九宫切图保存失败。'; + +function logShareGridFailure(label, _error) { + console.error(`[share-grid] ${label}`); +} + +function rejectShareGridSaveFailure(reject, label, error) { + logShareGridFailure(label, error); + reject(new Error(WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE)); +} + +function downloadImage(imageUrl) { + return new Promise((resolve, reject) => { + wx.downloadFile({ + url: imageUrl, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.tempFilePath); + return; + } + reject(new Error(`封面下载失败:${response.statusCode}`)); + }, + fail(error) { + rejectShareGridSaveFailure(reject, 'download failed', error); + }, + }); + }); +} + +function getImageInfo(src) { + return new Promise((resolve, reject) => { + wx.getImageInfo({ + src, + success: resolve, + fail(error) { + rejectShareGridSaveFailure(reject, 'read image info failed', error); + }, + }); + }); +} + +function getCanvasNode(page) { + return new Promise((resolve, reject) => { + wx.createSelectorQuery() + .in(page) + .select('#share-grid-canvas') + .fields({ node: true, size: true }) + .exec((results) => { + const canvas = results && results[0] && results[0].node; + if (canvas) { + resolve(canvas); + return; + } + reject(new Error('切图画布初始化失败')); + }); + }); +} + +function canvasToTempFilePath(canvas, width, height) { + return new Promise((resolve, reject) => { + wx.canvasToTempFilePath({ + canvas, + width, + height, + destWidth: width, + destHeight: height, + fileType: 'png', + success(response) { + resolve(response.tempFilePath); + }, + fail(error) { + rejectShareGridSaveFailure(reject, 'export tile failed', error); + }, + }); + }); +} + +function saveImageToAlbum(filePath) { + return new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath, + success() { + resolve(); + }, + fail(error) { + rejectShareGridSaveFailure(reject, 'save album failed', error); + }, + }); + }); +} + +function copyTempFileWithName(tempFilePath, fileName) { + const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager(); + const userDataPath = wx.env && wx.env.USER_DATA_PATH; + if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') { + return Promise.resolve(tempFilePath); + } + + const targetPath = `${userDataPath}/${fileName}`; + return new Promise((resolve) => { + fileSystem.copyFile({ + srcPath: tempFilePath, + destPath: targetPath, + success() { + resolve(targetPath); + }, + fail() { + resolve(tempFilePath); + }, + }); + }); +} + +async function saveGridTiles(page, params, localImagePath, imageInfo) { + const canvas = await getCanvasNode(page); + const context = canvas.getContext('2d'); + const image = canvas.createImage(); + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = () => reject(new Error('封面绘制失败')); + image.src = localImagePath; + }); + + const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height); + for (const tile of plan) { + canvas.width = tile.sourceWidth; + canvas.height = tile.sourceHeight; + context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight); + context.drawImage( + image, + tile.sourceX, + tile.sourceY, + tile.sourceWidth, + tile.sourceHeight, + 0, + 0, + tile.sourceWidth, + tile.sourceHeight, + ); + + const tempFilePath = await canvasToTempFilePath( + canvas, + tile.sourceWidth, + tile.sourceHeight, + ); + const namedFilePath = await copyTempFileWithName( + tempFilePath, + buildShareGridTileFileName(params, tile.index), + ); + await saveImageToAlbum(namedFilePath); + page.setData({ + savedCount: tile.index + 1, + }); + } +} + +function createWechatShareGridPage() { + return { + data: { + errorMessage: '', + loading: true, + savedCount: 0, + title: '九宫切图', + }, + + async onLoad(query = {}) { + const params = normalizeShareGridQuery(query); + this._shareGridParams = params; + this.setData({ + errorMessage: '', + loading: true, + savedCount: 0, + title: params.title, + }); + + if (!params.imageUrl) { + this.setData({ + errorMessage: '缺少封面图。', + loading: false, + }); + return; + } + + try { + const localImagePath = await downloadImage(params.imageUrl); + const imageInfo = await getImageInfo(localImagePath); + await saveGridTiles(this, params, localImagePath, imageInfo); + this.setData({ + loading: false, + savedCount: 9, + }); + wx.showToast({ + title: '已保存', + icon: 'success', + }); + } catch (error) { + logShareGridFailure('save failed', error); + this.setData({ + errorMessage: WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE, + loading: false, + }); + } + }, + + handleBack() { + wx.navigateBack(); + }, + }; +} + +module.exports = { + WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE, + createWechatShareGridPage, +}; diff --git a/miniprogram/shell/shareGrid.test.js b/miniprogram/shell/shareGrid.test.js new file mode 100644 index 000000000..a42492ccc --- /dev/null +++ b/miniprogram/shell/shareGrid.test.js @@ -0,0 +1,73 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const shareGridBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/shareGrid.js', +); +const shareGridShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/shareGrid.js', +); + +let createWechatShareGridPage; + +function createPage() { + const page = createWechatShareGridPage(); + return { + ...page, + data: { ...page.data }, + setData(patch) { + Object.assign(this.data, patch); + }, + }; +} + +describe('wechat share-grid shell page', () => { + beforeEach(() => { + globalThis.wx = { + navigateBack: vi.fn(), + }; + const shareGridBridge = loadCommonJsModule(shareGridBridgePath); + const shareGridShell = loadCommonJsModule(shareGridShellPath, { + '../host-bridge/shareGrid': shareGridBridge, + }); + createWechatShareGridPage = shareGridShell.createWechatShareGridPage; + }); + + test('shows a clear error when imageUrl is missing', async () => { + const page = createPage(); + + await page.onLoad({}); + + expect(page.data.loading).toBe(false); + expect(page.data.errorMessage).toBe('缺少封面图。'); + }); + + test('hides downloadFile native failure details from the page', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + globalThis.wx.downloadFile = vi.fn(({ fail }) => { + fail({ errMsg: 'private native download detail' }); + }); + const page = createPage(); + + await page.onLoad({ + imageUrl: 'https://web.test/cover.png', + title: '九宫切图', + publicWorkCode: 'PZ-0001', + }); + + expect(page.data.loading).toBe(false); + expect(page.data.errorMessage).toBe('九宫切图保存失败。'); + expect(page.data.errorMessage).not.toContain('private native download detail'); + expect(consoleError).toHaveBeenCalledWith('[share-grid] download failed'); + expect(consoleError).toHaveBeenCalledWith('[share-grid] save failed'); + expect(consoleError.mock.calls.flat()).not.toContain('private native download detail'); + consoleError.mockRestore(); + }); +}); diff --git a/miniprogram/shell/subscribeMessage.js b/miniprogram/shell/subscribeMessage.js new file mode 100644 index 000000000..f456b3474 --- /dev/null +++ b/miniprogram/shell/subscribeMessage.js @@ -0,0 +1,11 @@ +const { + createSubscribeMessagePageController, +} = require('../host-bridge/subscribeMessage'); + +function createSubscribeMessagePage(pageContext, options = {}) { + return createSubscribeMessagePageController(pageContext, options); +} + +module.exports = { + createSubscribeMessagePage, +}; diff --git a/miniprogram/shell/subscribeMessage.test.js b/miniprogram/shell/subscribeMessage.test.js new file mode 100644 index 000000000..8a2291ce8 --- /dev/null +++ b/miniprogram/shell/subscribeMessage.test.js @@ -0,0 +1,60 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; + +const subscribeBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/subscribeMessage.js', +); +const subscribeShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/subscribeMessage.js', +); + +describe('wechat subscribe-message shell page', () => { + let createSubscribeMessagePage; + + beforeEach(() => { + globalThis.wx = { + navigateBack: vi.fn(), + requestSubscribeMessage: vi.fn(), + setStorageSync: vi.fn(), + }; + const subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath); + const subscribeMessageShell = loadCommonJsModule(subscribeShellPath, { + '../host-bridge/subscribeMessage': subscribeMessageBridge, + }); + createSubscribeMessagePage = + subscribeMessageShell.createSubscribeMessagePage; + }); + + test('requests generation-result subscribe template and stores result', () => { + globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => { + options.success?.({ + [TEST_TEMPLATE_ID]: 'accept', + }); + }); + const page = createSubscribeMessagePage( + { setData: vi.fn() }, + { templateId: TEST_TEMPLATE_ID }, + ); + page.onLoad({ requestId: 'request-1' }); + + page.requestSubscribe(); + + expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({ + tmplIds: [TEST_TEMPLATE_ID], + success: expect.any(Function), + fail: expect.any(Function), + }); + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + 'genarrative:wechat-subscribe-result', + 'request-1:success', + ); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); +}); diff --git a/miniprogram/shell/webView.js b/miniprogram/shell/webView.js new file mode 100644 index 000000000..4d8957967 --- /dev/null +++ b/miniprogram/shell/webView.js @@ -0,0 +1,716 @@ +/* global wx */ +/* eslint-disable no-console */ + +const { + API_BASE_URL, + DEV_API_BASE_URL, + DEV_WEB_VIEW_ENTRY_URL, + MINI_PROGRAM_APP_ID, + MINI_PROGRAM_ENV, + WEB_VIEW_ENTRY_URL, + WEB_VIEW_SOURCE_QUERY, +} = require('../config'); +const { + appendHashParams, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + resolveShareTargetFromWebViewMessage, + resolveWebViewUrlFromRuntimeConfig, +} = require('../host-bridge/webView'); + +const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; +const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; +const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; +const AUTH_ACTION_LOGIN = 'login'; +const PAY_RESULT_RECHECK_DELAY_MS = 120; +const WEB_VIEW_SHARE_TITLE = '陶泥儿'; +const WECHAT_LOGIN_UNAVAILABLE_MESSAGE = '微信登录失败,请稍后重试。'; +const WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE = '绑定手机号失败,请稍后重试。'; +const WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE = '需要授权手机号后才能完成绑定。'; + +function showWebViewShareMenu() { + if (typeof wx.showShareMenu !== 'function') { + return; + } + + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); +} + +function resolveNativeShareQuery(page) { + return ( + (page && page._currentShareTarget) || + (page && page._lastLaunchQuery) || + {} + ); +} + +function buildWebViewShareAppMessage(query = {}) { + return { + title: WEB_VIEW_SHARE_TITLE, + path: buildWebViewSharePath(query), + }; +} + +function buildWebViewShareTimeline(query = {}) { + return { + title: WEB_VIEW_SHARE_TITLE, + query: buildWebViewShareTimelineQuery(query), + }; +} + +function isConfiguredEntryUrl(value) { + const trimmed = String(value || '').trim(); + return /^https:\/\/[^/]+/i.test(trimmed); +} + +function trimTrailingSlash(value) { + return String(value || '').trim().replace(/\/+$/u, ''); +} + +function readWebViewSourceQueryValue(key) { + return String((WEB_VIEW_SOURCE_QUERY && WEB_VIEW_SOURCE_QUERY[key]) || '').trim(); +} + +function isConfiguredApiBaseUrl(value) { + return /^https:\/\/[^/]+/i.test(String(value || '').trim()); +} + +function parseBooleanQueryFlag(value) { + return value === true || value === '1' || value === 'true' || value === 'yes'; +} + +function normalizeNicknameInput(value) { + return String(value || '').trim(); +} + +function normalizeNicknameForMatch(value) { + return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase(); +} + +function isPhoneLikeDisplayName(value) { + const normalized = normalizeNicknameForMatch(value); + if (!normalized) { + return false; + } + + const digits = normalized.replace(/\D/gu, ''); + return ( + /^(\+?86)?1\d{10}$/u.test(normalized) || + /^1\d{2}\*{4}\d{4}$/u.test(normalized) || + (/[*x]/iu.test(normalized) && digits.length >= 7) || + digits.length >= 11 + ); +} + +function isDefaultDisplayName(value, publicUserCode) { + const normalized = normalizeNicknameForMatch(value); + const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode); + if (!normalized) { + return true; + } + + return ( + normalized === '微信旅人' || + normalized === '玩家' || + normalized === normalizedPublicUserCode || + /^sy-\d{8}$/iu.test(normalized) || + /^user[_-]/iu.test(normalized) || + isPhoneLikeDisplayName(normalized) + ); +} + +function shouldRequestNicknameAfterLogin(authResult) { + const user = authResult && authResult.user ? authResult.user : {}; + const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName); + if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) { + return false; + } + + return ( + authResult && + (authResult.created || + isDefaultDisplayName(user.displayName, user.publicUserCode) || + (wechatDisplayName && + isDefaultDisplayName(wechatDisplayName, user.publicUserCode))) + ); +} + +function normalizeMiniProgramEnv(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'release') { + return 'release'; + } + if (normalized === 'trial') { + return 'trial'; + } + if ( + normalized === 'develop' || + normalized === 'development' || + normalized === 'dev' + ) { + return 'dev'; + } + return ''; +} + +function logMiniProgramEnvReadFailure(_error) { + console.warn('[web-view] read mini program env failed'); +} + +function readMiniProgramEnvVersion() { + if (typeof wx.getAccountInfoSync !== 'function') { + return ''; + } + try { + const accountInfo = wx.getAccountInfoSync(); + return ( + accountInfo && + accountInfo.miniProgram && + accountInfo.miniProgram.envVersion + ); + } catch (error) { + logMiniProgramEnvReadFailure(error); + return ''; + } +} + +function logWebViewAuthFailure(label, _detail) { + console.error(`[web-view] ${label}`); +} + +function logWebViewPageEvent(label, _detail) { + console.info(`[web-view] ${label}`); +} + +function logWebViewPageFailure(label, _detail) { + console.error(`[web-view] ${label}`); +} + +function resolveMiniProgramRuntimeConfig() { + const miniProgramEnv = + normalizeMiniProgramEnv(readMiniProgramEnvVersion()) || + normalizeMiniProgramEnv(MINI_PROGRAM_ENV) || + 'release'; + const useReleaseChannel = miniProgramEnv === 'release'; + const webViewEntryUrl = useReleaseChannel + ? WEB_VIEW_ENTRY_URL + : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL; + const apiBaseUrl = useReleaseChannel + ? API_BASE_URL + : DEV_API_BASE_URL || API_BASE_URL; + const sourceQuery = { + ...WEB_VIEW_SOURCE_QUERY, + }; + if (!useReleaseChannel) { + sourceQuery.miniProgramEnv = miniProgramEnv; + } + + return { + apiBaseUrl, + miniProgramEnv, + sourceQuery, + webViewEntryUrl, + }; +} + +function shouldStartAuthFromQuery(query) { + return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; +} + +function shouldReturnToPreviousPage(query) { + return String((query && query.returnTo) || '').trim() === 'previous'; +} + +function resolveWebViewUrl(authResult, launchQuery = {}) { + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); + if (!isConfiguredEntryUrl(entryUrl)) { + return ''; + } + + return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { + ...runtimeConfig, + webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), + }); +} + +function persistAuthResult(authResult) { + wx.setStorageSync(AUTH_RESULT_STORAGE_KEY, JSON.stringify(authResult)); +} + +function consumeAuthResult() { + const rawValue = wx.getStorageSync(AUTH_RESULT_STORAGE_KEY); + if (!rawValue) { + return null; + } + + wx.removeStorageSync(AUTH_RESULT_STORAGE_KEY); + try { + const parsed = JSON.parse(String(rawValue)); + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const token = String(parsed.token || '').trim(); + if (!token) { + return null; + } + + return { + token, + bindingStatus: String(parsed.bindingStatus || 'pending_bind_phone'), + }; + } catch (error) { + logWebViewAuthFailure('parse auth result failed', error); + return null; + } +} + +function getClientInstanceId() { + const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY); + if (stored) { + return String(stored); + } + + const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId); + return nextId; +} + +function resolveClientPlatform() { + const info = wx.getSystemInfoSync(); + const platform = String(info.platform || '').toLowerCase(); + if (platform === 'ios') { + return 'ios'; + } + if (platform === 'android') { + return 'android'; + } + return 'unknown'; +} + +function wxLogin() { + return new Promise((resolve, reject) => { + wx.login({ + success(result) { + if (result.code) { + resolve(result.code); + return; + } + logWebViewAuthFailure('wx.login returned no code', result); + reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE)); + }, + fail(error) { + logWebViewAuthFailure('wx.login failed', error); + reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE)); + }, + }); + }); +} + +function requestMiniProgramLogin(code, displayName) { + return new Promise((resolve, reject) => { + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); + if (!isConfiguredApiBaseUrl(apiBaseUrl)) { + reject(new Error('请先配置 API_BASE_URL')); + return; + } + + wx.request({ + url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`, + method: 'POST', + data: { + code, + ...(displayName ? { displayName } : {}), + }, + header: { + 'content-type': 'application/json', + 'x-client-type': readWebViewSourceQueryValue('clientType'), + 'x-client-runtime': readWebViewSourceQueryValue('clientRuntime'), + 'x-client-platform': resolveClientPlatform(), + 'x-client-instance-id': getClientInstanceId(), + 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, + }, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.data); + return; + } + logWebViewAuthFailure('mini program login failed', response); + reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE)); + }, + fail(error) { + logWebViewAuthFailure('mini program login request failed', error); + reject(new Error(WECHAT_LOGIN_UNAVAILABLE_MESSAGE)); + }, + }); + }); +} + +function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { + return new Promise((resolve, reject) => { + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); + if (!isConfiguredApiBaseUrl(apiBaseUrl)) { + reject(new Error('请先配置 API_BASE_URL')); + return; + } + + wx.request({ + url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, + method: 'POST', + data: { + wechatPhoneCode, + ...(displayName ? { displayName } : {}), + }, + header: { + authorization: `Bearer ${authToken}`, + 'content-type': 'application/json', + 'x-client-type': readWebViewSourceQueryValue('clientType'), + 'x-client-runtime': readWebViewSourceQueryValue('clientRuntime'), + 'x-client-platform': resolveClientPlatform(), + 'x-client-instance-id': getClientInstanceId(), + 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, + }, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.data); + return; + } + logWebViewAuthFailure('mini program bind phone failed', response); + reject(new Error(WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE)); + }, + fail(error) { + logWebViewAuthFailure('mini program bind phone request failed', error); + reject(new Error(WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE)); + }, + }); + }); +} + +async function resolveAuthResult(displayName) { + const code = await wxLogin(); + const response = await requestMiniProgramLogin(code, displayName); + if (!response || !response.token) { + throw new Error('服务器未返回登录态'); + } + return { + token: response.token, + bindingStatus: response.bindingStatus || 'pending_bind_phone', + user: response.user || null, + created: response.created === true, + }; +} + +function createWechatWebViewPage() { + return { + data: { + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: true, + nicknameInput: '', + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: '', + }, + + async onLoad(query = {}) { + this._lastLaunchQuery = query; + showWebViewShareMenu(); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 + if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) { + this.setData({ + errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。', + loading: false, + webViewUrl: '', + }); + return; + } + + const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); + const returnToPreviousPage = shouldReturnToPreviousPage(query); + if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { + this.setData({ + authResult: null, + bindingPhone: false, + errorMessage: '', + loading: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: resolveWebViewUrl(null, query), + }); + return; + } + + if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) { + this.setData({ + errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', + loading: false, + webViewUrl: '', + }); + return; + } + + this.setData({ + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: true, + loading: true, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + await this.startAuthFlow(returnToPreviousPage, ''); + }, + + handleNicknameInput(event) { + this.setData({ + nicknameInput: event.detail ? event.detail.value : '', + }); + }, + + async handleStartLogin() { + const displayName = normalizeNicknameInput(this.data.nicknameInput); + if (!displayName) { + this.setData({ + errorMessage: '请先选择或填写微信昵称。', + }); + return; + } + + this.setData({ + errorMessage: '', + loggingIn: true, + }); + await this.startAuthFlow(this.data.returnToPreviousPage, displayName); + }, + + async startAuthFlow(returnToPreviousPage, displayName) { + try { + const authResult = await resolveAuthResult(displayName); + if (!displayName && shouldRequestNicknameAfterLogin(authResult)) { + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: true, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + return; + } + + if (authResult.bindingStatus === 'pending_bind_phone') { + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: true, + returnToPreviousPage, + webViewUrl: '', + }); + return; + } + + if (returnToPreviousPage) { + persistAuthResult(authResult); + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + wx.navigateBack(); + return; + } + + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), + }); + } catch (error) { + logWebViewAuthFailure('auth flow failed', error); + this.setData({ + authResult: null, + errorMessage: WECHAT_LOGIN_UNAVAILABLE_MESSAGE, + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + } + }, + + onShow() { + const authResult = consumeAuthResult(); + if (authResult) { + this.setData({ + authResult, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), + }); + } + + this.consumePayResult(); + setTimeout(() => { + this.consumePayResult(); + }, PAY_RESULT_RECHECK_DELAY_MS); + }, + + consumePayResult() { + const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); + if (result && this.data.webViewUrl) { + wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); + this.setData({ + webViewUrl: appendHashParams(this.data.webViewUrl, { + wx_pay_result: result, + }), + }); + } + }, + + async handleGetPhoneNumber(event) { + if (!this.data.authResult || !this.data.authResult.token) { + this.handleRetryLogin(); + return; + } + + const detail = event.detail || {}; + if (!detail.code) { + logWebViewAuthFailure('bind phone auth declined', detail); + this.setData({ + errorMessage: WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE, + }); + return; + } + + this.setData({ + bindingPhone: true, + errorMessage: '', + }); + try { + const response = await requestMiniProgramBindPhone( + this.data.authResult.token, + detail.code, + normalizeNicknameInput(this.data.nicknameInput), + ); + if (!response || !response.token) { + throw new Error('服务器未返回绑定后的登录态'); + } + const nextAuthResult = { + token: response.token, + bindingStatus: 'active', + }; + if (this.data.returnToPreviousPage) { + persistAuthResult(nextAuthResult); + this.setData({ + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + }); + wx.navigateBack(); + return; + } + this.setData({ + authResult: nextAuthResult, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + webViewUrl: resolveWebViewUrl( + nextAuthResult, + this._lastLaunchQuery || {}, + ), + }); + } catch (error) { + logWebViewAuthFailure('bind phone failed', error); + this.setData({ + bindingPhone: false, + errorMessage: WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE, + }); + } + }, + + handleRetryLogin() { + this.setData({ + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: true, + nicknameInput: '', + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: '', + }); + this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN }); + }, + + handleWebViewLoad(event) { + logWebViewPageEvent('loaded', event.detail); + }, + + handleWebViewError(event) { + logWebViewPageFailure('load failed', event.detail); + }, + + handleWebViewMessage(event) { + const shareTarget = resolveShareTargetFromWebViewMessage(event.detail); + if (shareTarget) { + this._currentShareTarget = shareTarget; + } + logWebViewPageEvent('message', event.detail); + }, + + onShareAppMessage() { + return buildWebViewShareAppMessage(resolveNativeShareQuery(this)); + }, + + onShareTimeline() { + return buildWebViewShareTimeline(resolveNativeShareQuery(this)); + }, + }; +} + +module.exports = { + createWechatWebViewPage, +}; diff --git a/miniprogram/shell/webView.test.js b/miniprogram/shell/webView.test.js new file mode 100644 index 000000000..77f5fd066 --- /dev/null +++ b/miniprogram/shell/webView.test.js @@ -0,0 +1,130 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const webViewBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/webView.js', +); +const webViewShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/webView.js', +); +const config = { + API_BASE_URL: 'https://www.genarrative.world', + DEV_API_BASE_URL: 'https://dev.genarrative.world', + DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world', + MINI_PROGRAM_APP_ID: 'wx-test-app', + MINI_PROGRAM_ENV: 'release', + WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world', + WEB_VIEW_SOURCE_QUERY: { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }, +}; + +let createWechatWebViewPage; + +function createPage() { + const page = createWechatWebViewPage(); + return { + ...page, + data: { ...page.data }, + setData(patch) { + Object.assign(this.data, patch); + }, + }; +} + +describe('wechat web-view shell page', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + globalThis.wx = { + getAccountInfoSync: vi.fn(() => ({ + miniProgram: { envVersion: 'release' }, + })), + getStorageSync: vi.fn(() => ''), + getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })), + login: vi.fn(), + navigateBack: vi.fn(), + removeStorageSync: vi.fn(), + request: vi.fn(), + setStorageSync: vi.fn(), + showShareMenu: vi.fn(), + }; + const webViewBridge = loadCommonJsModule(webViewBridgePath); + const webViewShell = loadCommonJsModule(webViewShellPath, { + '../config': config, + '../host-bridge/webView': webViewBridge, + }); + createWechatWebViewPage = webViewShell.createWechatWebViewPage; + }); + + test('opens anonymous H5 web-view without eager login', async () => { + const page = createPage(); + + await page.onLoad({}); + + expect(globalThis.wx.login).not.toHaveBeenCalled(); + expect(globalThis.wx.request).not.toHaveBeenCalled(); + expect(page.data.loading).toBe(false); + expect(page.data.webViewUrl).toBe( + 'https://www.genarrative.world?clientType=mini_program&clientRuntime=wechat_mini_program', + ); + expect(globalThis.wx.showShareMenu).toHaveBeenCalledWith({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); + }); + + test('stores latest H5 share target for native share menu', () => { + const page = createPage(); + const webViewDetail = { + data: [ + { + data: { + type: 'genarrative:share-target', + payload: { + targetPath: '/works/detail', + work: 'BB-12345678', + title: '汪汪声浪', + }, + }, + }, + ], + }; + + page.handleWebViewMessage({ + detail: webViewDetail, + }); + + expect(page.onShareAppMessage()).toEqual({ + title: '陶泥儿', + path: '/pages/web-view/index?targetPath=%2Fworks%2Fdetail&work=BB-12345678', + }); + expect(page.onShareTimeline()).toEqual({ + title: '陶泥儿', + query: 'targetPath=%2Fworks%2Fdetail&work=BB-12345678', + }); + expect(console.info).toHaveBeenCalledWith('[web-view] message'); + expect(console.info.mock.calls.flat()).not.toContain(webViewDetail); + }); + + test('logs web-view page events without native detail payloads', () => { + const page = createPage(); + const loadDetail = { src: 'https://www.genarrative.world/private' }; + const errorDetail = { errMsg: 'private native load failed' }; + + page.handleWebViewLoad({ detail: loadDetail }); + page.handleWebViewError({ detail: errorDetail }); + + expect(console.info).toHaveBeenCalledWith('[web-view] loaded'); + expect(console.error).toHaveBeenCalledWith('[web-view] load failed'); + expect(console.info.mock.calls.flat()).not.toContain(loadDetail); + expect(console.error.mock.calls.flat()).not.toContain(errorDetail); + }); +}); diff --git a/miniprogram/test-utils/loadCommonJsModule.js b/miniprogram/test-utils/loadCommonJsModule.js new file mode 100644 index 000000000..baec1cf34 --- /dev/null +++ b/miniprogram/test-utils/loadCommonJsModule.js @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +export function loadCommonJsModule(filePath, requireMap = {}) { + const module = { exports: {} }; + const sandbox = { + clearTimeout, + console, + getCurrentPages(...args) { + return globalThis.getCurrentPages(...args); + }, + module, + exports: module.exports, + require(requestPath) { + if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) { + return requireMap[requestPath]; + } + throw new Error(`Unexpected require: ${requestPath}`); + }, + setTimeout, + URL, + URLSearchParams, + wx: globalThis.wx, + }; + + vm.runInNewContext(readFileSync(filePath, 'utf8'), sandbox, { + filename: filePath, + }); + + return module.exports; +} diff --git a/package-lock.json b/package-lock.json index 9d5e617a0..f58b1aa4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,21 +8,38 @@ "name": "react-example", "version": "0.0.0", "dependencies": { + "@expo/metro-runtime": "^56.0.15", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "cannon-es": "^0.20.0", "dotenv": "^17.2.3", + "expo": "^56.0.12", + "expo-camera": "56.0.8", + "expo-clipboard": "^56.0.4", + "expo-document-picker": "^56.0.4", + "expo-file-system": "^56.0.8", + "expo-haptics": "^56.0.3", + "expo-image-picker": "^56.0.18", + "expo-linking": "^56.0.14", + "expo-network": "^56.0.5", + "expo-notifications": "^56.0.18", + "expo-sharing": "^56.0.18", + "expo-status-bar": "^56.0.4", "jszip": "^3.10.1", "lucide-react": "^0.546.0", "motion": "^12.23.24", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-native": "^0.86.0", + "react-native-safe-area-context": "^5.8.0", + "react-native-webview": "^13.16.1", "three": "^0.184.0", "vite": "^6.2.0" }, "devDependencies": { "@colbymchenry/codegraph": "^0.8.0", + "@tauri-apps/cli": "^2.11.2", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.0", @@ -34,6 +51,7 @@ "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-v8": "^0.34.6", "autoprefixer": "^10.4.21", + "eas-cli": "^20.3.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react-hooks": "^4.6.2", @@ -50,6 +68,21 @@ "vitest": "^0.34.6" } }, + "node_modules/@0no-co/graphql.web": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.2.tgz", + "integrity": "sha512-Q1+pRlLhE31GOY/2c9BAEnFTNxO7Awtc6fhhEDlxyCBQ2N0IhD32cPVvPChrK9mwBNSgRdW/sF1kd2e0ojHj1Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -78,9 +111,10 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -115,12 +149,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -129,13 +164,26 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -144,34 +192,19 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -180,34 +213,196 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { "node": ">=6.9.0" } @@ -224,12 +419,107 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -238,6 +528,522 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", @@ -266,52 +1072,172 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -382,6 +1308,30 @@ "better-sqlite3": "^12.4.1" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -838,6 +1788,3143 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@expo/apple-utils": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.1.19.tgz", + "integrity": "sha512-f1iMteL+tTOSF1sovVB35ncobdiZvhjWvwEOWGIuAutyeIpcxNJ/2tUZSE748X/VEQLn1cL2Tozkdp2MLXStvA==", + "dev": true, + "license": "MIT", + "bin": { + "apple-utils": "bin.js" + } + }, + "node_modules/@expo/bunyan": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.1.tgz", + "integrity": "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@expo/bunyan/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@expo/cli": { + "version": "56.1.16", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz", + "integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~56.0.9", + "@expo/config-plugins": "~56.0.9", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.3.0", + "@expo/image-utils": "^0.10.1", + "@expo/inline-modules": "^0.0.12", + "@expo/json-file": "^10.2.0", + "@expo/log-box": "^56.0.13", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.14", + "@expo/metro-file-map": "^56.0.3", + "@expo/osascript": "^2.6.0", + "@expo/package-manager": "^1.12.1", + "@expo/plist": "^0.7.0", + "@expo/prebuild-config": "^56.0.16", + "@expo/require-utils": "^56.1.3", + "@expo/router-server": "^56.0.14", + "@expo/schema-utils": "^56.0.0", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.85.3", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^56.0.5", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "main.js" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/cli/node_modules/@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/cli/node_modules/@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/cli/node_modules/@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "license": "MIT" + }, + "node_modules/@expo/cli/node_modules/@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/cli/node_modules/@expo/image-utils": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", + "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/cli/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/cli/node_modules/@expo/osascript": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", + "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.8.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/cli/node_modules/@expo/package-manager": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", + "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.2.0", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/cli/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/cli/node_modules/@expo/prebuild-config": { + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.16.tgz", + "integrity": "sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==", + "license": "MIT", + "dependencies": { + "@expo/config": "~56.0.9", + "@expo/config-plugins": "~56.0.9", + "@expo/config-types": "^56.0.6", + "@expo/image-utils": "^0.10.1", + "@expo/json-file": "^10.2.0", + "@react-native/normalize-colors": "0.85.3", + "debug": "^4.3.1", + "expo-modules-autolinking": "~56.0.16", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/cli/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/cli/node_modules/@react-native/normalize-colors": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", + "integrity": "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==", + "license": "MIT" + }, + "node_modules/@expo/cli/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@expo/cli/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/cli/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/cli/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@expo/cli/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@expo/cli/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@expo/cli/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/@expo/cli/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/cli/node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/ora/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/cli/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/cli/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/cli/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@expo/cli/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@expo/cli/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", + "integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-forge": "^1.2.1", + "nullthrows": "^1.1.1" + } + }, + "node_modules/@expo/config": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.10.tgz", + "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~55.0.7", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.12", + "@expo/require-utils": "^55.0.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.7.tgz", + "integrity": "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.12", + "@expo/plist": "^0.5.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/config-plugins/node_modules/@expo/plist": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.4.tgz", + "integrity": "sha512-Jqppj0FULNq6Zp5JtQrFICl8TtpMjwwUbxEcEC2T3z7m+TOrTQEHZXz3D3Ay7vhbmvD+VMgfWJ4ARclJXeN8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/config-plugins/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/config-plugins/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config-plugins/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config-plugins/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/config-plugins/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@expo/config/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-56.0.2.tgz", + "integrity": "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.5.tgz", + "integrity": "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/eas-build-job": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@expo/eas-build-job/-/eas-build-job-20.1.0.tgz", + "integrity": "sha512-a1owbBU9eKbEG0B7Tm/lPIfep5k85bHFwbPsqB8WOWI5KBlHz9Gs/fAfBRt0/EAKlyxxMH3TxlR0YBC+Zy1hCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/logger": "20.0.0", + "@expo/results": "1.0.0", + "@expo/turtle-spawn": "20.0.0", + "joi": "^17.13.1", + "semver": "^7.6.2", + "zod": "^4.3.5" + } + }, + "node_modules/@expo/eas-build-job/node_modules/joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/@expo/eas-build-job/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/eas-json": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@expo/eas-json/-/eas-json-20.1.0.tgz", + "integrity": "sha512-B3ZrMLNTNmqIMaKB5Y7/mz5EBV6m+frrJohnjLIaGMa8ExQBMMyqn0rg283hc4bEeq6ZqIY2IViWZhdIjUs+aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "7.23.5", + "@expo/eas-build-job": "20.1.0", + "chalk": "4.1.2", + "env-string": "1.0.1", + "fs-extra": "11.2.0", + "golden-fleece": "1.0.9", + "joi": "17.11.0", + "log-symbols": "4.1.0", + "semver": "7.5.2", + "terminal-link": "2.1.1", + "tslib": "2.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@expo/eas-json/node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@expo/eas-json/node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/eas-json/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/eas-json/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@expo/eas-json/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@expo/eas-json/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@expo/eas-json/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/eas-json/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/eas-json/node_modules/semver": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/eas-json/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/eas-json/node_modules/tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@expo/eas-json/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/@expo/env": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-1.0.7.tgz", + "integrity": "sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + } + }, + "node_modules/@expo/env/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@expo/env/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/expo-modules-macros-plugin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.2.2.tgz", + "integrity": "sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==", + "license": "MIT" + }, + "node_modules/@expo/fingerprint": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.4.tgz", + "integrity": "sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.3.0", + "@expo/spawn-async": "^1.8.0", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/fingerprint/node_modules/@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/fingerprint/node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/fingerprint/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.6.5.tgz", + "integrity": "sha512-RsS/1CwJYzccvlprYktD42KjyfWZECH6PPIEowvoSmXfGLfdViwcUEI4RvBfKX5Jli6P67H+6YmHvPTbGOboew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "fs-extra": "9.0.0", + "getenv": "^1.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "temp-dir": "~2.0.0", + "unique-string": "~2.0.0" + } + }, + "node_modules/@expo/image-utils/node_modules/fs-extra": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz", + "integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils/node_modules/universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@expo/inline-modules": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.12.tgz", + "integrity": "sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.9" + } + }, + "node_modules/@expo/inline-modules/node_modules/@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/inline-modules/node_modules/@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "license": "MIT" + }, + "node_modules/@expo/inline-modules/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/inline-modules/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/inline-modules/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/inline-modules/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@expo/inline-modules/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/inline-modules/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/inline-modules/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/inline-modules/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/inline-modules/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/inline-modules/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/inline-modules/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@expo/json-file": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-8.3.3.tgz", + "integrity": "sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.2", + "write-file-atomic": "^2.3.0" + } + }, + "node_modules/@expo/json-file/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.8.tgz", + "integrity": "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~56.0.9", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "license": "MIT" + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/local-build-cache-provider/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@expo/log-box": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.13.tgz", + "integrity": "sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==", + "license": "MIT", + "dependencies": { + "@expo/dom-webview": "^56.0.5", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^56.0.5", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/logger": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@expo/logger/-/logger-20.0.0.tgz", + "integrity": "sha512-ov/lDy/FPv4P4AbK+whZMFmlSo/oQQZFRhHaRiamZspe7kJV0V52LP2XJ7kdFEv5tLRMsKbm8X/JCNXOirC4Uw==", + "dev": true, + "license": "BUSL-1.1", + "dependencies": { + "@types/bunyan": "^1.8.11", + "bunyan": "^1.8.15" + } + }, + "node_modules/@expo/metro": { + "version": "56.0.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", + "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", + "license": "MIT", + "dependencies": { + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4" + } + }, + "node_modules/@expo/metro-config": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.14.tgz", + "integrity": "sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~56.0.9", + "@expo/env": "~2.3.0", + "@expo/json-file": "~10.2.0", + "@expo/metro": "~56.0.0", + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.33.3", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "license": "MIT" + }, + "node_modules/@expo/metro-config/node_modules/@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/metro-config/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@expo/metro-config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/metro-config/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/metro-config/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/metro-config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/metro-config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/metro-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/metro-config/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/metro-config/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@expo/metro-file-map": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz", + "integrity": "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fb-watchman": "^2.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + } + }, + "node_modules/@expo/metro-runtime": { + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", + "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^56.0.13", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "@expo/log-box": "^56.0.13", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@expo/multipart-body-parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/multipart-body-parser/-/multipart-body-parser-2.0.0.tgz", + "integrity": "sha512-yS/wsqlj0d8ZKETEN7ro3dZtjdMhpte8wp+xUzjUQC3jizxcE0E62xgvGquJObiYUMGoCF5qRYr2t78STPEaSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "multipasta": "^0.2.5" + } + }, + "node_modules/@expo/osascript": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.1.4.tgz", + "integrity": "sha512-LcPjxJ5FOFpqPORm+5MRLV0CuYWMthJYV6eerF+lQVXKlvgSn3EOqaHC3Vf3H+vmB0f6G4kdvvFtg40vG4bIhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "exec-async": "^2.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz", + "integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.0.8", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/package-manager/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/package-manager/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/package-manager/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@expo/package-manager/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@expo/package-manager/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@expo/package-manager/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/package-manager/node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/package-manager/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/pkcs12": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@expo/pkcs12/-/pkcs12-0.1.3.tgz", + "integrity": "sha512-96MePEGppKi08vawrTPw8kMCRdsbrDbV900MlI8rrP9F57DfDl/y1P52bwIDBYCEHE3XtPMo7s1xkG0BKOLCVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-forge": "^1.2.1" + } + }, + "node_modules/@expo/plist": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.0.tgz", + "integrity": "sha512-F/IZJQaf8OIVnVA6XWUeMPC3OH6MV00Wxf0WC0JhTQht2QgjyHUa3U5Gs3vRtDq8tXNsZneOQRDVwpaOnd4zTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "~0.7.7", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "node_modules/@expo/plugin-help": { + "version": "5.1.23", + "resolved": "https://registry.npmjs.org/@expo/plugin-help/-/plugin-help-5.1.23.tgz", + "integrity": "sha512-s0uH6cPplLj73ZVie40EYUhl7X7q9kRR+8IfZWDod3wUtVGOFInxuCPX9Jpv1UwwBgbRu2cLisqr8m45LrFgxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/core": "^2.11.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@expo/plugin-help/node_modules/@oclif/core": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-2.16.0.tgz", + "integrity": "sha512-dL6atBH0zCZl1A1IXCKJgLPrM/wR7K+Wi401E/IvqsK8m2iCHW+0TEOGrans/cuN3oTW+uxIyJFHJ8Im0k4qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cli-progress": "^3.11.0", + "ansi-escapes": "^4.3.2", + "ansi-styles": "^4.3.0", + "cardinal": "^2.1.1", + "chalk": "^4.1.2", + "clean-stack": "^3.0.1", + "cli-progress": "^3.12.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "get-package-type": "^0.1.0", + "globby": "^11.1.0", + "hyperlinker": "^1.0.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "js-yaml": "^3.14.1", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.33", + "password-prompt": "^1.1.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@expo/plugin-help/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@expo/plugin-help/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@expo/plugin-help/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@expo/plugin-help/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@expo/plugin-warn-if-update-available": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@expo/plugin-warn-if-update-available/-/plugin-warn-if-update-available-2.5.1.tgz", + "integrity": "sha512-B65QSIZ+TgFHnVXsTw+1Q6djsJByWwnIjYfoG8ZV9wizOC01gbAw1cOZ/YtrJ2BrDnzFQtM8qecjlmZ7C3MPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/core": "^2.11.1", + "chalk": "^4.1.0", + "debug": "^4.3.4", + "ejs": "^3.1.7", + "fs-extra": "^10.1.0", + "http-call": "^5.2.2", + "semver": "^7.3.7", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/@oclif/core": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-2.16.0.tgz", + "integrity": "sha512-dL6atBH0zCZl1A1IXCKJgLPrM/wR7K+Wi401E/IvqsK8m2iCHW+0TEOGrans/cuN3oTW+uxIyJFHJ8Im0k4qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cli-progress": "^3.11.0", + "ansi-escapes": "^4.3.2", + "ansi-styles": "^4.3.0", + "cardinal": "^2.1.1", + "chalk": "^4.1.2", + "clean-stack": "^3.0.1", + "cli-progress": "^3.12.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "get-package-type": "^0.1.0", + "globby": "^11.1.0", + "hyperlinker": "^1.0.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "js-yaml": "^3.14.1", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.33", + "password-prompt": "^1.1.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@expo/plugin-warn-if-update-available/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-8.0.17.tgz", + "integrity": "sha512-HM+XpDox3fAZuXZXvy55VRcBbsZSDijGf8jI8i/pexgWvtsnt1ouelPXRuE1pXDicMX+lZO83QV+XkyLmBEXYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config": "~10.0.4", + "@expo/config-plugins": "~9.0.0", + "@expo/config-types": "^52.0.0", + "@expo/image-utils": "^0.6.0", + "@expo/json-file": "^9.0.0", + "@react-native/normalize-colors": "0.76.2", + "debug": "^4.3.1", + "fs-extra": "^9.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/prebuild-config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/config": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-10.0.11.tgz", + "integrity": "sha512-nociJ4zr/NmbVfMNe9j/+zRlt7wz/siISu7PjdWE4WE+elEGxWWxsGzltdJG0llzrM+khx8qUiFK5aiVcdMBww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~9.0.17", + "@expo/config-types": "^52.0.5", + "@expo/json-file": "^9.0.2", + "deepmerge": "^4.3.1", + "getenv": "^1.0.0", + "glob": "^10.4.2", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "3.35.0" + } + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/config-plugins": { + "version": "9.0.17", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-9.0.17.tgz", + "integrity": "sha512-m24F1COquwOm7PBl5wRbkT9P9DviCXe0D7S7nQsolfbhdCWuvMkfXeoWmgjtdhy7sDlOyIgBrAdnB6MfsWKqIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config-types": "^52.0.5", + "@expo/json-file": "~9.0.2", + "@expo/plist": "^0.2.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^1.0.0", + "glob": "^10.4.2", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.0.2.tgz", + "integrity": "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3", + "write-file-atomic": "^2.3.0" + } + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/config-types": { + "version": "52.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-52.0.5.tgz", + "integrity": "sha512-AMDeuDLHXXqd8W+0zSjIt7f37vUd/BP8p43k68NHpyAvQO+z8mbQZm3cNQVAMySeayK2XoPigAFB1JF2NFajaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/json-file": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", + "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/plist": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.2.tgz", + "integrity": "sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "~0.7.7", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "node_modules/@expo/prebuild-config/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@expo/prebuild-config/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/prebuild-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/prebuild-config/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@expo/prebuild-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/prebuild-config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/prebuild-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/prebuild-config/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@expo/require-utils": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", + "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/results": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/results/-/results-1.0.0.tgz", + "integrity": "sha512-qECzzXX5oJot3m2Gu9pfRDz50USdBieQVwYAzeAtQRUTD3PVeTK1tlRUoDcrK8PSruDLuVYdKkLebX4w/o55VA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/router-server": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz", + "integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^56.0.15", + "expo": "*", + "expo-constants": "^56.0.18", + "expo-font": "^56.0.6", + "expo-router": "*", + "expo-server": "^56.0.5", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/@expo/rudder-sdk-node": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz", + "integrity": "sha512-uy/hS/awclDJ1S88w9UGpc6Nm9XnNUjzOAAib1A3PVAnGQIwebg8DpFqOthFBTlZxeuV/BKbZ5jmTbtNZkp1WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/bunyan": "^4.0.0", + "@segment/loosely-validate-event": "^2.0.0", + "fetch-retry": "^4.1.1", + "md5": "^2.2.1", + "node-fetch": "^2.6.1", + "remove-trailing-slash": "^0.1.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/rudder-sdk-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@expo/schema-utils": { + "version": "56.0.1", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", + "integrity": "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", + "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/steps": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@expo/steps/-/steps-20.1.0.tgz", + "integrity": "sha512-J3pYvLOy9r76Q1hhAQFZ/Y4QLuaBpxE3UH04oOWcy8KGZrcV2r+W6khAobjWZuVfA2dh+ajZbUtxMxVeooiIMA==", + "dev": true, + "license": "BUSL-1.1", + "dependencies": { + "@expo/eas-build-job": "20.1.0", + "@expo/logger": "20.0.0", + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "fs-extra": "^11.2.0", + "joi": "^17.13.1", + "jsep": "^1.3.8", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "uuid": "^9.0.1", + "yaml": "^2.4.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@expo/steps/node_modules/joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/timeago.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/timeago.js/-/timeago.js-1.0.0.tgz", + "integrity": "sha512-PD45CGlCL8kG0U3YcH1NvYxQThw5XAS7qE9bgP4L7dakm8lsMz+p8BQ1IjBFMmImawVWsV3py6JZINaEebXLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@expo/turtle-spawn": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@expo/turtle-spawn/-/turtle-spawn-20.0.0.tgz", + "integrity": "sha512-5NKnpad0wzRPQLa1q50IusZqZtjuYS3FX9COEsCossXOFoGJB2SAYQOjWJNvt/20Hysl+NR2xeAKWp71pE+mow==", + "dev": true, + "license": "BUSL-1.1", + "dependencies": { + "@expo/logger": "20.0.0", + "@expo/spawn-async": "^1.7.2" + } + }, + "node_modules/@expo/ws-tunnel": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", + "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -873,6 +4960,131 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -887,7 +5099,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -895,6 +5106,23 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -921,6 +5149,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -970,6 +5208,515 @@ "node": ">= 8" } }, + "node_modules/@oclif/core": { + "version": "4.11.10", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.10.tgz", + "integrity": "sha512-kbzi5ZfWKYXZzUldAiJMoxVyXaBnMZqoIVDdHJs4DD7T9wg6ADWU5Ale+9XYfysScAt4Og9psyCEPCzIe10sEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.17", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/core/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@oclif/core/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@oclif/core/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@oclif/core/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@oclif/core/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@oclif/core/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@oclif/plugin-autocomplete": { + "version": "3.2.52", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.52.tgz", + "integrity": "sha512-SZaTawQ5ekM4KZCHNxZ0aUNcZ81q4+zyMFKnWeKqpXOIL1ypqvFwRNdxP3Rj3YGEXYoLxrNNW+XOuoqriaBRTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/core": "^4", + "ansis": "^3.16.0", + "debug": "^4.4.1", + "ejs": "^3.1.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz", + "integrity": "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", + "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.85.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", + "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.33.3", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@react-native/codegen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@react-native/codegen/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/codegen/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@react-native/codegen/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.0.tgz", + "integrity": "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.86.0", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.86.0" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-frontend": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.0.tgz", + "integrity": "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw==", + "license": "BSD-3-Clause", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-shell": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.0.tgz", + "integrity": "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/@react-native/dev-middleware": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.0.tgz", + "integrity": "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.0", + "@react-native/debugger-shell": "0.86.0", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.85.3.tgz", + "integrity": "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==", + "license": "BSD-3-Clause", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.85.3.tgz", + "integrity": "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.85.3.tgz", + "integrity": "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.85.3", + "@react-native/debugger-shell": "0.85.3", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.0.tgz", + "integrity": "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz", + "integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.76.2", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.2.tgz", + "integrity": "sha512-ICoOpaTLPsFQjNLSM00NgQr6wal300cZZonHVSDXKntX+BfkLeuCHRtr/Mn+klTtW+/1v2/2FRm9dXjvyGf9Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.0.tgz", + "integrity": "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.86.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1275,11 +6022,113 @@ "win32" ] }, + "node_modules/@segment/loosely-validate-event": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", + "integrity": "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==", + "dev": true, + "dependencies": { + "component-type": "^1.2.1", + "join-component": "^1.1.0" + } + }, + "node_modules/@sentry-internal/tracing": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.77.0.tgz", + "integrity": "sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/core": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.77.0.tgz", + "integrity": "sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/node": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.77.0.tgz", + "integrity": "sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry-internal/tracing": "7.77.0", + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0", + "https-proxy-agent": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/types": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.77.0.tgz", + "integrity": "sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/utils": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.77.0.tgz", + "integrity": "sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/types": "7.77.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==" }, "node_modules/@tailwindcss/node": { "version": "4.2.2", @@ -1581,6 +6430,223 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1687,6 +6753,34 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -1738,6 +6832,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bunyan": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.11.tgz", + "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", @@ -1753,6 +6857,22 @@ "@types/chai": "<5.2.0" } }, + "node_modules/@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1762,9 +6882,26 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1775,7 +6912,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1794,7 +6930,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.2.2" } @@ -1843,6 +6979,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -2096,8 +7247,29 @@ "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "node_modules/@urql/core": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-4.0.11.tgz", + "integrity": "sha512-FFdY97vF5xnUrElcGw9erOLvtu+KGMLfwrLNDfv4IPgdp2IBsiGe+Kb7Aypfd3kH//BETewVSLm3+y2sSzjX6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.1", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-retry": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.2.0.tgz", + "integrity": "sha512-1O/biKiVhhn0EtvDF4UOvz325K4RrLupfL8rHcmqD2TBLv4qVDWQuzx4JGa1FfqjjRb+C9TNZ6w19f32Mq85Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@urql/core": ">=4.0.0", + "wonka": "^6.3.2" + } }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", @@ -2239,6 +7411,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version has critical issues, please update to the latest version", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -2246,11 +7429,35 @@ "deprecated": "Use your platform's native atob() and btoa() methods instead", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2307,6 +7514,81 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2329,11 +7611,40 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { "version": "5.3.0", @@ -2355,6 +7666,22 @@ "node": ">=8" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -2364,12 +7691,39 @@ "node": "*" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -2406,17 +7760,200 @@ "postcss": "^8.1.0" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", + "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.33.3" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-expo": { + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", + "integrity": "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-plugin-codegen": "0.85.3", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.33.3", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^56.0.18", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/barcode-detector": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz", + "integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.0" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2431,8 +7968,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.10.9", @@ -2445,6 +7981,19 @@ "node": ">=6.0.0" } }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/better-sqlite3": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", @@ -2461,6 +8010,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2485,6 +8043,27 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2499,7 +8078,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -2539,6 +8117,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2565,6 +8152,40 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bunyan": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", + "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", + "dev": true, + "engines": [ + "node >=0.10.0" + ], + "license": "MIT", + "bin": { + "bunyan": "bin/bunyan" + }, + "optionalDependencies": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2630,6 +8251,20 @@ "integrity": "sha512-eZhWTZIkFOnMAJOgfXJa9+b3kVlvG+FX4mdkpePev/w/rP5V8NRquGyEozcjPfEoXUlb+p7d9SUcmDSn14prOA==", "license": "MIT" }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -2652,7 +8287,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2664,6 +8298,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -2684,6 +8328,106 @@ "license": "ISC", "optional": true }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -2695,6 +8439,15 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2733,6 +8486,70 @@ "node": ">=20" } }, + "node_modules/component-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/component-type/-/component-type-1.2.2.tgz", + "integrity": "sha512-99VUHREHiN5cLeHm3YLq312p6v+HUEcwtLCAtelvUDI6+SH5g5Cr85oNR2S1o6ywzL0ykMbuwLzM2ANocjEOIA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2745,22 +8562,81 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2770,6 +8646,26 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -2786,7 +8682,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "devOptional": true }, "node_modules/data-urls": { "version": "4.0.0", @@ -2802,6 +8698,16 @@ "node": ">=14" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2879,6 +8785,37 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2888,6 +8825,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2899,6 +8845,16 @@ "node": ">=6" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2907,6 +8863,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -2934,6 +8900,12 @@ "node": ">=8" } }, + "node_modules/dnssd-advertise": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", + "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2966,6 +8938,13 @@ "node": ">=12" } }, + "node_modules/domino": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", + "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -2977,6 +8956,50 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "nan": "^2.14.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2991,6 +9014,359 @@ "node": ">= 0.4" } }, + "node_modules/eas-cli": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/eas-cli/-/eas-cli-20.3.0.tgz", + "integrity": "sha512-aITxilXdza6hqiJhdCHajh2bh8xMgjTDos4uMYDvqFSbO7zSE3c+hgYBU+EQ1rapoMfeAGp9SDc9+A0RNkLbJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/apple-utils": "2.1.19", + "@expo/code-signing-certificates": "0.0.5", + "@expo/config": "55.0.10", + "@expo/config-plugins": "55.0.7", + "@expo/eas-build-job": "20.1.0", + "@expo/eas-json": "20.1.0", + "@expo/env": "^1.0.0", + "@expo/json-file": "8.3.3", + "@expo/logger": "20.0.0", + "@expo/multipart-body-parser": "2.0.0", + "@expo/osascript": "2.1.4", + "@expo/package-manager": "1.9.10", + "@expo/pkcs12": "0.1.3", + "@expo/plist": "0.2.0", + "@expo/plugin-help": "5.1.23", + "@expo/plugin-warn-if-update-available": "2.5.1", + "@expo/prebuild-config": "8.0.17", + "@expo/results": "1.0.0", + "@expo/rudder-sdk-node": "1.1.1", + "@expo/spawn-async": "1.7.2", + "@expo/steps": "20.1.0", + "@expo/timeago.js": "1.0.0", + "@oclif/core": "^4.8.3", + "@oclif/plugin-autocomplete": "^3.2.40", + "@segment/ajv-human-errors": "^2.1.2", + "@sentry/node": "7.77.0", + "@urql/core": "4.0.11", + "@urql/exchange-retry": "1.2.0", + "ajv": "8.11.0", + "ajv-formats": "2.1.1", + "better-opn": "3.0.2", + "bplist-parser": "^0.3.0", + "chalk": "4.1.2", + "cli-progress": "3.12.0", + "dateformat": "4.6.3", + "debug": "4.4.3", + "diff": "7.0.0", + "dotenv": "16.3.1", + "env-paths": "2.2.0", + "envinfo": "7.11.0", + "fast-deep-equal": "3.1.3", + "fast-glob": "3.3.2", + "figures": "3.2.0", + "form-data": "^4.0.4", + "fs-extra": "11.2.0", + "getenv": "1.0.0", + "gradle-to-js": "2.0.1", + "graphql": "16.8.1", + "graphql-tag": "2.12.6", + "https-proxy-agent": "5.0.1", + "ignore": "5.3.0", + "indent-string": "4.0.0", + "invariant": "^2.2.2", + "jks-js": "1.1.0", + "joi": "17.11.0", + "keychain": "1.5.0", + "log-symbols": "4.1.0", + "mime": "3.0.0", + "minimatch": "5.1.2", + "minizlib": "3.0.1", + "nanoid": "3.3.8", + "node-fetch": "2.6.7", + "node-forge": "1.3.1", + "node-stream-zip": "1.15.0", + "nullthrows": "1.1.1", + "ora": "5.1.0", + "pkg-dir": "4.2.0", + "pngjs": "7.0.0", + "promise-limit": "2.7.0", + "promise-retry": "2.0.1", + "prompts": "2.4.2", + "qrcode-terminal": "0.12.0", + "resolve-from": "5.0.0", + "semver": "7.5.4", + "set-interval-async": "3.0.3", + "slash": "3.0.0", + "tar": "7.5.7", + "tar-stream": "3.1.7", + "terminal-link": "2.1.1", + "ts-deepmerge": "6.2.0", + "tslib": "2.6.2", + "turndown": "7.1.2", + "untildify": "4.0.0", + "uuid": "9.0.1", + "wrap-ansi": "7.0.0", + "yaml": "2.6.0", + "zod": "^4.1.3" + }, + "bin": { + "eas": "bin/run" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eas-cli/node_modules/@segment/ajv-human-errors": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@segment/ajv-human-errors/-/ajv-human-errors-2.16.0.tgz", + "integrity": "sha512-cHNfZcbHrmuYOA7/Sn7HlIDHanamiRTZtngfxcAuFaKQjP7cSqsVHjLz38FI2FQ8JDLz3syGLaz10Gn2ddo7+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.0" + } + }, + "node_modules/eas-cli/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eas-cli/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eas-cli/node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/eas-cli/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/eas-cli/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/eas-cli/node_modules/ignore": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eas-cli/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/eas-cli/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eas-cli/node_modules/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eas-cli/node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/eas-cli/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/eas-cli/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eas-cli/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eas-cli/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/eas-cli/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "license": "0BSD" + }, + "node_modules/eas-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/eas-cli/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -3002,6 +9378,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3037,6 +9422,62 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz", + "integrity": "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/env-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/env-string/-/env-string-1.0.1.tgz", + "integrity": "sha512-/DhCJDf5DSFK32joQiWRpWrT0h7p3hVQfMKxiBb7Nt8C8IF8BYyPtclDnuGGLOoj16d/8udKeiE7JbkotDmorQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3050,7 +9491,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -3131,11 +9571,16 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -3316,6 +9761,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3358,6 +9817,41 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/exec-async": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", + "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", + "dev": true, + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3369,12 +9863,893 @@ "node": ">=6" } }, + "node_modules/expo": { + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.12.tgz", + "integrity": "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "^56.1.16", + "@expo/config": "~56.0.9", + "@expo/config-plugins": "~56.0.9", + "@expo/devtools": "~56.0.2", + "@expo/dom-webview": "~56.0.5", + "@expo/fingerprint": "^0.19.4", + "@expo/local-build-cache-provider": "^56.0.8", + "@expo/log-box": "^56.0.13", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.14", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~56.0.15", + "expo-asset": "~56.0.17", + "expo-constants": "~56.0.18", + "expo-file-system": "~56.0.8", + "expo-font": "~56.0.7", + "expo-keep-awake": "~56.0.3", + "expo-modules-autolinking": "~56.0.16", + "expo-modules-core": "~56.0.17", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.2" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-web": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-application": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz", + "integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-asset": { + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.17.tgz", + "integrity": "sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.10.1", + "expo-constants": "~56.0.18" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-asset/node_modules/@expo/image-utils": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", + "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/expo-asset/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-asset/node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/expo-asset/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo-asset/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-camera": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz", + "integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-clipboard": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.4.tgz", + "integrity": "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", + "integrity": "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.3.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants/node_modules/@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/expo-constants/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo-document-picker": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-56.0.4.tgz", + "integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz", + "integrity": "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", + "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-haptics": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-56.0.3.tgz", + "integrity": "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-loader": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz", + "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.18.tgz", + "integrity": "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~56.0.3" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", + "integrity": "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-linking": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.14.tgz", + "integrity": "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==", + "license": "MIT", + "dependencies": { + "expo-constants": "~56.0.18", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz", + "integrity": "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-autolinking/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-modules-autolinking/node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/expo-modules-autolinking/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/expo-modules-jsi": { + "version": "56.0.10", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.10.tgz", + "integrity": "sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/expo-network": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-56.0.5.tgz", + "integrity": "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-notifications": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.18.tgz", + "integrity": "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.10.1", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~56.0.3", + "expo-constants": "~56.0.18" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-notifications/node_modules/@expo/image-utils": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", + "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/expo-notifications/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-notifications/node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/expo-notifications/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo-notifications/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-server": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", + "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-sharing": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.18.tgz", + "integrity": "sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^56.0.9", + "@expo/config-types": "^56.0.6", + "@expo/plist": "^0.7.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-sharing/node_modules/@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-sharing/node_modules/@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "license": "MIT" + }, + "node_modules/expo-sharing/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/expo-sharing/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo-sharing/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-sharing/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/expo-sharing/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo-sharing/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo-sharing/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo-sharing/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo-sharing/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo-sharing/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-sharing/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/expo-status-bar": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz", + "integrity": "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/expo/node_modules/@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo/node_modules/@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "license": "MIT" + }, + "node_modules/expo/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/expo/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/expo/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo/node_modules/expo-modules-core": { + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz", + "integrity": "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==", + "license": "MIT", + "dependencies": { + "@expo/expo-modules-macros-plugin": "0.2.2", + "expo-modules-jsi": "~56.0.10", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expo/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3432,6 +10807,23 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -3451,6 +10843,27 @@ "reusify": "^1.0.4" } }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3467,6 +10880,19 @@ } } }, + "node_modules/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" + }, + "node_modules/fetch-retry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", + "integrity": "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==", + "dev": true, + "license": "MIT" + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -3474,6 +10900,32 @@ "dev": true, "license": "MIT" }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3494,11 +10946,43 @@ "license": "MIT", "optional": true }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -3506,6 +10990,39 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3542,6 +11059,48 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3597,6 +11156,15 @@ } } }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3605,6 +11173,31 @@ "license": "MIT", "optional": true }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3628,7 +11221,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3683,6 +11275,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3708,6 +11310,16 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/getenv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", + "integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -3784,6 +11396,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/golden-fleece": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/golden-fleece/-/golden-fleece-1.0.9.tgz", + "integrity": "sha512-YSwLaGMOgSBx9roJlNLL12c+FRiw7VECphinc6mGucphc/ZxTHgdEz6gmJqH6NOzYEd/yr64hwjom5pZ+tJVpg==", + "dev": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3801,17 +11419,55 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gradle-to-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/gradle-to-js/-/gradle-to-js-2.0.1.tgz", + "integrity": "sha512-is3hDn9zb8XXnjbEeAEIqxTpLHUiGBqjegLmXPuyMBfKAggpadWFku4/AP8iYAGBX6qR9/5UIUIp47V0XI3aMw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.merge": "^4.6.2" + }, + "bin": { + "gradle-to-js": "cli.js" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -3844,10 +11500,10 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -3855,6 +11511,45 @@ "node": ">= 0.4" } }, + "node_modules/hermes-compiler": { + "version": "250829098.0.14", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz", + "integrity": "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", + "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", + "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.33.3" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -3874,6 +11569,53 @@ "dev": true, "license": "MIT" }, + "node_modules/http-call": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz", + "integrity": "sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==", + "dev": true, + "license": "ISC", + "dependencies": { + "content-type": "^1.0.4", + "debug": "^4.1.1", + "is-retry-allowed": "^1.1.0", + "is-stream": "^2.0.0", + "parse-json": "^4.0.0", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -3901,6 +11643,16 @@ "node": ">= 6" } }, + "node_modules/hyperlinker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hyperlinker/-/hyperlinker-1.0.0.tgz", + "integrity": "sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3927,11 +11679,25 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, "engines": { "node": ">= 4" } }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -3963,6 +11729,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3987,6 +11763,59 @@ "license": "ISC", "optional": true }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4017,11 +11846,20 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -4041,6 +11879,54 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/is-retry-allowed": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4050,8 +11936,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -4107,6 +11992,143 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4115,6 +12137,39 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jks-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jks-js/-/jks-js-1.1.0.tgz", + "integrity": "sha512-irWi8S2V029Vic63w0/TYa8NIZwXu9oeMtHQsX51JDIVBo0lrEaOoyM8ALEEh5PVKD6TrA26FixQK6TzT7dHqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.1", + "node-int64": "^0.4.0", + "node-rsa": "^1.1.1" + } + }, + "node_modules/joi": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", + "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/join-component": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz", + "integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4124,7 +12179,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -4132,6 +12186,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, "node_modules/jsdom": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", @@ -4174,6 +12234,16 @@ } } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4191,6 +12261,13 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4221,6 +12298,29 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -4263,6 +12363,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/keychain": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/keychain/-/keychain-1.5.0.tgz", + "integrity": "sha512-liyp4r+93RI7EB2jhwaRd4MWfdgHH6shuldkaPMkELCJjMFvOOVXuTvw1pGqFfhsrgA6OqfykWWPQgBjQakVag==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4272,6 +12379,33 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4294,6 +12428,31 @@ "immediate": "~3.0.5" } }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4531,6 +12690,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/local-pkg": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", @@ -4558,12 +12737,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -4636,6 +12871,28 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4645,6 +12902,30 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4661,11 +12942,513 @@ "dev": true, "license": "MIT" }, + "node_modules/metro": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", + "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", + "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro-cache": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", + "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache-key": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", + "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/metro-cache/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/metro-config": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", + "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.4", + "metro-cache": "0.84.4", + "metro-core": "0.84.4", + "metro-runtime": "0.84.4", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-config/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/metro-core": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", + "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-file-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", + "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", + "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-resolver": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", + "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-runtime": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", + "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", + "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.4", + "nullthrows": "^1.1.1", + "ob1": "0.84.4", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", + "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.4", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-symbolicate/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", + "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", + "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-source-map": "0.84.4", + "metro-transform-plugins": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/metro/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/metro/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/metro/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -4678,7 +13461,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -4686,11 +13468,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4699,7 +13493,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -4707,6 +13500,16 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -4744,6 +13547,129 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minizlib/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/minizlib/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minizlib/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minizlib/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -4770,6 +13696,17 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", @@ -4813,16 +13750,121 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "dev": true, + "license": "MIT" + }, + "node_modules/multitars": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", + "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/mv/node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mv/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mv/node_modules/rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^6.0.1" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -4844,6 +13886,36 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "ncp": "bin/ncp" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -4872,11 +13944,83 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "node_modules/node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "asn1": "^0.2.4" + } + }, "node_modules/node-sqlite3-wasm": { "version": "0.8.57", "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.57.tgz", @@ -4884,12 +14028,112 @@ "dev": true, "license": "MIT" }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, "node_modules/nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true }, + "node_modules/ob1": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", + "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4899,6 +14143,40 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4916,6 +14194,29 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.1.0.tgz", + "integrity": "sha512-9tXIMPvjZ7hPTbk8DFq1f7Kow/HU/pQYB60JbNq+QnGwcyhWVZaQ4hM9zQDEsPxw/muLpgiHSaumUZxCAmod/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.4.0", + "is-interactive": "^1.0.0", + "log-symbols": "^4.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4955,6 +14256,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -4973,6 +14281,41 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parse-png/node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4985,6 +14328,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/password-prompt": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.3.tgz", + "integrity": "sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "ansi-escapes": "^4.3.2", + "cross-spawn": "^7.0.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5006,11 +14369,41 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5041,9 +14434,10 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -5051,6 +14445,85 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -5068,6 +14541,38 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/plist/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -5078,9 +14583,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -5095,8 +14600,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5167,7 +14673,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -5181,7 +14686,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "engines": { "node": ">=10" }, @@ -5189,12 +14693,73 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -5245,12 +14810,30 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "dev": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5271,6 +14854,15 @@ } ] }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5307,6 +14899,37 @@ "node": ">=0.10.0" } }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -5321,8 +14944,259 @@ "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/react-native": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", + "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", + "license": "MIT", + "dependencies": { + "@react-native/assets-registry": "0.86.0", + "@react-native/codegen": "0.86.0", + "@react-native/community-cli-plugin": "0.86.0", + "@react-native/gradle-plugin": "0.86.0", + "@react-native/js-polyfills": "0.86.0", + "@react-native/normalize-colors": "0.86.0", + "@react-native/virtualized-lists": "0.86.0", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.14", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native/jest-preset": "0.86.0", + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", + "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-webview": { + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native/node_modules/@react-native/codegen": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.0.tgz", + "integrity": "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/react-native/node_modules/@react-native/normalize-colors": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.0.tgz", + "integrity": "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.0" + } + }, + "node_modules/react-native/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/react-native/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-native/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-native/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5348,6 +15222,82 @@ "node": ">= 6" } }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/remove-trailing-slash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz", + "integrity": "sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==", + "dev": true, + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5357,6 +15307,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -5369,6 +15329,27 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5387,6 +15368,36 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5489,7 +15500,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -5504,6 +15514,13 @@ "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "dev": true, "license": "MIT", "optional": true }, @@ -5513,6 +15530,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5538,23 +15564,152 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/set-interval-async": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/set-interval-async/-/set-interval-async-3.0.3.tgz", + "integrity": "sha512-o4DyBv6mko+A9cH3QKek4SAAT5UyJRkfdTi6JHii6ZCKUYFun8SwgBmQrOXd158JOwBQzA+BnO8BvT64xuCaSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -5566,17 +15721,34 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -5626,11 +15798,33 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, "license": "MIT" }, "node_modules/slash": { @@ -5642,11 +15836,37 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5660,18 +15880,92 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5697,6 +15991,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5708,6 +16018,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5732,11 +16056,121 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -5744,12 +16178,49 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -5767,6 +16238,24 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -5799,6 +16288,89 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5814,18 +16386,57 @@ "node": ">=8" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/three": { "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", "license": "MIT" }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5833,12 +16444,13 @@ "dev": true }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5865,11 +16477,16 @@ "node": ">=14.0.0" } }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -5877,6 +16494,21 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -5926,6 +16558,84 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-deepmerge": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", + "integrity": "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5956,7 +16666,6 @@ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -5964,6 +16673,16 @@ "node": "*" } }, + "node_modules/turndown": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.1.2.tgz", + "integrity": "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domino": "^2.1.6" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6001,7 +16720,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6019,8 +16738,60 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/universalify": { "version": "0.2.0", @@ -6031,6 +16802,25 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6085,6 +16875,37 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -6100,6 +16921,24 @@ "node": ">=10.12.0" } }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -7623,6 +18462,12 @@ } } }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -7635,6 +18480,24 @@ "node": ">=14" } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/web-tree-sitter": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", @@ -7684,6 +18547,12 @@ "node": ">=0.10.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -7706,11 +18575,16 @@ "node": ">=14" } }, + "node_modules/whatwg-url-minimum": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", + "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7743,6 +18617,26 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wonka": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "dev": true, + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7752,6 +18646,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7766,17 +18667,47 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/write-file-atomic": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", + "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, "engines": { "node": ">=10.0.0" }, @@ -7793,6 +18724,29 @@ } } }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xcode/node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -7802,6 +18756,38 @@ "node": ">=12" } }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", + "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -7819,6 +18805,19 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", @@ -7906,6 +18905,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7917,9 +18926,54 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zxing-wasm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz", + "integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.7.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } }, "dependencies": { + "@0no-co/graphql.web": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.2.tgz", + "integrity": "sha512-Q1+pRlLhE31GOY/2c9BAEnFTNxO7Awtc6fhhEDlxyCBQ2N0IhD32cPVvPChrK9mwBNSgRdW/sF1kd2e0ojHj1Q==", + "dev": true, + "requires": {} + }, "@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -7941,9 +18995,9 @@ } }, "@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==" }, "@babel/core": { "version": "7.29.0", @@ -7968,72 +19022,172 @@ } }, "@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "requires": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, - "@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "requires": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/types": "^7.29.7" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "requires": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, + "@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "requires": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + } + }, "@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==" + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "requires": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + } }, "@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "requires": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" } }, "@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "requires": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "requires": { + "@babel/types": "^7.29.7" } }, "@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==" + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "requires": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "requires": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + } }, "@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" }, "@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" }, "@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==" + }, + "@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "requires": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + } }, "@babel/helpers": { "version": "7.29.2", @@ -8044,12 +19198,376 @@ "@babel/types": "^7.29.0" } }, - "@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "dev": true, "requires": { - "@babel/types": "^7.29.0" + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "requires": { + "@babel/types": "^7.29.7" + } + }, + "@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + } + }, + "@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "requires": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + } + }, + "@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "requires": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "requires": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + } + }, + "@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "requires": { + "@babel/plugin-transform-react-jsx": "^7.29.7" } }, "@babel/plugin-transform-react-jsx-self": { @@ -8068,43 +19586,121 @@ "@babel/helper-plugin-utils": "^7.27.1" } }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "requires": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + } + }, + "@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + } + }, + "@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + } + }, "@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" }, "@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "requires": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "requires": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + } } }, "@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "requires": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "requires": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + } } }, "@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "requires": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" } }, "@bcoe/v8-coverage": { @@ -8154,6 +19750,27 @@ "web-tree-sitter": "^0.25.3" } }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -8354,6 +19971,2298 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true }, + "@expo/apple-utils": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.1.19.tgz", + "integrity": "sha512-f1iMteL+tTOSF1sovVB35ncobdiZvhjWvwEOWGIuAutyeIpcxNJ/2tUZSE748X/VEQLn1cL2Tozkdp2MLXStvA==", + "dev": true + }, + "@expo/bunyan": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.1.tgz", + "integrity": "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==", + "dev": true, + "requires": { + "uuid": "^8.0.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, + "@expo/cli": { + "version": "56.1.16", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz", + "integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==", + "requires": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~56.0.9", + "@expo/config-plugins": "~56.0.9", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.3.0", + "@expo/image-utils": "^0.10.1", + "@expo/inline-modules": "^0.0.12", + "@expo/json-file": "^10.2.0", + "@expo/log-box": "^56.0.13", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.14", + "@expo/metro-file-map": "^56.0.3", + "@expo/osascript": "^2.6.0", + "@expo/package-manager": "^1.12.1", + "@expo/plist": "^0.7.0", + "@expo/prebuild-config": "^56.0.16", + "@expo/require-utils": "^56.1.3", + "@expo/router-server": "^56.0.14", + "@expo/schema-utils": "^56.0.0", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.85.3", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^56.0.5", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "dependencies": { + "@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "requires": { + "node-forge": "^1.3.3" + } + }, + "@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "requires": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "requires": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==" + }, + "@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "requires": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + } + }, + "@expo/image-utils": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", + "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "requires": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "@expo/osascript": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", + "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", + "requires": { + "@expo/spawn-async": "^1.8.0" + } + }, + "@expo/package-manager": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", + "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", + "requires": { + "@expo/json-file": "^10.2.0", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@expo/prebuild-config": { + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.16.tgz", + "integrity": "sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==", + "requires": { + "@expo/config": "~56.0.9", + "@expo/config-plugins": "~56.0.9", + "@expo/config-types": "^56.0.6", + "@expo/image-utils": "^0.10.1", + "@expo/json-file": "^10.2.0", + "@react-native/normalize-colors": "0.85.3", + "debug": "^4.3.1", + "expo-modules-autolinking": "~56.0.16", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, + "@react-native/normalize-colors": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", + "integrity": "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==" + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==" + }, + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==" + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "requires": { + "chalk": "^2.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==" + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "requires": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + } + } + }, + "@expo/code-signing-certificates": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", + "integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==", + "dev": true, + "requires": { + "node-forge": "^1.2.1", + "nullthrows": "^1.1.1" + } + }, + "@expo/config": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.10.tgz", + "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==", + "dev": true, + "requires": { + "@expo/config-plugins": "~55.0.7", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.12", + "@expo/require-utils": "^55.0.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + }, + "dependencies": { + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + } + } + }, + "@expo/config-plugins": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.7.tgz", + "integrity": "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==", + "dev": true, + "requires": { + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.12", + "@expo/plist": "^0.5.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "dev": true, + "requires": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.4.tgz", + "integrity": "sha512-Jqppj0FULNq6Zp5JtQrFICl8TtpMjwwUbxEcEC2T3z7m+TOrTQEHZXz3D3Ay7vhbmvD+VMgfWJ4ARclJXeN8Eg==", + "dev": true, + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true + } + } + }, + "@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "dev": true + }, + "@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "requires": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "@expo/devtools": { + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-56.0.2.tgz", + "integrity": "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==", + "requires": { + "chalk": "^4.1.2" + } + }, + "@expo/dom-webview": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.5.tgz", + "integrity": "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==", + "requires": {} + }, + "@expo/eas-build-job": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@expo/eas-build-job/-/eas-build-job-20.1.0.tgz", + "integrity": "sha512-a1owbBU9eKbEG0B7Tm/lPIfep5k85bHFwbPsqB8WOWI5KBlHz9Gs/fAfBRt0/EAKlyxxMH3TxlR0YBC+Zy1hCA==", + "dev": true, + "requires": { + "@expo/logger": "20.0.0", + "@expo/results": "1.0.0", + "@expo/turtle-spawn": "20.0.0", + "joi": "^17.13.1", + "semver": "^7.6.2", + "zod": "^4.3.5" + }, + "dependencies": { + "joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + } + } + }, + "@expo/eas-json": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@expo/eas-json/-/eas-json-20.1.0.tgz", + "integrity": "sha512-B3ZrMLNTNmqIMaKB5Y7/mz5EBV6m+frrJohnjLIaGMa8ExQBMMyqn0rg283hc4bEeq6ZqIY2IViWZhdIjUs+aA==", + "dev": true, + "requires": { + "@babel/code-frame": "7.23.5", + "@expo/eas-build-job": "20.1.0", + "chalk": "4.1.2", + "env-string": "1.0.1", + "fs-extra": "11.2.0", + "golden-fleece": "1.0.9", + "joi": "17.11.0", + "log-symbols": "4.1.0", + "semver": "7.5.2", + "terminal-link": "2.1.1", + "tslib": "2.4.1" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "@expo/env": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-1.0.7.tgz", + "integrity": "sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + }, + "dependencies": { + "dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true + } + } + }, + "@expo/expo-modules-macros-plugin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.2.2.tgz", + "integrity": "sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==" + }, + "@expo/fingerprint": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.4.tgz", + "integrity": "sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==", + "requires": { + "@expo/env": "^2.3.0", + "@expo/spawn-async": "^1.8.0", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "dependencies": { + "@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "requires": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + } + }, + "@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + } + } + }, + "@expo/image-utils": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.6.5.tgz", + "integrity": "sha512-RsS/1CwJYzccvlprYktD42KjyfWZECH6PPIEowvoSmXfGLfdViwcUEI4RvBfKX5Jli6P67H+6YmHvPTbGOboew==", + "dev": true, + "requires": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "fs-extra": "9.0.0", + "getenv": "^1.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "temp-dir": "~2.0.0", + "unique-string": "~2.0.0" + }, + "dependencies": { + "fs-extra": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz", + "integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "dev": true + } + } + }, + "@expo/inline-modules": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.12.tgz", + "integrity": "sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==", + "requires": { + "@expo/config-plugins": "~56.0.9" + }, + "dependencies": { + "@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "requires": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==" + }, + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==" + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + } + } + }, + "@expo/json-file": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-8.3.3.tgz", + "integrity": "sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A==", + "dev": true, + "requires": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.2", + "write-file-atomic": "^2.3.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + } + } + }, + "@expo/local-build-cache-provider": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.8.tgz", + "integrity": "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==", + "requires": { + "@expo/config": "~56.0.9", + "chalk": "^4.1.2" + }, + "dependencies": { + "@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "requires": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "requires": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==" + }, + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==" + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + } + } + }, + "@expo/log-box": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.13.tgz", + "integrity": "sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==", + "requires": { + "@expo/dom-webview": "^56.0.5", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + } + }, + "@expo/logger": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@expo/logger/-/logger-20.0.0.tgz", + "integrity": "sha512-ov/lDy/FPv4P4AbK+whZMFmlSo/oQQZFRhHaRiamZspe7kJV0V52LP2XJ7kdFEv5tLRMsKbm8X/JCNXOirC4Uw==", + "dev": true, + "requires": { + "@types/bunyan": "^1.8.11", + "bunyan": "^1.8.15" + } + }, + "@expo/metro": { + "version": "56.0.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", + "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", + "requires": { + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4" + } + }, + "@expo/metro-config": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.14.tgz", + "integrity": "sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~56.0.9", + "@expo/env": "~2.3.0", + "@expo/json-file": "~10.2.0", + "@expo/metro": "~56.0.0", + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.33.3", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "requires": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "requires": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==" + }, + "@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "requires": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + } + }, + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==" + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + } + } + }, + "@expo/metro-file-map": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz", + "integrity": "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==", + "requires": { + "debug": "^4.3.4", + "fb-watchman": "^2.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + } + }, + "@expo/metro-runtime": { + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", + "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", + "requires": { + "@expo/log-box": "^56.0.13", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + } + }, + "@expo/multipart-body-parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/multipart-body-parser/-/multipart-body-parser-2.0.0.tgz", + "integrity": "sha512-yS/wsqlj0d8ZKETEN7ro3dZtjdMhpte8wp+xUzjUQC3jizxcE0E62xgvGquJObiYUMGoCF5qRYr2t78STPEaSw==", + "dev": true, + "requires": { + "multipasta": "^0.2.5" + } + }, + "@expo/osascript": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.1.4.tgz", + "integrity": "sha512-LcPjxJ5FOFpqPORm+5MRLV0CuYWMthJYV6eerF+lQVXKlvgSn3EOqaHC3Vf3H+vmB0f6G4kdvvFtg40vG4bIhA==", + "dev": true, + "requires": { + "@expo/spawn-async": "^1.7.2", + "exec-async": "^2.2.0" + } + }, + "@expo/package-manager": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz", + "integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==", + "dev": true, + "requires": { + "@expo/json-file": "^10.0.8", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + }, + "dependencies": { + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "@expo/pkcs12": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@expo/pkcs12/-/pkcs12-0.1.3.tgz", + "integrity": "sha512-96MePEGppKi08vawrTPw8kMCRdsbrDbV900MlI8rrP9F57DfDl/y1P52bwIDBYCEHE3XtPMo7s1xkG0BKOLCVg==", + "dev": true, + "requires": { + "node-forge": "^1.2.1" + } + }, + "@expo/plist": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.0.tgz", + "integrity": "sha512-F/IZJQaf8OIVnVA6XWUeMPC3OH6MV00Wxf0WC0JhTQht2QgjyHUa3U5Gs3vRtDq8tXNsZneOQRDVwpaOnd4zTQ==", + "dev": true, + "requires": { + "@xmldom/xmldom": "~0.7.7", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "@expo/plugin-help": { + "version": "5.1.23", + "resolved": "https://registry.npmjs.org/@expo/plugin-help/-/plugin-help-5.1.23.tgz", + "integrity": "sha512-s0uH6cPplLj73ZVie40EYUhl7X7q9kRR+8IfZWDod3wUtVGOFInxuCPX9Jpv1UwwBgbRu2cLisqr8m45LrFgxw==", + "dev": true, + "requires": { + "@oclif/core": "^2.11.1" + }, + "dependencies": { + "@oclif/core": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-2.16.0.tgz", + "integrity": "sha512-dL6atBH0zCZl1A1IXCKJgLPrM/wR7K+Wi401E/IvqsK8m2iCHW+0TEOGrans/cuN3oTW+uxIyJFHJ8Im0k4qBw==", + "dev": true, + "requires": { + "@types/cli-progress": "^3.11.0", + "ansi-escapes": "^4.3.2", + "ansi-styles": "^4.3.0", + "cardinal": "^2.1.1", + "chalk": "^4.1.2", + "clean-stack": "^3.0.1", + "cli-progress": "^3.12.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "get-package-type": "^0.1.0", + "globby": "^11.1.0", + "hyperlinker": "^1.0.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "js-yaml": "^3.14.1", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.33", + "password-prompt": "^1.1.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "@expo/plugin-warn-if-update-available": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@expo/plugin-warn-if-update-available/-/plugin-warn-if-update-available-2.5.1.tgz", + "integrity": "sha512-B65QSIZ+TgFHnVXsTw+1Q6djsJByWwnIjYfoG8ZV9wizOC01gbAw1cOZ/YtrJ2BrDnzFQtM8qecjlmZ7C3MPLw==", + "dev": true, + "requires": { + "@oclif/core": "^2.11.1", + "chalk": "^4.1.0", + "debug": "^4.3.4", + "ejs": "^3.1.7", + "fs-extra": "^10.1.0", + "http-call": "^5.2.2", + "semver": "^7.3.7", + "tslib": "^2.4.0" + }, + "dependencies": { + "@oclif/core": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-2.16.0.tgz", + "integrity": "sha512-dL6atBH0zCZl1A1IXCKJgLPrM/wR7K+Wi401E/IvqsK8m2iCHW+0TEOGrans/cuN3oTW+uxIyJFHJ8Im0k4qBw==", + "dev": true, + "requires": { + "@types/cli-progress": "^3.11.0", + "ansi-escapes": "^4.3.2", + "ansi-styles": "^4.3.0", + "cardinal": "^2.1.1", + "chalk": "^4.1.2", + "clean-stack": "^3.0.1", + "cli-progress": "^3.12.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "get-package-type": "^0.1.0", + "globby": "^11.1.0", + "hyperlinker": "^1.0.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "js-yaml": "^3.14.1", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.33", + "password-prompt": "^1.1.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "@expo/prebuild-config": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-8.0.17.tgz", + "integrity": "sha512-HM+XpDox3fAZuXZXvy55VRcBbsZSDijGf8jI8i/pexgWvtsnt1ouelPXRuE1pXDicMX+lZO83QV+XkyLmBEXYQ==", + "dev": true, + "requires": { + "@expo/config": "~10.0.4", + "@expo/config-plugins": "~9.0.0", + "@expo/config-types": "^52.0.0", + "@expo/image-utils": "^0.6.0", + "@expo/json-file": "^9.0.0", + "@react-native/normalize-colors": "0.76.2", + "debug": "^4.3.1", + "fs-extra": "^9.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@expo/config": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-10.0.11.tgz", + "integrity": "sha512-nociJ4zr/NmbVfMNe9j/+zRlt7wz/siISu7PjdWE4WE+elEGxWWxsGzltdJG0llzrM+khx8qUiFK5aiVcdMBww==", + "dev": true, + "requires": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~9.0.17", + "@expo/config-types": "^52.0.5", + "@expo/json-file": "^9.0.2", + "deepmerge": "^4.3.1", + "getenv": "^1.0.0", + "glob": "^10.4.2", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "3.35.0" + } + }, + "@expo/config-plugins": { + "version": "9.0.17", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-9.0.17.tgz", + "integrity": "sha512-m24F1COquwOm7PBl5wRbkT9P9DviCXe0D7S7nQsolfbhdCWuvMkfXeoWmgjtdhy7sDlOyIgBrAdnB6MfsWKqIg==", + "dev": true, + "requires": { + "@expo/config-types": "^52.0.5", + "@expo/json-file": "~9.0.2", + "@expo/plist": "^0.2.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^1.0.0", + "glob": "^10.4.2", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + }, + "dependencies": { + "@expo/json-file": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.0.2.tgz", + "integrity": "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw==", + "dev": true, + "requires": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3", + "write-file-atomic": "^2.3.0" + } + } + } + }, + "@expo/config-types": { + "version": "52.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-52.0.5.tgz", + "integrity": "sha512-AMDeuDLHXXqd8W+0zSjIt7f37vUd/BP8p43k68NHpyAvQO+z8mbQZm3cNQVAMySeayK2XoPigAFB1JF2NFajaA==", + "dev": true + }, + "@expo/json-file": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", + "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", + "dev": true, + "requires": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.2.tgz", + "integrity": "sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g==", + "dev": true, + "requires": { + "@xmldom/xmldom": "~0.7.7", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.2" + } + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + } + } + }, + "@expo/require-utils": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", + "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@expo/results": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/results/-/results-1.0.0.tgz", + "integrity": "sha512-qECzzXX5oJot3m2Gu9pfRDz50USdBieQVwYAzeAtQRUTD3PVeTK1tlRUoDcrK8PSruDLuVYdKkLebX4w/o55VA==", + "dev": true + }, + "@expo/router-server": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz", + "integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==", + "requires": { + "debug": "^4.3.4" + } + }, + "@expo/rudder-sdk-node": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz", + "integrity": "sha512-uy/hS/awclDJ1S88w9UGpc6Nm9XnNUjzOAAib1A3PVAnGQIwebg8DpFqOthFBTlZxeuV/BKbZ5jmTbtNZkp1WQ==", + "dev": true, + "requires": { + "@expo/bunyan": "^4.0.0", + "@segment/loosely-validate-event": "^2.0.0", + "fetch-retry": "^4.1.1", + "md5": "^2.2.1", + "node-fetch": "^2.6.1", + "remove-trailing-slash": "^0.1.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, + "@expo/schema-utils": { + "version": "56.0.1", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", + "integrity": "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==" + }, + "@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==" + }, + "@expo/spawn-async": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", + "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3" + } + }, + "@expo/steps": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@expo/steps/-/steps-20.1.0.tgz", + "integrity": "sha512-J3pYvLOy9r76Q1hhAQFZ/Y4QLuaBpxE3UH04oOWcy8KGZrcV2r+W6khAobjWZuVfA2dh+ajZbUtxMxVeooiIMA==", + "dev": true, + "requires": { + "@expo/eas-build-job": "20.1.0", + "@expo/logger": "20.0.0", + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "fs-extra": "^11.2.0", + "joi": "^17.13.1", + "jsep": "^1.3.8", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "uuid": "^9.0.1", + "yaml": "^2.4.3" + }, + "dependencies": { + "joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + } + } + }, + "@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==" + }, + "@expo/timeago.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/timeago.js/-/timeago.js-1.0.0.tgz", + "integrity": "sha512-PD45CGlCL8kG0U3YcH1NvYxQThw5XAS7qE9bgP4L7dakm8lsMz+p8BQ1IjBFMmImawVWsV3py6JZINaEebXLnw==", + "dev": true + }, + "@expo/turtle-spawn": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@expo/turtle-spawn/-/turtle-spawn-20.0.0.tgz", + "integrity": "sha512-5NKnpad0wzRPQLa1q50IusZqZtjuYS3FX9COEsCossXOFoGJB2SAYQOjWJNvt/20Hysl+NR2xeAKWp71pE+mow==", + "dev": true, + "requires": { + "@expo/logger": "20.0.0", + "@expo/spawn-async": "^1.7.2" + } + }, + "@expo/ws-tunnel": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", + "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "requires": {} + }, + "@expo/xcpretty": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "requires": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + } + }, + "@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -8377,6 +22286,85 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "requires": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "requires": { + "ansi-regex": "^6.2.2" + } + }, + "wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "requires": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + } + } + } + }, + "@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "requires": { + "minipass": "^7.0.4" + } + }, + "@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==" + }, "@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -8387,11 +22375,23 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, "requires": { "@sinclair/typebox": "^0.27.8" } }, + "@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "requires": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + } + }, "@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -8415,6 +22415,15 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" }, + "@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -8455,6 +22464,323 @@ "fastq": "^1.6.0" } }, + "@oclif/core": { + "version": "4.11.10", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.10.tgz", + "integrity": "sha512-kbzi5ZfWKYXZzUldAiJMoxVyXaBnMZqoIVDdHJs4DD7T9wg6ADWU5Ale+9XYfysScAt4Og9psyCEPCzIe10sEQ==", + "dev": true, + "requires": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.17", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "@oclif/plugin-autocomplete": { + "version": "3.2.52", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.52.tgz", + "integrity": "sha512-SZaTawQ5ekM4KZCHNxZ0aUNcZ81q4+zyMFKnWeKqpXOIL1ypqvFwRNdxP3Rj3YGEXYoLxrNNW+XOuoqriaBRTg==", + "dev": true, + "requires": { + "@oclif/core": "^4", + "ansis": "^3.16.0", + "debug": "^4.4.1", + "ejs": "^3.1.10" + } + }, + "@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true + }, + "@react-native/assets-registry": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz", + "integrity": "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==" + }, + "@react-native/babel-plugin-codegen": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", + "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", + "requires": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.85.3" + } + }, + "@react-native/codegen": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", + "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", + "requires": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.33.3", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "dependencies": { + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + } + } + }, + "@react-native/community-cli-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.0.tgz", + "integrity": "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ==", + "requires": { + "@react-native/dev-middleware": "0.86.0", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "dependencies": { + "@react-native/debugger-frontend": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.0.tgz", + "integrity": "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw==" + }, + "@react-native/debugger-shell": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.0.tgz", + "integrity": "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w==", + "requires": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + } + }, + "@react-native/dev-middleware": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.0.tgz", + "integrity": "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw==", + "requires": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.0", + "@react-native/debugger-shell": "0.86.0", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + } + }, + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "requires": {} + } + } + }, + "@react-native/debugger-frontend": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.85.3.tgz", + "integrity": "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==" + }, + "@react-native/debugger-shell": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.85.3.tgz", + "integrity": "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==", + "requires": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + } + }, + "@react-native/dev-middleware": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.85.3.tgz", + "integrity": "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==", + "requires": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.85.3", + "@react-native/debugger-shell": "0.85.3", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "dependencies": { + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "requires": {} + } + } + }, + "@react-native/gradle-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.0.tgz", + "integrity": "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ==" + }, + "@react-native/js-polyfills": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz", + "integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==" + }, + "@react-native/normalize-colors": { + "version": "0.76.2", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.2.tgz", + "integrity": "sha512-ICoOpaTLPsFQjNLSM00NgQr6wal300cZZonHVSDXKntX+BfkLeuCHRtr/Mn+klTtW+/1v2/2FRm9dXjvyGf9Dw==", + "dev": true + }, + "@react-native/virtualized-lists": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.0.tgz", + "integrity": "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw==", + "requires": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + } + }, "@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -8610,11 +22936,90 @@ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "optional": true }, + "@segment/loosely-validate-event": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", + "integrity": "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==", + "dev": true, + "requires": { + "component-type": "^1.2.1", + "join-component": "^1.1.0" + } + }, + "@sentry-internal/tracing": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.77.0.tgz", + "integrity": "sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==", + "dev": true, + "requires": { + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" + } + }, + "@sentry/core": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.77.0.tgz", + "integrity": "sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==", + "dev": true, + "requires": { + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" + } + }, + "@sentry/node": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.77.0.tgz", + "integrity": "sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==", + "dev": true, + "requires": { + "@sentry-internal/tracing": "7.77.0", + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0", + "https-proxy-agent": "^5.0.0" + } + }, + "@sentry/types": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.77.0.tgz", + "integrity": "sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==", + "dev": true + }, + "@sentry/utils": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.77.0.tgz", + "integrity": "sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==", + "dev": true, + "requires": { + "@sentry/types": "7.77.0" + } + }, + "@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==" }, "@tailwindcss/node": { "version": "4.2.2", @@ -8789,6 +23194,102 @@ "tailwindcss": "4.2.2" } }, + "@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "requires": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "dev": true, + "optional": true + }, + "@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "dev": true, + "optional": true + }, "@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -8856,6 +23357,30 @@ "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true }, + "@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -8906,6 +23431,15 @@ "@babel/types": "^7.28.2" } }, + "@types/bunyan": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.11.tgz", + "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/chai": { "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", @@ -8919,6 +23453,20 @@ "dev": true, "requires": {} }, + "@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==" + }, "@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -8927,8 +23475,23 @@ "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" + }, + "@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "requires": { + "@types/istanbul-lib-report": "*" + } }, "@types/json-schema": { "version": "7.0.15", @@ -8940,7 +23503,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, "requires": { "undici-types": "~6.21.0" } @@ -8958,7 +23520,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "requires": { "csstype": "^3.2.2" } @@ -9002,6 +23564,19 @@ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "dev": true }, + "@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" + }, "@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -9148,8 +23723,27 @@ "@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "@urql/core": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-4.0.11.tgz", + "integrity": "sha512-FFdY97vF5xnUrElcGw9erOLvtu+KGMLfwrLNDfv4IPgdp2IBsiGe+Kb7Aypfd3kH//BETewVSLm3+y2sSzjX6A==", + "dev": true, + "requires": { + "@0no-co/graphql.web": "^1.0.1", + "wonka": "^6.3.2" + } + }, + "@urql/exchange-retry": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.2.0.tgz", + "integrity": "sha512-1O/biKiVhhn0EtvDF4UOvz325K4RrLupfL8rHcmqD2TBLv4qVDWQuzx4JGa1FfqjjRb+C9TNZ6w19f32Mq85Ug==", + "dev": true, + "requires": { + "@urql/core": ">=4.0.0", + "wonka": "^6.3.2" + } }, "@vitejs/plugin-react": { "version": "5.2.0", @@ -9253,17 +23847,39 @@ "pretty-format": "^29.5.0" } }, + "@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "dev": true + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, "acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==" }, "acorn-jsx": { "version": "5.3.2", @@ -9302,6 +23918,55 @@ "uri-js": "^4.2.2" } }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, + "anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==" + }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "requires": { + "type-fest": "^0.21.3" + }, + "dependencies": { + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==" + } + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -9315,11 +23980,33 @@ "color-convert": "^2.0.1" } }, + "ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true + }, + "ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "dev": true + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "aria-query": { "version": "5.3.0", @@ -9337,18 +24024,50 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, + "asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true + }, "autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -9362,24 +24081,163 @@ "postcss-value-parser": "^4.2.0" } }, + "b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "requires": {} + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "requires": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "requires": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "requires": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + } + }, + "babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "requires": { + "@babel/types": "^7.26.0" + } + }, + "babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==" + }, + "babel-plugin-syntax-hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", + "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", + "requires": { + "hermes-parser": "0.33.3" + } + }, + "babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "requires": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "babel-preset-expo": { + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", + "integrity": "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==", + "requires": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-plugin-codegen": "0.85.3", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.33.3", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4" + } + }, + "badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==" + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "barcode-detector": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz", + "integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==", + "requires": { + "zxing-wasm": "3.1.0" + } + }, + "bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "requires": {} + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "optional": true + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==" }, + "better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "dev": true, + "requires": { + "open": "^8.0.4" + } + }, "better-sqlite3": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", @@ -9391,6 +24249,11 @@ "prebuild-install": "^7.1.1" } }, + "big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==" + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -9413,6 +24276,22 @@ "readable-stream": "^3.4.0" } }, + "bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "requires": { + "stream-buffers": "2.2.x" + } + }, + "bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "requires": { + "big-integer": "1.6.x" + } + }, "brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -9427,7 +24306,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "requires": { "fill-range": "^7.1.1" } @@ -9444,6 +24322,14 @@ "update-browserslist-db": "^1.2.0" } }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "requires": { + "node-int64": "^0.4.0" + } + }, "buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -9455,6 +24341,28 @@ "ieee754": "^1.1.13" } }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "bunyan": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", + "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", + "dev": true, + "requires": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -9492,6 +24400,16 @@ "resolved": "https://registry.npmjs.org/cannon-es/-/cannon-es-0.20.0.tgz", "integrity": "sha512-eZhWTZIkFOnMAJOgfXJa9+b3kVlvG+FX4mdkpePev/w/rP5V8NRquGyEozcjPfEoXUlb+p7d9SUcmDSn14prOA==" }, + "cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "requires": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + } + }, "chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -9511,12 +24429,17 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true + }, "check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -9533,6 +24456,66 @@ "dev": true, "optional": true }, + "chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "requires": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + } + }, + "chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "requires": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==" + }, + "clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "dev": true, + "requires": { + "escape-string-regexp": "4.0.0" + } + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "requires": { + "string-width": "^4.2.3" + } + }, + "cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==" + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -9543,6 +24526,11 @@ "wrap-ansi": "^6.2.0" } }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9571,6 +24559,54 @@ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true }, + "component-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/component-type/-/component-type-1.2.2.tgz", + "integrity": "sha512-99VUHREHiN5cLeHm3YLq312p6v+HUEcwtLCAtelvUDI6+SH5g5Cr85oNR2S1o6ywzL0ykMbuwLzM2ANocjEOIA==", + "dev": true + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "requires": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==" + } + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -9583,27 +24619,84 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, + "connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true + }, "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "requires": { + "browserslist": "^4.28.1" + } + }, "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true + }, + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true + }, "cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -9617,7 +24710,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "devOptional": true }, "data-urls": { "version": "4.0.0", @@ -9630,6 +24723,12 @@ "whatwg-url": "^12.0.0" } }, + "dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -9681,12 +24780,36 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, + "defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "requires": { + "clone": "^1.0.2" + } + }, + "define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, "dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -9694,11 +24817,22 @@ "dev": true, "peer": true }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, + "diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true + }, "diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -9719,6 +24853,11 @@ "path-type": "^4.0.0" } }, + "dnssd-advertise": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", + "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==" + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -9744,11 +24883,44 @@ "webidl-conversions": "^7.0.0" } }, + "domino": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", + "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==", + "dev": true + }, "dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==" }, + "dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "requires": { + "dotenv": "^16.4.5" + }, + "dependencies": { + "dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true + } + } + }, + "dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.14.0" + } + }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9760,6 +24932,270 @@ "gopd": "^1.2.0" } }, + "eas-cli": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/eas-cli/-/eas-cli-20.3.0.tgz", + "integrity": "sha512-aITxilXdza6hqiJhdCHajh2bh8xMgjTDos4uMYDvqFSbO7zSE3c+hgYBU+EQ1rapoMfeAGp9SDc9+A0RNkLbJQ==", + "dev": true, + "requires": { + "@expo/apple-utils": "2.1.19", + "@expo/code-signing-certificates": "0.0.5", + "@expo/config": "55.0.10", + "@expo/config-plugins": "55.0.7", + "@expo/eas-build-job": "20.1.0", + "@expo/eas-json": "20.1.0", + "@expo/env": "^1.0.0", + "@expo/json-file": "8.3.3", + "@expo/logger": "20.0.0", + "@expo/multipart-body-parser": "2.0.0", + "@expo/osascript": "2.1.4", + "@expo/package-manager": "1.9.10", + "@expo/pkcs12": "0.1.3", + "@expo/plist": "0.2.0", + "@expo/plugin-help": "5.1.23", + "@expo/plugin-warn-if-update-available": "2.5.1", + "@expo/prebuild-config": "8.0.17", + "@expo/results": "1.0.0", + "@expo/rudder-sdk-node": "1.1.1", + "@expo/spawn-async": "1.7.2", + "@expo/steps": "20.1.0", + "@expo/timeago.js": "1.0.0", + "@oclif/core": "^4.8.3", + "@oclif/plugin-autocomplete": "^3.2.40", + "@segment/ajv-human-errors": "^2.1.2", + "@sentry/node": "7.77.0", + "@urql/core": "4.0.11", + "@urql/exchange-retry": "1.2.0", + "ajv": "8.11.0", + "ajv-formats": "2.1.1", + "better-opn": "3.0.2", + "bplist-parser": "^0.3.0", + "chalk": "4.1.2", + "cli-progress": "3.12.0", + "dateformat": "4.6.3", + "debug": "4.4.3", + "diff": "7.0.0", + "dotenv": "16.3.1", + "env-paths": "2.2.0", + "envinfo": "7.11.0", + "fast-deep-equal": "3.1.3", + "fast-glob": "3.3.2", + "figures": "3.2.0", + "form-data": "^4.0.4", + "fs-extra": "11.2.0", + "getenv": "1.0.0", + "gradle-to-js": "2.0.1", + "graphql": "16.8.1", + "graphql-tag": "2.12.6", + "https-proxy-agent": "5.0.1", + "ignore": "5.3.0", + "indent-string": "4.0.0", + "invariant": "^2.2.2", + "jks-js": "1.1.0", + "joi": "17.11.0", + "keychain": "1.5.0", + "log-symbols": "4.1.0", + "mime": "3.0.0", + "minimatch": "5.1.2", + "minizlib": "3.0.1", + "nanoid": "3.3.8", + "node-fetch": "2.6.7", + "node-forge": "1.3.1", + "node-stream-zip": "1.15.0", + "nullthrows": "1.1.1", + "ora": "5.1.0", + "pkg-dir": "4.2.0", + "pngjs": "7.0.0", + "promise-limit": "2.7.0", + "promise-retry": "2.0.1", + "prompts": "2.4.2", + "qrcode-terminal": "0.12.0", + "resolve-from": "5.0.0", + "semver": "7.5.4", + "set-interval-async": "3.0.3", + "slash": "3.0.0", + "tar": "7.5.7", + "tar-stream": "3.1.7", + "terminal-link": "2.1.1", + "ts-deepmerge": "6.2.0", + "tslib": "2.6.2", + "turndown": "7.1.2", + "untildify": "4.0.0", + "uuid": "9.0.1", + "wrap-ansi": "7.0.0", + "yaml": "2.6.0", + "zod": "^4.1.3" + }, + "dependencies": { + "@segment/ajv-human-errors": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@segment/ajv-human-errors/-/ajv-human-errors-2.16.0.tgz", + "integrity": "sha512-cHNfZcbHrmuYOA7/Sn7HlIDHanamiRTZtngfxcAuFaKQjP7cSqsVHjLz38FI2FQ8JDLz3syGLaz10Gn2ddo7+w==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "dev": true + }, + "fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "ignore": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "dev": true + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true + }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "requires": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "requires": { + "jake": "^10.8.5" + } + }, "electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -9770,6 +25206,11 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, "end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9795,6 +25236,47 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true }, + "env-paths": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz", + "integrity": "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==", + "dev": true + }, + "env-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/env-string/-/env-string-1.0.1.tgz", + "integrity": "sha512-/DhCJDf5DSFK32joQiWRpWrT0h7p3hVQfMKxiBb7Nt8C8IF8BYyPtclDnuGGLOoj16d/8udKeiE7JbkotDmorQ==", + "dev": true + }, + "envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true + }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "requires": { + "stackframe": "^1.3.4" + } + }, "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -9804,8 +25286,7 @@ "es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, "es-object-atoms": { "version": "1.1.1", @@ -9867,11 +25348,15 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "eslint": { "version": "8.57.1", @@ -9989,6 +25474,12 @@ "eslint-visitor-keys": "^3.4.1" } }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, "esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -10019,6 +25510,31 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "requires": { + "bare-events": "^2.7.0" + } + }, + "exec-async": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", + "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", + "dev": true + }, "expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -10026,12 +25542,577 @@ "dev": true, "optional": true }, + "expo": { + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.12.tgz", + "integrity": "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==", + "requires": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "^56.1.16", + "@expo/config": "~56.0.9", + "@expo/config-plugins": "~56.0.9", + "@expo/devtools": "~56.0.2", + "@expo/dom-webview": "~56.0.5", + "@expo/fingerprint": "^0.19.4", + "@expo/local-build-cache-provider": "^56.0.8", + "@expo/log-box": "^56.0.13", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.14", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~56.0.15", + "expo-asset": "~56.0.17", + "expo-constants": "~56.0.18", + "expo-file-system": "~56.0.8", + "expo-font": "~56.0.7", + "expo-keep-awake": "~56.0.3", + "expo-modules-autolinking": "~56.0.16", + "expo-modules-core": "~56.0.17", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.2" + }, + "dependencies": { + "@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "requires": { + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "requires": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==" + }, + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==" + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "expo-modules-core": { + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz", + "integrity": "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==", + "requires": { + "@expo/expo-modules-macros-plugin": "0.2.2", + "expo-modules-jsi": "~56.0.10", + "invariant": "^2.2.4" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + } + } + }, + "expo-application": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz", + "integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==", + "requires": {} + }, + "expo-asset": { + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.17.tgz", + "integrity": "sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==", + "requires": { + "@expo/image-utils": "^0.10.1", + "expo-constants": "~56.0.18" + }, + "dependencies": { + "@expo/image-utils": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", + "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "requires": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + } + } + }, + "expo-camera": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz", + "integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==", + "requires": { + "barcode-detector": "^3.0.0" + } + }, + "expo-clipboard": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.4.tgz", + "integrity": "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==", + "requires": {} + }, + "expo-constants": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", + "integrity": "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==", + "requires": { + "@expo/env": "~2.3.0" + }, + "dependencies": { + "@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "requires": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + } + } + }, + "expo-document-picker": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-56.0.4.tgz", + "integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==", + "requires": {} + }, + "expo-file-system": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz", + "integrity": "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==", + "requires": {} + }, + "expo-font": { + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", + "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", + "requires": { + "fontfaceobserver": "^2.1.0" + } + }, + "expo-haptics": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-56.0.3.tgz", + "integrity": "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw==", + "requires": {} + }, + "expo-image-loader": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz", + "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==", + "requires": {} + }, + "expo-image-picker": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.18.tgz", + "integrity": "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==", + "requires": { + "expo-image-loader": "~56.0.3" + } + }, + "expo-keep-awake": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", + "integrity": "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==", + "requires": {} + }, + "expo-linking": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.14.tgz", + "integrity": "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==", + "requires": { + "expo-constants": "~56.0.18", + "invariant": "^2.2.4" + } + }, + "expo-modules-autolinking": { + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz", + "integrity": "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==", + "requires": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "dependencies": { + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + } + } + }, + "expo-modules-jsi": { + "version": "56.0.10", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.10.tgz", + "integrity": "sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==", + "requires": {} + }, + "expo-network": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-56.0.5.tgz", + "integrity": "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==", + "requires": {} + }, + "expo-notifications": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.18.tgz", + "integrity": "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==", + "requires": { + "@expo/image-utils": "^0.10.1", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~56.0.3", + "expo-constants": "~56.0.18" + }, + "dependencies": { + "@expo/image-utils": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", + "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "requires": { + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + } + } + }, + "expo-server": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", + "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==" + }, + "expo-sharing": { + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.18.tgz", + "integrity": "sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ==", + "requires": { + "@expo/config-plugins": "^56.0.9", + "@expo/config-types": "^56.0.6", + "@expo/plist": "^0.7.0" + }, + "dependencies": { + "@expo/config-plugins": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", + "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "requires": { + "@expo/config-types": "^56.0.6", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "@expo/config-types": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", + "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==" + }, + "@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "requires": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "requires": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + } + }, + "@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==" + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + } + } + }, + "expo-status-bar": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz", + "integrity": "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA==", + "requires": {} + }, + "exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -10083,6 +26164,12 @@ "fast-string-truncated-width": "^3.0.2" } }, + "fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true + }, "fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -10101,18 +26188,59 @@ "reusify": "^1.0.4" } }, + "fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==" + }, + "fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "requires": { + "bser": "2.1.1" + } + }, "fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "requires": {} }, + "fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==" + }, + "fetch-retry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", + "integrity": "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==", + "dev": true + }, "fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "dev": true }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + }, + "dependencies": { + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + } + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10129,15 +26257,72 @@ "dev": true, "optional": true }, + "filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "requires": { + "minimatch": "^5.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "requires": { "to-regex-range": "^5.0.1" } }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10165,6 +26350,34 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==" + }, + "fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==" + }, + "foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "dependencies": { + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + } + } + }, "form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -10194,6 +26407,11 @@ "tslib": "^2.4.0" } }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -10201,6 +26419,25 @@ "dev": true, "optional": true }, + "fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + } + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -10216,8 +26453,7 @@ "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "gensync": { "version": "1.0.0-beta.2", @@ -10253,6 +26489,12 @@ "math-intrinsics": "^1.1.0" } }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, "get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -10272,6 +26514,12 @@ "resolve-pkg-maps": "^1.0.0" } }, + "getenv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", + "integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==", + "dev": true + }, "github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -10325,6 +26573,12 @@ "slash": "^3.0.0" } }, + "golden-fleece": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/golden-fleece/-/golden-fleece-1.0.9.tgz", + "integrity": "sha512-YSwLaGMOgSBx9roJlNLL12c+FRiw7VECphinc6mGucphc/ZxTHgdEz6gmJqH6NOzYEd/yr64hwjom5pZ+tJVpg==", + "dev": true + }, "gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10336,17 +26590,40 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "gradle-to-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/gradle-to-js/-/gradle-to-js-2.0.1.tgz", + "integrity": "sha512-is3hDn9zb8XXnjbEeAEIqxTpLHUiGBqjegLmXPuyMBfKAggpadWFku4/AP8iYAGBX6qR9/5UIUIp47V0XI3aMw==", + "dev": true, + "requires": { + "lodash.merge": "^4.6.2" + } + }, "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "dev": true + }, + "graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "has-symbols": { "version": "1.1.0", @@ -10364,14 +26641,46 @@ } }, "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "requires": { "function-bind": "^1.1.2" } }, + "hermes-compiler": { + "version": "250829098.0.14", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz", + "integrity": "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==" + }, + "hermes-estree": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", + "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==" + }, + "hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", + "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", + "requires": { + "hermes-estree": "0.33.3" + } + }, + "hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "requires": { + "lru-cache": "^10.0.1" + }, + "dependencies": { + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + } + } + }, "html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -10387,6 +26696,39 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "http-call": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz", + "integrity": "sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==", + "dev": true, + "requires": { + "content-type": "^1.0.4", + "debug": "^4.1.1", + "is-retry-allowed": "^1.1.0", + "is-stream": "^2.0.0", + "parse-json": "^4.0.0", + "tunnel-agent": "^0.6.0" + } + }, + "http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "requires": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "dependencies": { + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + } + } + }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -10408,6 +26750,12 @@ "debug": "4" } }, + "hyperlinker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hyperlinker/-/hyperlinker-1.0.0.tgz", + "integrity": "sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==", + "dev": true + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -10418,8 +26766,15 @@ "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" + }, + "image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "requires": { + "queue": "6.0.2" + } }, "immediate": { "version": "3.0.6", @@ -10442,6 +26797,12 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -10464,6 +26825,39 @@ "dev": true, "optional": true }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "requires": { + "hasown": "^2.0.3" + } + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10484,11 +26878,16 @@ "is-extglob": "^2.1.1" } }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, "is-path-inside": { "version": "3.0.3", @@ -10502,6 +26901,32 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "is-retry-allowed": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", + "dev": true + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -10510,8 +26935,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "istanbul-lib-coverage": { "version": "3.2.2", @@ -10551,11 +26975,133 @@ "istanbul-lib-report": "^3.0.0" } }, + "jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "requires": { + "@isaacs/cliui": "^8.0.2", + "@pkgjs/parseargs": "^0.11.0" + } + }, + "jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "requires": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + } + }, + "jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==" + }, + "jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "dependencies": { + "picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" + } + } + }, + "jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "requires": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "dependencies": { + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + } + } + }, + "jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "requires": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==" + }, "jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==" }, + "jks-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jks-js/-/jks-js-1.1.0.tgz", + "integrity": "sha512-irWi8S2V029Vic63w0/TYa8NIZwXu9oeMtHQsX51JDIVBo0lrEaOoyM8ALEEh5PVKD6TrA26FixQK6TzT7dHqA==", + "dev": true, + "requires": { + "node-forge": "^1.3.1", + "node-int64": "^0.4.0", + "node-rsa": "^1.1.1" + } + }, + "joi": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", + "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "join-component": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz", + "integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10565,11 +27111,15 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "requires": { "argparse": "^2.0.1" } }, + "jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==" + }, "jsdom": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", @@ -10601,6 +27151,12 @@ "xml-name-validator": "^4.0.0" } }, + "jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true + }, "jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10612,6 +27168,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -10635,6 +27197,24 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, + "jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + } + } + }, "jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -10675,6 +27255,12 @@ } } }, + "keychain": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/keychain/-/keychain-1.5.0.tgz", + "integrity": "sha512-liyp4r+93RI7EB2jhwaRd4MWfdgHH6shuldkaPMkELCJjMFvOOVXuTvw1pGqFfhsrgA6OqfykWWPQgBjQakVag==", + "dev": true + }, "keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10684,6 +27270,21 @@ "json-buffer": "3.0.1" } }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" + }, + "lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==" + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10702,6 +27303,30 @@ "immediate": "~3.0.5" } }, + "lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "requires": { + "debug": "^2.6.9", + "marky": "^1.2.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -10787,6 +27412,18 @@ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "optional": true }, + "lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, "local-pkg": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", @@ -10802,12 +27439,52 @@ "p-locate": "^5.0.0" } }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -10863,12 +27540,52 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "requires": { + "tmpl": "1.0.5" + } + }, + "marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==" + }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10881,11 +27598,370 @@ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", "dev": true }, + "metro": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", + "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "dependencies": { + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==" + }, + "hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "requires": { + "hermes-estree": "0.35.0" + } + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "requires": {} + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + } + } + }, + "metro-babel-transformer": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", + "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", + "requires": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.4", + "nullthrows": "^1.1.1" + }, + "dependencies": { + "hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==" + }, + "hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "requires": { + "hermes-estree": "0.35.0" + } + } + } + }, + "metro-cache": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", + "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", + "requires": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.4" + }, + "dependencies": { + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + } + } + }, + "metro-cache-key": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", + "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", + "requires": { + "flow-enums-runtime": "^0.0.6" + } + }, + "metro-config": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", + "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", + "requires": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.4", + "metro-cache": "0.84.4", + "metro-core": "0.84.4", + "metro-runtime": "0.84.4", + "yaml": "^2.6.1" + }, + "dependencies": { + "yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==" + } + } + }, + "metro-core": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", + "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", + "requires": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.4" + } + }, + "metro-file-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", + "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", + "requires": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + } + }, + "metro-minify-terser": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", + "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", + "requires": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + } + }, + "metro-resolver": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", + "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", + "requires": { + "flow-enums-runtime": "^0.0.6" + } + }, + "metro-runtime": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", + "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", + "requires": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + } + }, + "metro-source-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", + "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", + "requires": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.4", + "nullthrows": "^1.1.1", + "ob1": "0.84.4", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + } + } + }, + "metro-symbolicate": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", + "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", + "requires": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.4", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + } + } + }, + "metro-transform-plugins": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", + "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", + "requires": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + } + }, + "metro-transform-worker": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", + "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", + "requires": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-source-map": "0.84.4", + "metro-transform-plugins": "0.84.4", + "nullthrows": "^1.1.1" + } + }, "micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "requires": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -10894,26 +27970,35 @@ "picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" } } }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + }, "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "requires": { "mime-db": "1.52.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -10937,6 +28022,85 @@ "dev": true, "optional": true }, + "minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "dev": true, + "requires": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "dependencies": { + "brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.2" + } + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + }, + "rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "requires": { + "glob": "^10.3.7" + } + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, "mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -10964,6 +28128,13 @@ } } }, + "moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "optional": true + }, "motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", @@ -10991,10 +28162,93 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "dev": true + }, + "multitars": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", + "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==" + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "dev": true, + "optional": true, + "requires": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "dev": true, + "optional": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "optional": true, + "requires": { + "minimist": "^1.2.6" + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "dev": true, + "optional": true, + "requires": { + "glob": "^6.0.1" + } + } + } + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "dev": true, + "optional": true + }, "nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==" }, "napi-build-utils": { "version": "2.0.0", @@ -11009,6 +28263,24 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "dev": true + }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "dev": true, + "optional": true + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, "node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -11028,23 +28300,138 @@ } } }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" + }, "node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "dev": true, + "requires": { + "asn1": "^0.2.4" + } + }, "node-sqlite3-wasm": { "version": "0.8.57", "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.57.tgz", "integrity": "sha512-9sME3Agp6vqevHVgMvCV4PMsoTHjuwxjhiooNMiMjjPO3Ea3QbmyAbZn2H9Ko1rkTi2Oo8skv9Y3HvS+rSMcMA==", "dev": true }, + "node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "dev": true + }, + "npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "requires": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "dependencies": { + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + } + } + }, + "nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" + }, "nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true }, + "ob1": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", + "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", + "requires": { + "flow-enums-runtime": "^0.0.6" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, + "object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11054,6 +28441,26 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "requires": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + } + }, "optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11068,6 +28475,22 @@ "word-wrap": "^1.2.5" } }, + "ora": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.1.0.tgz", + "integrity": "sha512-9tXIMPvjZ7hPTbk8DFq1f7Kow/HU/pQYB60JbNq+QnGwcyhWVZaQ4hM9zQDEsPxw/muLpgiHSaumUZxCAmod/w==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.4.0", + "is-interactive": "^1.0.0", + "log-symbols": "^4.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + } + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11091,6 +28514,12 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, + "package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -11105,6 +28534,31 @@ "callsites": "^3.0.0" } }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "requires": { + "pngjs": "^3.3.0" + }, + "dependencies": { + "pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==" + } + } + }, "parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -11114,6 +28568,21 @@ "entities": "^6.0.0" } }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "password-prompt": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.3.tgz", + "integrity": "sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==", + "dev": true, + "requires": { + "ansi-escapes": "^4.3.2", + "cross-spawn": "^7.0.3" + } + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11128,8 +28597,28 @@ "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "dependencies": { + "lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==" + } + } }, "path-type": { "version": "4.0.0", @@ -11155,9 +28644,63 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==" + }, + "pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } + } }, "pkg-types": { "version": "1.3.1", @@ -11178,17 +28721,39 @@ } } }, + "plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "requires": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "dependencies": { + "@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==" + }, + "xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" + } + } + }, "pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" }, "postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "requires": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } @@ -11236,7 +28801,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, "requires": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -11246,16 +28810,58 @@ "ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" } } }, + "proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==" + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, + "promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "requires": { + "asap": "~2.0.6" + } + }, + "promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "dev": true + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, "psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -11292,18 +28898,37 @@ "yargs": "^15.3.1" } }, + "qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "dev": true + }, "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "requires": { + "inherits": "~2.0.3" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -11331,6 +28956,23 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" }, + "react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "requires": { + "shell-quote": "^1.6.1", + "ws": "^7" + }, + "dependencies": { + "ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "requires": {} + } + } + }, "react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -11342,8 +28984,168 @@ "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "react-native": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", + "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", + "requires": { + "@react-native/assets-registry": "0.86.0", + "@react-native/codegen": "0.86.0", + "@react-native/community-cli-plugin": "0.86.0", + "@react-native/gradle-plugin": "0.86.0", + "@react-native/js-polyfills": "0.86.0", + "@react-native/normalize-colors": "0.86.0", + "@react-native/virtualized-lists": "0.86.0", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.14", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "dependencies": { + "@react-native/codegen": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.0.tgz", + "integrity": "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==", + "requires": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + } + }, + "@react-native/normalize-colors": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.0.tgz", + "integrity": "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g==" + }, + "babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "requires": { + "hermes-parser": "0.36.0" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==" + }, + "hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==" + }, + "hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "requires": { + "hermes-estree": "0.36.0" + } + }, + "react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "requires": {} + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + } + } + }, + "react-native-safe-area-context": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", + "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", + "requires": {} + }, + "react-native-webview": { + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", + "requires": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + } }, "react-refresh": { "version": "0.18.0", @@ -11362,11 +29164,76 @@ "util-deprecate": "^1.0.1" } }, + "redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "requires": { + "esprima": "~4.0.0" + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + }, + "regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "requires": { + "regenerate": "^1.4.2" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "requires": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + } + }, + "regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==" + }, + "regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "requires": { + "jsesc": "~3.1.0" + } + }, + "remove-trailing-slash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz", + "integrity": "sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==", + "dev": true + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -11378,6 +29245,17 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "requires": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -11390,6 +29268,27 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "devOptional": true }, + "resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==" + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true + }, "reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11457,7 +29356,12 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", "dev": true, "optional": true }, @@ -11467,6 +29371,11 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==" + }, "saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -11486,21 +29395,114 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" }, + "send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + } + } + }, + "serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==" + }, + "serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "requires": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "dependencies": { + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + } + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "set-interval-async": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/set-interval-async/-/set-interval-async-3.0.3.tgz", + "integrity": "sha512-o4DyBv6mko+A9cH3QKek4SAAT5UyJRkfdTi6JHii6ZCKUYFun8SwgBmQrOXd158JOwBQzA+BnO8BvT64xuCaSw==", + "dev": true + }, "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "requires": { "shebang-regex": "^3.0.0" } @@ -11508,8 +29510,12 @@ "shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==" }, "siginfo": { "version": "2.0.0", @@ -11517,6 +29523,11 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, "simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -11536,11 +29547,30 @@ "simple-concat": "^1.0.0" } }, + "simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "requires": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + }, + "dependencies": { + "bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "requires": { + "big-integer": "1.6.x" + } + } + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" }, "slash": { "version": "3.0.0", @@ -11548,29 +29578,100 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==" + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, "source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "requires": { + "type-fest": "^0.7.1" + }, + "dependencies": { + "type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==" + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + }, "std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==" + }, + "streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "requires": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -11591,6 +29692,17 @@ "strip-ansi": "^6.0.1" } }, + "string-width-cjs": { + "version": "npm:string-width@4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11599,6 +29711,15 @@ "ansi-regex": "^5.0.1" } }, + "strip-ansi-cjs": { + "version": "npm:strip-ansi@6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -11614,21 +29735,115 @@ "acorn": "^8.10.0" } }, + "structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==" + }, + "sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "dependencies": { + "brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.2" + } + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + } + } + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "requires": { "has-flag": "^4.0.0" } }, + "supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "requires": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==" + }, "tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -11639,6 +29854,42 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==" }, + "tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "dev": true, + "requires": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "dependencies": { + "chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true + }, + "minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "requires": { + "minipass": "^7.1.2" + } + }, + "yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true + } + } + }, "tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -11666,6 +29917,39 @@ "readable-stream": "^3.1.1" } }, + "temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true + }, + "terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "requires": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + } + }, + "terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + } + } + }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -11677,17 +29961,49 @@ "minimatch": "^3.0.4" } }, + "text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "requires": { + "b4a": "^1.6.4" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, "three": { "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==" }, + "throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" + }, "tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11695,12 +30011,12 @@ "dev": true }, "tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "requires": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" } }, "tinypool": { @@ -11715,15 +30031,29 @@ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true }, + "tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "requires": { "is-number": "^7.0.0" } }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==" + }, "tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -11761,6 +30091,53 @@ "dev": true, "requires": {} }, + "ts-deepmerge": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", + "integrity": "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==", + "dev": true + }, + "ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "dependencies": { + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true + } + } + }, "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11782,11 +30159,19 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.0.1" } }, + "turndown": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.1.2.tgz", + "integrity": "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg==", + "dev": true, + "requires": { + "domino": "^2.1.6" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11812,7 +30197,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true + "devOptional": true }, "ufo": { "version": "1.6.3", @@ -11823,8 +30208,40 @@ "undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==" + }, + "unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==" + }, + "unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==" + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "requires": { + "crypto-random-string": "^2.0.0" + } }, "universalify": { "version": "0.2.0", @@ -11832,6 +30249,17 @@ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "dev": true }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true + }, "update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -11865,6 +30293,23 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true + }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -11876,6 +30321,16 @@ "convert-source-map": "^2.0.0" } }, + "validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, "vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -12541,6 +30996,11 @@ } } }, + "vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==" + }, "w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -12550,6 +31010,22 @@ "xml-name-validator": "^4.0.0" } }, + "walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "requires": { + "makeerror": "1.0.12" + } + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "requires": { + "defaults": "^1.0.3" + } + }, "web-tree-sitter": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", @@ -12583,6 +31059,11 @@ } } }, + "whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, "whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -12599,11 +31080,15 @@ "webidl-conversions": "^7.0.0" } }, + "whatwg-url-minimum": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", + "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==" + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -12623,12 +31108,33 @@ "stackback": "0.0.2" } }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "requires": { + "string-width": "^4.0.0" + } + }, + "wonka": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "dev": true + }, "word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -12639,25 +31145,84 @@ "strip-ansi": "^6.0.0" } }, + "wrap-ansi-cjs": { + "version": "npm:wrap-ansi@7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "write-file-atomic": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", + "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, "ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, "requires": {} }, + "xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "requires": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "dependencies": { + "uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==" + } + } + }, "xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", "dev": true }, + "xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "dependencies": { + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + } + } + }, + "xmlbuilder": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", + "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", + "dev": true + }, "xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -12674,6 +31239,12 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "devOptional": true + }, "yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", @@ -12736,11 +31307,42 @@ "decamelize": "^1.2.0" } }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true + }, + "zxing-wasm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz", + "integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==", + "requires": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.7.0" + }, + "dependencies": { + "type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "requires": { + "tagged-tag": "^1.0.0" + } + } + } } } } diff --git a/package.json b/package.json index fa497d129..6d15185b0 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "container:config": "node scripts/container-compose.mjs config", "container:k6": "node scripts/container-compose.mjs k6", "container:worker-smoke": "node scripts/container-worker-smoke.mjs", - "check": "npm run lint && npm run test && npm run build && npm run check:content", + "check": "npm run lint && npm run test && npm run build && npm run check:content && npm run check:native-shells", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts", @@ -102,24 +102,56 @@ "codegraph:status": "codegraph status .", "rag:index": "node scripts/rag/index-docs.mjs", "rag:search": "node scripts/rag/search-docs.mjs", - "database:backup:oss": "node scripts/database-backup-to-oss.mjs" + "database:backup:oss": "node scripts/database-backup-to-oss.mjs", + "mobile-shell:dev": "npm --prefix apps/mobile-shell run dev", + "mobile-shell:typecheck": "npm --prefix apps/mobile-shell run typecheck", + "mobile-shell:test": "npm --prefix apps/mobile-shell run test", + "mobile-shell:build-config": "npm --prefix apps/mobile-shell run build-config:smoke", + "mobile-shell:build:android": "npm --prefix apps/mobile-shell run build:android", + "mobile-shell:build:ios": "npm --prefix apps/mobile-shell run build:ios", + "mobile-shell:build-artifacts": "npm --prefix apps/mobile-shell run build-artifacts:smoke", + "mobile-shell:config": "npm --prefix apps/mobile-shell run config:smoke", + "mobile-shell:export": "npm --prefix apps/mobile-shell run export:smoke", + "desktop-shell:dev": "npm --prefix apps/desktop-shell run dev", + "desktop-shell:build": "npm --prefix apps/desktop-shell run build --", + "desktop-shell:stage-release-binary": "npm --prefix apps/desktop-shell run stage-release-binary", + "desktop-shell:typecheck": "npm --prefix apps/desktop-shell run typecheck", + "desktop-shell:test": "cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml", + "check:native-shells": "node scripts/check-native-shells.mjs" }, "dependencies": { + "@expo/metro-runtime": "^56.0.15", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "cannon-es": "^0.20.0", "dotenv": "^17.2.3", + "expo": "^56.0.12", + "expo-camera": "56.0.8", + "expo-clipboard": "^56.0.4", + "expo-document-picker": "^56.0.4", + "expo-file-system": "^56.0.8", + "expo-haptics": "^56.0.3", + "expo-image-picker": "^56.0.18", + "expo-linking": "^56.0.14", + "expo-network": "^56.0.5", + "expo-notifications": "^56.0.18", + "expo-sharing": "^56.0.18", + "expo-status-bar": "^56.0.4", "jszip": "^3.10.1", "lucide-react": "^0.546.0", "motion": "^12.23.24", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-native": "^0.86.0", + "react-native-safe-area-context": "^5.8.0", + "react-native-webview": "^13.16.1", "three": "^0.184.0", "vite": "^6.2.0" }, "devDependencies": { "@colbymchenry/codegraph": "^0.8.0", + "@tauri-apps/cli": "^2.11.2", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.0", @@ -131,6 +163,7 @@ "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-v8": "^0.34.6", "autoprefixer": "^10.4.21", + "eas-cli": "^20.3.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react-hooks": "^4.6.2", diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts new file mode 100644 index 000000000..1b79fb6dc --- /dev/null +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -0,0 +1,647 @@ +import { describe, expect, test } from 'vitest'; + +import { + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES, + HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEYS, + HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS, + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, + HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES, + HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES, + HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, + HOST_BRIDGE_TAURI_COMMAND, + HOST_BRIDGE_CAPABILITIES, + HOST_BRIDGE_DOCUMENT_MIME_TYPES, + HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS, + HOST_BRIDGE_EVENTS, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, + HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, + HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS, + HOST_BRIDGE_RESPONSE_CACHE_MAX, + HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, + HOST_BRIDGE_SCANNER_TIMEOUT_MS, + HOST_BRIDGE_TEXT_MIME_TYPES, + HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, + isHostBridgeMethod, + isHostBridgeCapability, + isHostBridgeEventName, + normalizeHostBridgeAppTitle, + normalizeHostBridgeBadgeCount, + normalizeHostBridgeClipboardText, + normalizeHostBridgeColorScheme, + normalizeHostBridgeConnectionType, + normalizeHostBridgeExportAudioPayload, + normalizeHostBridgeExportFileName, + normalizeHostBridgeExportImagePayload, + normalizeHostBridgeExportTextPayload, + normalizeHostBridgeExternalUrl, + normalizeHostBridgeExternalUrlPayload, + normalizeHostBridgeHapticsImpactStyle, + normalizeHostBridgeImportAudioResult, + normalizeHostBridgeImportDocumentResult, + normalizeHostBridgeImportFileName, + normalizeHostBridgeImportImageResult, + normalizeHostBridgeImportTextResult, + normalizeHostBridgeLifecycleState, + normalizeHostBridgeLocalNotification, + normalizeHostBridgeQrCodeValue, + normalizeHostBridgeRequestId, + normalizeHostBridgeShareOpenPayload, +} from './hostBridge'; + +describe('HostBridge shared contract helpers', () => { + test('固定公开 H5 主站入口', () => { + expect(HOST_BRIDGE_PUBLIC_WEB_ORIGIN).toBe('https://www.genarrative.world'); + expect(HOST_BRIDGE_PUBLIC_WEB_URL).toBe('https://www.genarrative.world/'); + }); + + test('固定 Tauri 只暴露唯一 HostBridge command', () => { + expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request'); + }); + + test('固定移动壳 WebView 下载协议阻断清单', () => { + expect(HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS).toEqual([ + 'blob:', + 'data:', + 'file:', + 'filesystem:', + ]); + }); + + test('固定移动壳本地通知频道', () => { + expect(HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID).toBe( + 'genarrative-local', + ); + }); + + test('固定原生壳请求超时边界', () => { + expect(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS).toBe(8000); + expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000); + expect(HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS).toBe(3000); + expect(HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS).toBe(30000); + expect(HOST_BRIDGE_SCANNER_TIMEOUT_MS).toBe(60000); + expect(HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS).toBe(1200); + }); + + test('固定宿主侧响应回放缓存边界', () => { + expect(HOST_BRIDGE_RESPONSE_CACHE_MAX).toBe(128); + }); + + test('固定宿主上下文 query 契约', () => { + expect(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY).toEqual({ + clientRuntime: 'clientRuntime', + clientType: 'clientType', + miniProgramEnv: 'miniProgramEnv', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', + }); + expect(HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS).toEqual([ + 'clientType', + 'clientRuntime', + 'miniProgramEnv', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', + ]); + expect(HOST_BRIDGE_NATIVE_APP_QUERY_KEY).toEqual({ + clientRuntime: 'clientRuntime', + clientType: 'clientType', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', + }); + expect(HOST_BRIDGE_NATIVE_APP_QUERY_KEYS).toEqual([ + 'clientRuntime', + 'clientType', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', + ]); + expect(HOST_BRIDGE_NATIVE_APP_QUERY).toEqual({ + clientRuntime: 'native_app', + clientType: 'native_app', + hostShellExpoMobile: 'expo_mobile', + hostShellTauriDesktop: 'tauri_desktop', + }); + expect(HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY).toEqual({ + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }); + }); + + test('只允许明确的外链协议交给宿主打开', () => { + expect(normalizeHostBridgeExternalUrl(' https://example.com/a ')).toBe( + 'https://example.com/a', + ); + expect(normalizeHostBridgeExternalUrl('mailto:hi@example.com')).toBe( + 'mailto:hi@example.com', + ); + expect(normalizeHostBridgeExternalUrl('tel:+12345678')).toBe( + 'tel:+12345678', + ); + }); + + test('拒绝空值、控制字符和危险协议', () => { + expect(normalizeHostBridgeExternalUrl('')).toBeNull(); + expect(normalizeHostBridgeExternalUrl('javascript:alert(1)')).toBeNull(); + expect(normalizeHostBridgeExternalUrl('file:///etc/passwd')).toBeNull(); + expect( + normalizeHostBridgeExternalUrl('https://example.com/\nnext'), + ).toBeNull(); + expect(normalizeHostBridgeExternalUrl('/relative/path')).toBeNull(); + }); + + test('归一化宿主导出文件名', () => { + expect(normalizeHostBridgeExportFileName(' 作品:记录?.txt ')).toBe( + '作品-记录-.txt', + ); + expect(normalizeHostBridgeExportFileName('../secret.txt')).toBe( + 'secret.txt', + ); + expect(normalizeHostBridgeExportFileName('')).toBe( + 'genarrative-export.txt', + ); + expect(normalizeHostBridgeExportFileName('a'.repeat(140))).toHaveLength( + 120, + ); + }); + + test('识别 HostBridge 能力白名单', () => { + expect(isHostBridgeMethod('host.getRuntime')).toBe(true); + expect(isHostBridgeMethod('share.open')).toBe(true); + expect(isHostBridgeMethod('app.lifecycle')).toBe(false); + expect(isHostBridgeMethod('unknown.method')).toBe(false); + expect(isHostBridgeCapability('appearance.getColorScheme')).toBe(true); + expect(isHostBridgeCapability('share.open')).toBe(true); + expect(isHostBridgeCapability('app.reloadWebView')).toBe(true); + expect(isHostBridgeCapability('app.lifecycle')).toBe(true); + expect(isHostBridgeCapability('network.status')).toBe(true); + expect(isHostBridgeCapability('network.statusChanged')).toBe(true); + expect(isHostBridgeCapability('clipboard.readText')).toBe(true); + expect(isHostBridgeCapability('file.importText')).toBe(true); + expect(isHostBridgeCapability('file.importDocument')).toBe(true); + expect(isHostBridgeCapability('file.importImage')).toBe(true); + expect(isHostBridgeCapability('file.captureImage')).toBe(true); + expect(isHostBridgeCapability('scanner.scanQrCode')).toBe(true); + expect(isHostBridgeCapability('file.importAudio')).toBe(true); + expect(isHostBridgeCapability('file.exportAudio')).toBe(true); + expect(isHostBridgeCapability('file.imageDropped')).toBe(true); + expect(isHostBridgeCapability('app.setBadgeCount')).toBe(true); + expect(isHostBridgeCapability('notification.showLocal')).toBe(true); + expect(isHostBridgeCapability('navigation.canGoBack')).toBe(true); + expect(isHostBridgeCapability('unknown.capability')).toBe(false); + expect(isHostBridgeCapability(null)).toBe(false); + }); + + test('识别 HostBridge 事件白名单', () => { + expect(HOST_BRIDGE_EVENTS).toEqual([ + 'app.lifecycle', + 'network.statusChanged', + 'navigation.canGoBack', + 'file.imageDropped', + ]); + for (const eventName of HOST_BRIDGE_EVENTS) { + expect(isHostBridgeEventName(eventName)).toBe(true); + expect(isHostBridgeCapability(eventName)).toBe(true); + expect(isHostBridgeMethod(eventName)).toBe(false); + } + expect(isHostBridgeEventName('host.getRuntime')).toBe(false); + expect(isHostBridgeEventName('unknown.event')).toBe(false); + expect(isHostBridgeEventName(null)).toBe(false); + }); + + test('宿主壳 capability profile 来自共享白名单', () => { + const profiles = [ + HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES, + ]; + for (const profile of profiles) { + expect(profile.every((capability) => + HOST_BRIDGE_CAPABILITIES.includes(capability), + )).toBe(true); + } + expect(HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES).toEqual([ + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', + 'navigation.openNativePage', + ]); + for (const nativeAppProfile of [ + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES, + ]) { + expect(nativeAppProfile).not.toContain('auth.requestLogin'); + expect(nativeAppProfile).not.toContain('payment.request'); + } + }); + + test('移动和桌面 profile 保留真实平台差异', () => { + expect(HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES).toEqual([ + 'app.setBadgeCount', + ]); + expect(HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES).toEqual([ + ...HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + ...HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES, + ]); + expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain( + 'file.captureImage', + ); + expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain( + 'file.importDocument', + ); + expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).toContain( + 'file.importDocument', + ); + expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain( + 'scanner.scanQrCode', + ); + expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).toContain('app.setTitle'); + expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).toContain( + 'file.imageDropped', + ); + expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).not.toContain( + 'file.captureImage', + ); + expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).not.toContain( + 'scanner.scanQrCode', + ); + expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).not.toContain( + 'haptics.impact', + ); + }); + + test('归一化 HostBridge request id', () => { + expect(normalizeHostBridgeRequestId(' request-1 ')).toBe('request-1'); + expect(normalizeHostBridgeRequestId('')).toBeNull(); + expect(normalizeHostBridgeRequestId('request\n1')).toBeNull(); + expect(normalizeHostBridgeRequestId('a'.repeat(121))).toBeNull(); + expect(normalizeHostBridgeRequestId(null)).toBeNull(); + }); + + test('归一化宿主外链打开载荷', () => { + expect( + normalizeHostBridgeExternalUrlPayload(' https://example.com/path '), + ).toEqual({ + url: 'https://example.com/path', + }); + expect(normalizeHostBridgeExternalUrlPayload('mailto:hi@example.com')).toEqual({ + url: 'mailto:hi@example.com', + }); + expect(normalizeHostBridgeExternalUrlPayload('')).toBeNull(); + expect(normalizeHostBridgeExternalUrlPayload('bad\nurl')).toBeNull(); + expect( + normalizeHostBridgeExternalUrlPayload('javascript:alert(1)'), + ).toBeNull(); + expect(normalizeHostBridgeExternalUrlPayload(null)).toBeNull(); + }); + + test('归一化宿主剪贴板读取文本', () => { + expect(normalizeHostBridgeClipboardText('作品号 PZ-1')).toEqual({ + text: '作品号 PZ-1', + }); + expect(normalizeHostBridgeClipboardText('a'.repeat(100010))).toEqual({ + text: 'a'.repeat(100000), + }); + expect(normalizeHostBridgeClipboardText(null)).toBeNull(); + }); + + test('归一化宿主窗口标题', () => { + expect(normalizeHostBridgeAppTitle(' 拼图 - 陶泥儿 ')).toEqual({ + title: '拼图 - 陶泥儿', + }); + expect(normalizeHostBridgeAppTitle('')).toBeNull(); + expect(normalizeHostBridgeAppTitle('拼图\n陶泥儿')).toBeNull(); + expect(normalizeHostBridgeAppTitle('a'.repeat(90))).toEqual({ + title: 'a'.repeat(80), + }); + }); + + test('归一化宿主二维码扫码结果', () => { + expect(normalizeHostBridgeQrCodeValue(' https://example.com/a ')).toEqual({ + value: 'https://example.com/a', + format: 'qr_code', + }); + expect(normalizeHostBridgeQrCodeValue('')).toBeNull(); + expect(normalizeHostBridgeQrCodeValue('bad\nvalue')).toBeNull(); + expect(normalizeHostBridgeQrCodeValue(null)).toBeNull(); + expect(normalizeHostBridgeQrCodeValue('a'.repeat(4100))).toEqual({ + value: 'a'.repeat(4096), + format: 'qr_code', + }); + }); + + test('文档导入契约包含文本和 DOCX 边界', () => { + expect(HOST_BRIDGE_DOCUMENT_MIME_TYPES).toEqual([ + ...HOST_BRIDGE_TEXT_MIME_TYPES, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ]); + expect(HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES).toBe(5 * 1024 * 1024); + }); + + test('归一化宿主文本、图片和音频导出载荷', () => { + expect( + normalizeHostBridgeExportTextPayload({ + fileName: ' ../作品:记录?.md ', + content: 'content', + mimeType: 'text/markdown', + }), + ).toEqual({ + fileName: '作品-记录-.md', + content: 'content', + mimeType: 'text/markdown', + }); + expect( + normalizeHostBridgeExportTextPayload({ + fileName: '作品记录.txt', + content: 'content', + }), + ).toEqual({ + fileName: '作品记录.txt', + content: 'content', + }); + expect( + normalizeHostBridgeExportImagePayload({ + fileName: ' ../分享:卡?.png ', + base64Data: ' c2hhcmU= ', + mimeType: 'image/png', + }), + ).toEqual({ + fileName: '分享-卡-.png', + base64Data: 'c2hhcmU=', + mimeType: 'image/png', + }); + expect( + normalizeHostBridgeExportAudioPayload({ + fileName: '敲击音效.wav', + base64Data: ' YXVkaW8= ', + mimeType: 'audio/wav', + }), + ).toEqual({ + fileName: '敲击音效.wav', + base64Data: 'YXVkaW8=', + mimeType: 'audio/wav', + }); + expect( + normalizeHostBridgeExportTextPayload({ + fileName: 'bad.bin', + content: 'content', + mimeType: 'application/octet-stream', + }), + ).toBeNull(); + expect( + normalizeHostBridgeExportImagePayload({ + fileName: 'bad.gif', + base64Data: 'c2hhcmU=', + mimeType: 'image/gif', + }), + ).toBeNull(); + expect( + normalizeHostBridgeExportAudioPayload({ + fileName: 'empty.wav', + base64Data: ' ', + mimeType: 'audio/wav', + }), + ).toBeNull(); + }); + + test('归一化宿主文件导入结果', () => { + expect(normalizeHostBridgeImportFileName(' ../剧情:草稿?.md ')).toBe( + '剧情-草稿-.md', + ); + expect( + normalizeHostBridgeImportTextResult({ + action: 'selected', + fileName: ' ../剧情:草稿?.md ', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }), + ).toEqual({ + action: 'selected', + fileName: '剧情-草稿-.md', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }); + expect( + normalizeHostBridgeImportDocumentResult({ + action: 'selected', + fileName: ' 世界设定.docx ', + base64Data: ' UEsDBGRvY3g= ', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }), + ).toEqual({ + action: 'selected', + fileName: '世界设定.docx', + base64Data: 'UEsDBGRvY3g=', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }); + expect( + normalizeHostBridgeImportImageResult({ + action: 'dropped', + fileName: ' 参考图.png ', + base64Data: ' aW1hZ2U= ', + mimeType: 'image/png', + bytes: 5, + position: { x: 12, y: 24 }, + }), + ).toEqual({ + action: 'dropped', + fileName: '参考图.png', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + position: { x: 12, y: 24 }, + }); + expect( + normalizeHostBridgeImportAudioResult({ + action: 'selected', + fileName: ' 敲击音效.webm ', + base64Data: ' YXVkaW8= ', + mimeType: 'audio/webm', + bytes: 5, + }), + ).toEqual({ + action: 'selected', + fileName: '敲击音效.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 5, + }); + expect( + normalizeHostBridgeImportTextResult({ + action: 'selected', + fileName: 'bad.bin', + content: 'content', + mimeType: 'application/octet-stream', + bytes: 7, + }), + ).toBeNull(); + expect( + normalizeHostBridgeImportAudioResult({ + action: 'selected', + fileName: 'huge.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 20 * 1024 * 1024 + 1, + }), + ).toBeNull(); + }); + + test('归一化宿主本地通知内容', () => { + expect(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT).toEqual({ + action: 'delivered_to_system', + }); + expect( + normalizeHostBridgeLocalNotification({ + title: ' 生成完成 ', + body: ' 作品已准备好 可以试玩 ', + }), + ).toEqual({ + title: '生成完成', + body: '作品已准备好 可以试玩', + }); + expect( + normalizeHostBridgeLocalNotification({ + title: '生成完成', + body: '', + }), + ).toEqual({ + title: '生成完成', + }); + expect( + normalizeHostBridgeLocalNotification({ + title: 'a'.repeat(90), + body: 'b'.repeat(250), + }), + ).toEqual({ + title: 'a'.repeat(80), + body: 'b'.repeat(240), + }); + expect(normalizeHostBridgeLocalNotification({ title: '' })).toBeNull(); + expect( + normalizeHostBridgeLocalNotification({ title: '生成\n完成' }), + ).toBeNull(); + expect( + normalizeHostBridgeLocalNotification({ + title: '生成完成', + body: '坏\u0001内容', + }), + ).toBeNull(); + expect(normalizeHostBridgeLocalNotification(null)).toBeNull(); + }); + + test('归一化宿主分享载荷', () => { + expect( + normalizeHostBridgeShareOpenPayload({ + title: ' 暖灯猫街 ', + message: ' 来玩 ', + url: '/works/detail?work=PZ-1', + }), + ).toEqual({ + status: 'valid', + payload: { + title: '暖灯猫街', + message: '来玩', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', + }, + }); + expect( + normalizeHostBridgeShareOpenPayload({ + title: '作品', + work: 'PZ 1/二', + }), + ).toEqual({ + status: 'valid', + payload: { + title: '作品', + url: 'https://www.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C', + }, + }); + expect( + normalizeHostBridgeShareOpenPayload({ + title: '作品', + targetPath: '//www.genarrative.world/works/detail?work=PZ-1', + }), + ).toEqual({ status: 'invalid' }); + expect( + normalizeHostBridgeShareOpenPayload({ + title: '作品', + url: 'https://example.com/works/detail?work=PZ-1', + }), + ).toEqual({ status: 'invalid' }); + expect(normalizeHostBridgeShareOpenPayload({})).toEqual({ + status: 'empty', + }); + expect(normalizeHostBridgeShareOpenPayload(null)).toEqual({ + status: 'empty', + }); + }); + + test('归一化宿主触觉反馈强度', () => { + expect(normalizeHostBridgeHapticsImpactStyle(undefined)).toBe('light'); + expect(normalizeHostBridgeHapticsImpactStyle('light')).toBe('light'); + expect(normalizeHostBridgeHapticsImpactStyle('medium')).toBe('medium'); + expect(normalizeHostBridgeHapticsImpactStyle('heavy')).toBe('heavy'); + expect(normalizeHostBridgeHapticsImpactStyle('rigid')).toBeNull(); + expect(normalizeHostBridgeHapticsImpactStyle(null)).toBeNull(); + }); + + test('归一化宿主角标数量', () => { + expect(normalizeHostBridgeBadgeCount(0)).toBe(0); + expect(normalizeHostBridgeBadgeCount(12)).toBe(12); + expect(normalizeHostBridgeBadgeCount(99999)).toBe(99999); + expect(normalizeHostBridgeBadgeCount(-1)).toBeNull(); + expect(normalizeHostBridgeBadgeCount(1.5)).toBeNull(); + expect(normalizeHostBridgeBadgeCount(100000)).toBeNull(); + expect(normalizeHostBridgeBadgeCount('1')).toBeNull(); + }); + + test('归一化宿主配色模式', () => { + expect(normalizeHostBridgeColorScheme('light')).toBe('light'); + expect(normalizeHostBridgeColorScheme('dark')).toBe('dark'); + expect(normalizeHostBridgeColorScheme('unspecified')).toBe('unknown'); + expect(normalizeHostBridgeColorScheme(null)).toBe('unknown'); + }); + + test('归一化宿主生命周期状态', () => { + expect(normalizeHostBridgeLifecycleState('active')).toBe('active'); + expect(normalizeHostBridgeLifecycleState('background')).toBe('background'); + expect(normalizeHostBridgeLifecycleState('extension')).toBe('inactive'); + expect(normalizeHostBridgeLifecycleState(null)).toBe('inactive'); + }); + + test('归一化宿主网络连接类型', () => { + expect(normalizeHostBridgeConnectionType('WIFI')).toBe('wifi'); + expect(normalizeHostBridgeConnectionType('CELLULAR')).toBe('cellular'); + expect(normalizeHostBridgeConnectionType('ETHERNET')).toBe('ethernet'); + expect(normalizeHostBridgeConnectionType('NONE')).toBe('none'); + expect(normalizeHostBridgeConnectionType('satellite')).toBe('unknown'); + expect(normalizeHostBridgeConnectionType(null)).toBe('unknown'); + }); +}); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts new file mode 100644 index 000000000..e4ace5b92 --- /dev/null +++ b/packages/shared/src/contracts/hostBridge.ts @@ -0,0 +1,1207 @@ +export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge'; +export const HOST_BRIDGE_VERSION = 1; +export const HOST_BRIDGE_TAURI_COMMAND = 'host_bridge_request'; +export const HOST_BRIDGE_PUBLIC_WEB_ORIGIN = 'https://www.genarrative.world'; +export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://www.genarrative.world/'; + +export type HostShellKind = 'browser' | 'wechat_mini_program' | 'native_app'; + +export type NativeHostShell = 'expo_mobile' | 'tauri_desktop'; + +export type NativeHostPlatform = + | 'ios' + | 'android' + | 'macos' + | 'windows' + | 'linux' + | 'unknown'; + +export const HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY = { + clientRuntime: 'clientRuntime', + clientType: 'clientType', + miniProgramEnv: 'miniProgramEnv', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', +} as const; + +export const HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS = [ + 'clientType', + 'clientRuntime', + 'miniProgramEnv', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', +] as const; + +export const HOST_BRIDGE_NATIVE_APP_QUERY_KEY = { + clientRuntime: 'clientRuntime', + clientType: 'clientType', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', +} as const; + +export const HOST_BRIDGE_NATIVE_APP_QUERY_KEYS = [ + 'clientRuntime', + 'clientType', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', +] as const; + +export const HOST_BRIDGE_NATIVE_APP_QUERY = { + clientRuntime: 'native_app', + clientType: 'native_app', + hostShellExpoMobile: 'expo_mobile', + hostShellTauriDesktop: 'tauri_desktop', +} as const; + +export const HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY = { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', +} as const; + +export const HOST_BRIDGE_METHODS = [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', + 'navigation.openNativePage', + 'app.reloadWebView', + 'app.openExternalUrl', + 'app.setTitle', + 'app.setBadgeCount', + 'network.status', + 'clipboard.writeText', + 'clipboard.readText', + 'file.exportText', + 'file.importText', + 'file.importDocument', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'scanner.scanQrCode', + 'file.importAudio', + 'file.exportAudio', + 'haptics.impact', + 'notification.showLocal', +] as const; + +export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number]; + +export function isHostBridgeMethod(value: unknown): value is HostBridgeMethod { + return ( + typeof value === 'string' && + HOST_BRIDGE_METHODS.includes(value as HostBridgeMethod) + ); +} + +export const HOST_BRIDGE_CAPABILITIES = [ + ...HOST_BRIDGE_METHODS, + 'host.events', + 'app.lifecycle', + 'network.statusChanged', + 'file.imageDropped', + 'navigation.canGoBack', +] as const; + +export type HostBridgeCapability = (typeof HOST_BRIDGE_CAPABILITIES)[number]; + +export function isHostBridgeCapability( + value: unknown, +): value is HostBridgeCapability { + return ( + typeof value === 'string' && + HOST_BRIDGE_CAPABILITIES.includes(value as HostBridgeCapability) + ); +} + +export const HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES: readonly HostBridgeCapability[] = [ + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', + 'navigation.openNativePage', +]; + +export const HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES: readonly HostBridgeCapability[] = [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'host.events', + 'app.lifecycle', + 'share.open', + 'share.setTarget', + 'navigation.openNativePage', + 'navigation.canGoBack', + 'app.reloadWebView', + 'app.openExternalUrl', + 'network.status', + 'network.statusChanged', + 'clipboard.writeText', + 'clipboard.readText', + 'file.exportText', + 'file.importText', + 'file.importDocument', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'scanner.scanQrCode', + 'file.importAudio', + 'file.exportAudio', + 'haptics.impact', + 'notification.showLocal', +]; + +export const HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES: readonly HostBridgeCapability[] = [ + 'app.setBadgeCount', +]; + +export const HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES: readonly HostBridgeCapability[] = [ + ...HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + ...HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES, +]; + +export const HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES: readonly HostBridgeCapability[] = [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'host.events', + 'app.lifecycle', + 'share.open', + 'share.setTarget', + 'navigation.openNativePage', + 'navigation.canGoBack', + 'app.reloadWebView', + 'app.openExternalUrl', + 'app.setTitle', + 'app.setBadgeCount', + 'network.status', + 'clipboard.writeText', + 'clipboard.readText', + 'file.exportText', + 'file.importText', + 'file.importDocument', + 'file.exportImage', + 'file.importImage', + 'file.importAudio', + 'file.exportAudio', + 'file.imageDropped', + 'notification.showLocal', +]; + +export type HostBridgeRuntimeResult = { + shell: NativeHostShell; + platform: NativeHostPlatform; + hostVersion: string | null; + bridgeVersion: number; + capabilities: readonly HostBridgeCapability[]; +}; + +export type HostBridgeRequest = { + bridge: typeof HOST_BRIDGE_PROTOCOL; + version: typeof HOST_BRIDGE_VERSION; + id: string; + method: HostBridgeMethod; + payload?: Payload; + timeoutMs?: number; +}; + +export const HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS = 8000; +export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000; +export const HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS = 3000; +export const HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS = 30000; +export const HOST_BRIDGE_SCANNER_TIMEOUT_MS = 60000; +export const HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS = 1200; +export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; +export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; + +export function normalizeHostBridgeRequestId(rawId: unknown) { + if (typeof rawId !== 'string') { + return null; + } + + const id = rawId.trim(); + if ( + !id || + id.length > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH || + hasHostBridgeControlCharacter(id) + ) { + return null; + } + + return id; +} + +export type HostBridgeError = { + code: + | 'invalid_request' + | 'unsupported_method' + | 'unsupported_capability' + | 'timeout' + | 'cancelled' + | 'host_error'; + message: string; +}; + +export type HostBridgeResponse = { + bridge: typeof HOST_BRIDGE_PROTOCOL; + version: typeof HOST_BRIDGE_VERSION; + id: string; +} & ( + | { + ok: true; + result?: Result; + } + | { + ok: false; + error: HostBridgeError; + } +); + +export const HOST_BRIDGE_EVENTS = [ + 'app.lifecycle', + 'network.statusChanged', + 'navigation.canGoBack', + 'file.imageDropped', +] as const; + +export type HostBridgeEventName = (typeof HOST_BRIDGE_EVENTS)[number]; + +export function isHostBridgeEventName( + value: unknown, +): value is HostBridgeEventName { + return ( + typeof value === 'string' && + HOST_BRIDGE_EVENTS.includes(value as HostBridgeEventName) + ); +} + +export type HostBridgeEvent = { + bridge: typeof HOST_BRIDGE_PROTOCOL; + version: typeof HOST_BRIDGE_VERSION; + event: HostBridgeEventName; + payload?: Payload; +}; + +export type NavigationCanGoBackEventPayload = { + canGoBack: boolean; +}; + +export type HostAppLifecycleState = 'active' | 'inactive' | 'background'; + +export type AppLifecycleEventPayload = { + state: HostAppLifecycleState; + focused: boolean; + nativeState?: string; +}; + +export function normalizeHostBridgeLifecycleState( + rawState: unknown, +): HostAppLifecycleState { + return rawState === 'active' || rawState === 'background' + ? rawState + : 'inactive'; +} + +export type HostNetworkConnectionType = + | 'none' + | 'unknown' + | 'cellular' + | 'wifi' + | 'ethernet' + | 'bluetooth' + | 'vpn' + | 'other'; + +export type NetworkStatusResult = { + isConnected: boolean; + isInternetReachable: boolean | null; + connectionType: HostNetworkConnectionType; + nativeType?: string; +}; + +export function normalizeHostBridgeConnectionType( + rawConnectionType: unknown, +): HostNetworkConnectionType { + if (typeof rawConnectionType !== 'string') { + return 'unknown'; + } + + const normalizedType = rawConnectionType.toLowerCase(); + if ( + normalizedType === 'none' || + normalizedType === 'cellular' || + normalizedType === 'wifi' || + normalizedType === 'ethernet' || + normalizedType === 'bluetooth' || + normalizedType === 'vpn' + ) { + return normalizedType; + } + + if (normalizedType === 'other') { + return 'other'; + } + + return 'unknown'; +} + +export type HostAppearanceColorScheme = 'light' | 'dark' | 'unknown'; + +export type AppearanceColorSchemeResult = { + colorScheme: HostAppearanceColorScheme; +}; + +export function normalizeHostBridgeColorScheme( + rawColorScheme: unknown, +): HostAppearanceColorScheme { + return rawColorScheme === 'light' || rawColorScheme === 'dark' + ? rawColorScheme + : 'unknown'; +} + +export type NavigateNativePagePayload = { + url: string; +}; + +export type OpenExternalUrlPayload = { + url: string; +}; + +export type SetTitlePayload = { + title: string; +}; + +export const HOST_BRIDGE_APP_TITLE_MAX_LENGTH = 80; + +export function normalizeHostBridgeAppTitle(rawTitle: unknown): SetTitlePayload | null { + if (typeof rawTitle !== 'string') { + return null; + } + + const title = rawTitle.trim(); + if (!title || hasHostBridgeControlCharacter(title)) { + return null; + } + + return { + title: title.slice(0, HOST_BRIDGE_APP_TITLE_MAX_LENGTH), + }; +} + +export type SetBadgeCountPayload = { + count: number; +}; + +export const HOST_BRIDGE_BADGE_COUNT_MAX = 99999; + +export function normalizeHostBridgeBadgeCount(rawCount: unknown) { + if ( + typeof rawCount !== 'number' || + !Number.isInteger(rawCount) || + rawCount < 0 || + rawCount > HOST_BRIDGE_BADGE_COUNT_MAX + ) { + return null; + } + + return rawCount; +} + +export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [ + 'http:', + 'https:', + 'mailto:', + 'tel:', +] as const; + +export type HostBridgeExternalUrlProtocol = + (typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number]; + +export const HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = [ + 'blob:', + 'data:', + 'file:', + 'filesystem:', +] as const; + +export type HostBridgeMobileWebViewBlockedDownloadProtocol = + (typeof HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS)[number]; + +function hasHostBridgeControlCharacter(value: string) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} + +export function normalizeHostBridgeExternalUrl(rawUrl: unknown) { + if (typeof rawUrl !== 'string') { + return null; + } + + const urlText = rawUrl.trim(); + if (!urlText || hasHostBridgeControlCharacter(urlText)) { + return null; + } + + try { + const url = new URL(urlText); + if ( + !HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS.includes( + url.protocol as HostBridgeExternalUrlProtocol, + ) + ) { + return null; + } + + return url.toString(); + } catch { + return null; + } +} + +export function normalizeHostBridgeExternalUrlPayload( + rawUrl: unknown, +): OpenExternalUrlPayload | null { + const url = normalizeHostBridgeExternalUrl(rawUrl); + return url ? { url } : null; +} + +export type ClipboardWriteTextPayload = { + text: string; +}; + +export type ClipboardReadTextResult = { + text: string; +}; + +export const HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH = 100000; + +export function normalizeHostBridgeClipboardText( + rawText: unknown, +): ClipboardReadTextResult | null { + if (typeof rawText !== 'string') { + return null; + } + + return { + text: rawText.slice(0, HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH), + }; +} + +export type FileExportTextPayload = { + fileName: string; + content: string; + mimeType?: HostBridgeTextMimeType; +}; + +export type FileExportTextResult = { + action: 'saved'; + fileName: string; + bytes: number; +}; + +export type HostBridgeTextMimeType = + | 'text/plain' + | 'text/markdown' + | 'text/csv' + | 'application/json'; + +export const HOST_BRIDGE_TEXT_MIME_TYPES = [ + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/json', +] as const satisfies readonly HostBridgeTextMimeType[]; + +const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set( + HOST_BRIDGE_TEXT_MIME_TYPES, +); + +export type FileImportTextResult = { + action: 'selected'; + fileName: string; + content: string; + mimeType: HostBridgeTextMimeType; + bytes: number; +}; + +export type HostBridgeDocumentMimeType = + | HostBridgeTextMimeType + | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +export const HOST_BRIDGE_DOCUMENT_MIME_TYPES = [ + ...HOST_BRIDGE_TEXT_MIME_TYPES, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +] as const satisfies readonly HostBridgeDocumentMimeType[]; + +export type FileImportDocumentResult = { + action: 'selected'; + fileName: string; + base64Data: string; + mimeType: HostBridgeDocumentMimeType; + bytes: number; +}; + +export type FileExportImagePayload = { + fileName: string; + base64Data: string; + mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; +}; + +export type FileExportImageResult = { + action: 'saved'; + fileName: string; + bytes: number; +}; + +export type HostBridgeImageMimeType = + | 'image/png' + | 'image/jpeg' + | 'image/webp'; + +export const HOST_BRIDGE_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', +] as const satisfies readonly HostBridgeImageMimeType[]; + +const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set( + HOST_BRIDGE_IMAGE_MIME_TYPES, +); + +export type FileImportImageResult = { + action: 'selected' | 'dropped' | 'captured'; + fileName: string; + base64Data: string; + mimeType: HostBridgeImageMimeType; + bytes: number; + position?: { + x: number; + y: number; + }; +}; + +export type ScannerScanQrCodeResult = { + value: string; + format: 'qr_code'; +}; + +export const HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH = 4096; + +export function normalizeHostBridgeQrCodeValue( + rawValue: unknown, +): ScannerScanQrCodeResult | null { + if (typeof rawValue !== 'string') { + return null; + } + + const value = rawValue.trim(); + if (!value || hasHostBridgeControlCharacter(value)) { + return null; + } + + return { + value: value.slice(0, HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH), + format: 'qr_code', + }; +} + +export type HostBridgeAudioMimeType = + | 'audio/mpeg' + | 'audio/mp4' + | 'audio/wav' + | 'audio/ogg' + | 'audio/webm'; + +export const HOST_BRIDGE_AUDIO_MIME_TYPES = [ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', +] as const satisfies readonly HostBridgeAudioMimeType[]; + +const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set( + HOST_BRIDGE_AUDIO_MIME_TYPES, +); + +export type FileImportAudioResult = { + action: 'selected'; + fileName: string; + base64Data: string; + mimeType: HostBridgeAudioMimeType; + bytes: number; +}; + +export type FileExportAudioPayload = { + fileName: string; + base64Data: string; + mimeType: HostBridgeAudioMimeType; +}; + +export type FileExportAudioResult = { + action: 'saved'; + fileName: string; + bytes: number; +}; + +export const HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; + +function estimateHostBridgeUtf8Bytes(value: string) { + return new TextEncoder().encode(value).length; +} + +export function normalizeHostBridgeExportTextPayload( + payload: unknown, +): FileExportTextPayload | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (typeof candidate.content !== 'string') { + return null; + } + + if ( + estimateHostBridgeUtf8Bytes(candidate.content) > + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES + ) { + return null; + } + + const normalizedPayload: FileExportTextPayload = { + fileName: normalizeHostBridgeExportFileName(candidate.fileName), + content: candidate.content, + }; + + if (candidate.mimeType === undefined) { + return normalizedPayload; + } + + const mimeType = candidate.mimeType as HostBridgeTextMimeType; + if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType)) { + return null; + } + + return { + ...normalizedPayload, + mimeType, + }; +} + +function normalizeHostBridgeImportedBytes(rawBytes: unknown, maxBytes: number) { + if ( + typeof rawBytes !== 'number' || + !Number.isInteger(rawBytes) || + rawBytes <= 0 || + rawBytes > maxBytes + ) { + return null; + } + + return rawBytes; +} + +export function normalizeHostBridgeImportFileName(rawFileName: unknown) { + return normalizeHostBridgeExportFileName(rawFileName); +} + +export function normalizeHostBridgeImportTextResult( + payload: unknown, +): FileImportTextResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (candidate.action !== 'selected') { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeTextMimeType; + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType) || + typeof candidate.content !== 'string' || + estimateHostBridgeUtf8Bytes(candidate.content) > + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES + ) { + return null; + } + + return { + action: 'selected', + fileName, + content: candidate.content, + mimeType, + bytes, + }; +} + +export function normalizeHostBridgeImportDocumentResult( + payload: unknown, +): FileImportDocumentResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (candidate.action !== 'selected') { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeDocumentMimeType; + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_DOCUMENT_MIME_TYPES.includes(mimeType) || + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES + ) { + return null; + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + +export function normalizeHostBridgeImportImageResult( + payload: unknown, +): FileImportImageResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if ( + candidate.action !== 'selected' && + candidate.action !== 'dropped' && + candidate.action !== 'captured' + ) { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeImageMimeType; + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType) || + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES + ) { + return null; + } + + const position = + candidate.position && + typeof candidate.position.x === 'number' && + Number.isFinite(candidate.position.x) && + typeof candidate.position.y === 'number' && + Number.isFinite(candidate.position.y) + ? { + x: candidate.position.x, + y: candidate.position.y, + } + : undefined; + + return { + action: candidate.action, + fileName, + base64Data, + mimeType, + bytes, + ...(position ? { position } : {}), + }; +} + +export function normalizeHostBridgeImportAudioResult( + payload: unknown, +): FileImportAudioResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (candidate.action !== 'selected') { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeAudioMimeType; + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType) || + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES + ) { + return null; + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + +function normalizeHostBridgeBase64Data(rawData: unknown) { + if (typeof rawData !== 'string') { + return null; + } + + const base64Data = rawData.trim(); + if (!base64Data || hasHostBridgeControlCharacter(base64Data)) { + return null; + } + + return base64Data; +} + +function estimateHostBridgeBase64Bytes(base64Data: string) { + const normalizedLength = base64Data.replace(/\s+/g, '').length; + return Math.ceil((normalizedLength * 3) / 4); +} + +export function normalizeHostBridgeExportImagePayload( + payload: unknown, +): FileExportImagePayload | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + const mimeType = candidate.mimeType as HostBridgeImageMimeType; + if (!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType)) { + return null; + } + + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES + ) { + return null; + } + + return { + fileName: normalizeHostBridgeExportFileName(candidate.fileName), + base64Data, + mimeType, + }; +} + +export function normalizeHostBridgeExportAudioPayload( + payload: unknown, +): FileExportAudioPayload | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + const mimeType = candidate.mimeType as HostBridgeAudioMimeType; + if (!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType)) { + return null; + } + + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES + ) { + return null; + } + + return { + fileName: normalizeHostBridgeExportFileName(candidate.fileName), + base64Data, + mimeType, + }; +} + +export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [ + 'light', + 'medium', + 'heavy', +] as const; + +export type HostBridgeHapticsImpactStyle = + (typeof HOST_BRIDGE_HAPTICS_IMPACT_STYLES)[number]; + +export function normalizeHostBridgeHapticsImpactStyle(rawStyle: unknown) { + if (rawStyle === undefined) { + return 'light'; + } + + return HOST_BRIDGE_HAPTICS_IMPACT_STYLES.includes( + rawStyle as HostBridgeHapticsImpactStyle, + ) + ? rawStyle as HostBridgeHapticsImpactStyle + : null; +} + +export type HapticsImpactPayload = { + style?: HostBridgeHapticsImpactStyle; +}; + +export type LocalNotificationPayload = { + title: string; + body?: string; +}; + +export type LocalNotificationResult = { + action: 'delivered_to_system'; +}; + +export const HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT: LocalNotificationResult = { + action: 'delivered_to_system', +}; + +export const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH = 80; +export const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH = 240; +export const HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID = + 'genarrative-local'; + +function normalizeHostBridgePlainText( + value: unknown, + maxLength: number, + required: boolean, +) { + if (typeof value !== 'string') { + return required ? null : undefined; + } + + if (hasHostBridgeControlCharacter(value)) { + return null; + } + + const text = value.trim().replace(/\s+/g, ' '); + if (!text) { + return required ? null : undefined; + } + + return text.slice(0, maxLength); +} + +export function normalizeHostBridgeLocalNotification( + payload: unknown, +): LocalNotificationPayload | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + const title = normalizeHostBridgePlainText( + candidate.title, + HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, + true, + ); + if (!title) { + return null; + } + + const body = normalizeHostBridgePlainText( + candidate.body, + HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH, + false, + ); + if (body === null) { + return null; + } + + return body ? { title, body } : { title }; +} + +export type ShareSetTargetPayload = { + target: unknown; +}; + +export type ShareOpenPayload = { + title?: string; + message?: string; + url?: string; + href?: string; + path?: string; + targetPath?: string; + work?: string; +}; + +export type HostBridgeSharePayloadNormalization = + | { status: 'empty' } + | { status: 'invalid' } + | { status: 'valid'; payload: ShareOpenPayload }; + +function hostBridgeStringField(value: unknown, field: string) { + if (!value || typeof value !== 'object') { + return undefined; + } + + const fieldValue = (value as Record)[field]; + if (typeof fieldValue !== 'string') { + return undefined; + } + + const text = fieldValue.trim(); + return text || undefined; +} + +function hostBridgeShareTargetPayload(value: unknown) { + if (!value || typeof value !== 'object') { + return value; + } + + const target = value as Record; + return target.target ?? value; +} + +function normalizeHostBridgePublicShareUrl(rawUrl: string | undefined) { + if (!rawUrl || rawUrl.startsWith('//')) { + return undefined; + } + + try { + const url = new URL(rawUrl, HOST_BRIDGE_PUBLIC_WEB_ORIGIN); + if (url.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN) { + return undefined; + } + + return url.toString(); + } catch { + return undefined; + } +} + +function hostBridgeWorkDetailUrl(work: string) { + const searchParams = new URLSearchParams({ work }); + return `${HOST_BRIDGE_PUBLIC_WEB_ORIGIN}/works/detail?${searchParams.toString()}`; +} + +export function normalizeHostBridgeShareOpenPayload( + value: unknown, +): HostBridgeSharePayloadNormalization { + const target = hostBridgeShareTargetPayload(value); + const payload = + target && typeof target === 'object' + ? (target as Record).payload ?? target + : target; + + if (!payload || typeof payload !== 'object') { + return { status: 'empty' }; + } + + const title = hostBridgeStringField(payload, 'title'); + const message = hostBridgeStringField(payload, 'message'); + const rawDirectUrl = + hostBridgeStringField(payload, 'url') ?? + hostBridgeStringField(payload, 'href'); + const directUrl = normalizeHostBridgePublicShareUrl(rawDirectUrl); + if (rawDirectUrl && !directUrl) { + return { status: 'invalid' }; + } + + const work = hostBridgeStringField(payload, 'work'); + const rawPath = + hostBridgeStringField(payload, 'path') ?? + hostBridgeStringField(payload, 'targetPath'); + const pathUrl = normalizeHostBridgePublicShareUrl(rawPath); + if (rawPath && !pathUrl) { + return { status: 'invalid' }; + } + + const url = directUrl ?? (work ? hostBridgeWorkDetailUrl(work) : undefined) ?? pathUrl; + if (!title && !message && !url) { + return { status: 'empty' }; + } + + return { + status: 'valid', + payload: { + ...(title ? { title } : {}), + ...(message ? { message } : {}), + ...(url ? { url } : {}), + }, + }; +} + +export const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt'; +export const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120; + +function isHostBridgeInvalidFileNameCharacter(value: string) { + if (hasHostBridgeControlCharacter(value)) { + return true; + } + + return ['<', '>', ':', '"', '/', '\\', '|', '?', '*'].includes(value); +} + +export function normalizeHostBridgeExportFileName(rawFileName: unknown) { + if (typeof rawFileName !== 'string') { + return HOST_BRIDGE_FILE_NAME_FALLBACK; + } + + const fileName = rawFileName + .trim() + .split('') + .map((character) => + isHostBridgeInvalidFileNameCharacter(character) ? '-' : character, + ) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[.\s-]+/, '') + .slice(0, HOST_BRIDGE_FILE_NAME_MAX_LENGTH) + .trim(); + + return fileName || HOST_BRIDGE_FILE_NAME_FALLBACK; +} diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 4d42e963a..02b4005e4 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,11 +1,12 @@ -export type * from './creativeAgent'; +export type * from './barkBattle'; export type * from './creationAudio'; +export type * from './creativeAgent'; +export * from './hostBridge'; export type * from './hyper3d'; export type * from './jumpHop'; -export type * from './puzzleCreativeTemplate'; -export type * from './puzzleClear'; export * from './playTypes'; export type * from './publicWork'; +export type * from './puzzleClear'; +export type * from './puzzleCreativeTemplate'; export type * from './visualNovel'; -export type * from './barkBattle'; export type * from './woodenFish'; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs new file mode 100644 index 000000000..e65292757 --- /dev/null +++ b/scripts/check-native-shells.mjs @@ -0,0 +1,4351 @@ +#!/usr/bin/env node + +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const nativeShellPlanPath = + 'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md'; +const hostBridgeProtocolDocPath = + 'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md'; +const developmentWorkflowDocPath = + 'docs/project-memory/shared-memory/development-workflow.md'; +const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md'; +const rootPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); +const mobileShellConfigCheckSource = fs.readFileSync( + 'apps/mobile-shell/scripts/check-config.mjs', + 'utf8', +); +const desktopShellConfigCheckSource = fs.readFileSync( + 'apps/desktop-shell/scripts/check-config.mjs', + 'utf8', +); + +const productionShellScanRoots = [ + 'apps/mobile-shell', + 'apps/desktop-shell', + 'miniprogram', + 'packages/shared/src/contracts/hostBridge.ts', + 'src/services/host-bridge', +]; +const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge'; +const h5NativeAppHostBridgeTransportModule = + 'src/services/host-bridge/nativeAppHostBridge'; +const h5HostBridgeScannedFacadeImports = new Set([ + 'canUseHostShareGrid', + 'captureHostImageFile', + 'exportHostAudioFile', + 'exportHostImageFile', + 'exportHostTextFile', + 'getHostAppearanceColorScheme', + 'getHostNetworkStatus', + 'getNativeAppHostRuntime', + 'importHostAudioFile', + 'importHostDocumentFile', + 'importHostImageFile', + 'importHostTextFile', + 'navigateHostNativePage', + 'openHostExternalUrl', + 'openHostShare', + 'openHostShareGrid', + 'readHostClipboardText', + 'refreshNativeAppHostRuntime', + 'reloadHostWebView', + 'requestHostHapticsImpact', + 'requestHostLogin', + 'requestHostPayment', + 'scanHostQrCode', + 'setHostAppBadgeCount', + 'setHostAppTitle', + 'setHostShareTarget', + 'showHostLocalNotification', + 'subscribeHostAppLifecycle', + 'subscribeHostImageDrop', + 'subscribeHostNavigationCanGoBack', + 'subscribeHostNetworkStatusChange', + 'subscribeHostRuntimeChange', + 'writeHostClipboardText', +]); + +function assertRootNativeShellCheckScripts() { + if (rootPackageJson.scripts?.['check:native-shells'] !== 'node scripts/check-native-shells.mjs') { + throw new Error('root check:native-shells script must run scripts/check-native-shells.mjs'); + } + if ( + rootPackageJson.scripts?.check !== + 'npm run lint && npm run test && npm run build && npm run check:content && npm run check:native-shells' + ) { + throw new Error('root check script must include check:native-shells after build and content checks'); + } +} + +assertRootNativeShellCheckScripts(); +function assertNativeShellDependencyVersionGuardrails() { + for (const snippet of [ + "const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)", + 'function assertPackageDependencyVersion(', + 'function assertPackageLockVersion(', + "'@expo/metro-runtime': '^56.0.15'", + "expo: '^56.0.12'", + "'react-native': '^0.86.0'", + "'react-native-webview': '^13.16.1'", + "'eas-cli': '^20.3.0'", + "assertPackageLockVersion('eas-cli', '20.3.0')", + ]) { + if (!mobileShellConfigCheckSource.includes(snippet)) { + throw new Error(`mobile shell dependency guardrail drifted: missing ${snippet}`); + } + } + + for (const snippet of [ + "const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)", + "const cargoLockPath = new URL('../src-tauri/Cargo.lock', import.meta.url)", + 'function assertPackageDependencyVersion(', + 'function assertPackageLockVersion(', + 'function assertCargoDependencyLine(', + 'function assertCargoLockPackageVersion(', + 'function assertCargoLockDirectDependency(', + "'@tauri-apps/cli': '^2.11.2'", + "'@tauri-apps/cli': '2.11.2'", + 'tauri = { version = "2.11.2", features = ["tray-icon"] }', + "['tauri', '2.11.2']", + 'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }', + ]) { + if (!desktopShellConfigCheckSource.includes(snippet)) { + throw new Error(`desktop shell dependency guardrail drifted: missing ${snippet}`); + } + } +} + +assertNativeShellDependencyVersionGuardrails(); +const h5HostBridgeCallChainWrapperFiles = [ + 'src/hooks/useHostLifecycleActive.ts', + 'src/hooks/useHostNavigationCanGoBack.ts', + 'src/hooks/useHostNetworkOnline.ts', + 'src/components/platform-entry/platformProfileHostClipboard.ts', + 'src/components/platform-entry/platformHostBridgeSync.ts', +]; +const h5HostBridgeRequiredCallChainFiles = [ + 'src/App.tsx', + 'src/components/auth/AuthGate.tsx', + 'src/components/bark-battle-creation/BarkBattleResultView.tsx', + 'src/components/common/CreativeAudioInputPanel.tsx', + 'src/components/common/CreativeImageInputPanel.tsx', + 'src/components/common/PublishShareModal.tsx', + 'src/components/common/publishShareCardImage.ts', + 'src/components/creation-agent/CreationAgentWorkspace.tsx', + 'src/components/match3d-runtime/Match3DRuntimeShell.tsx', + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + 'src/components/platform-entry/PlatformFeedbackView.tsx', + 'src/components/platform-entry/PlatformProfilePrimitives.tsx', + 'src/components/platform-entry/PlatformProfileQrScannerModal.tsx', + 'src/components/platform-entry/PlatformProfileReferralModal.tsx', + 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', + 'src/components/platform-entry/usePlatformProfileCenterController.ts', + 'src/components/puzzle-runtime/PuzzleRuntimeShell.tsx', + 'src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx', + 'src/components/rpg-entry/RpgEntryHomeView.tsx', + 'src/components/square-hole-result/SquareHoleResultView.tsx', + 'src/components/visual-novel-result/VisualNovelResultView.tsx', + 'src/hooks/useBackgroundMusic.ts', + 'src/hooks/useHostLifecycleActive.ts', + 'src/hooks/useHostNavigationCanGoBack.ts', + 'src/hooks/useHostNetworkOnline.ts', + 'src/main.tsx', + 'src/services/appTitle.ts', + 'src/services/authService.ts', + 'src/services/clipboard.ts', + 'src/services/payment/paymentRedirect.ts', + 'src/services/runtimeAudioFeedback.ts', + 'src/services/wechatMiniProgramShareTarget.ts', + 'src/services/wechatMiniProgramSubscribe.ts', + 'src/services/wechatMiniProgramShareGrid.ts', +]; +const h5HostBridgeEventSubscriptionFacades = [ + { + functionName: 'subscribeHostAppLifecycle', + eventName: 'app.lifecycle', + }, + { + functionName: 'subscribeHostNetworkStatusChange', + eventName: 'network.statusChanged', + }, + { + functionName: 'subscribeHostNavigationCanGoBack', + eventName: 'navigation.canGoBack', + }, + { + functionName: 'subscribeHostImageDrop', + eventName: 'file.imageDropped', + }, +]; +const wechatCapabilityFlowContracts = [ + { + capability: 'auth.requestLogin', + files: [ + 'miniprogram/host-bridge/protocol.js', + 'miniprogram/host-bridge/webView.js', + 'miniprogram/shell/webView.js', + 'miniprogram/pages/web-view/index.js', + ], + snippets: [ + ['miniprogram/host-bridge/protocol.js', 'WECHAT_AUTH_PAGE_URL'], + ['miniprogram/host-bridge/webView.js', 'resolveWebViewUrlFromRuntimeConfig'], + ['miniprogram/shell/webView.js', 'shouldStartAuthFromQuery'], + ['miniprogram/shell/webView.js', 'wx.login'], + ['miniprogram/shell/webView.js', '/api/auth/wechat/miniprogram-login'], + ['miniprogram/pages/web-view/index.js', 'createWechatWebViewPage'], + ], + tests: [ + 'miniprogram/host-bridge/protocol.test.js', + 'miniprogram/host-bridge/webView.test.js', + 'miniprogram/shell/webView.test.js', + 'scripts/miniprogram-web-view-auth.test.ts', + ], + }, + { + capability: 'payment.request', + files: [ + 'miniprogram/host-bridge/protocol.js', + 'miniprogram/host-bridge/payment.js', + 'miniprogram/shell/payment.js', + 'miniprogram/pages/wechat-pay/index.js', + ], + snippets: [ + ['miniprogram/host-bridge/protocol.js', 'WECHAT_PAY_PAGE_URL'], + ['miniprogram/host-bridge/payment.js', 'requestWechatPayment'], + ['miniprogram/host-bridge/payment.js', 'wx.requestPayment'], + ['miniprogram/host-bridge/payment.js', 'wx.requestVirtualPayment'], + ['miniprogram/shell/payment.js', 'createWechatPayPage'], + ['miniprogram/shell/payment.js', 'notifyPreviousWebView'], + ['miniprogram/pages/wechat-pay/index.js', 'createWechatPayPage'], + ], + tests: [ + 'miniprogram/host-bridge/protocol.test.js', + 'miniprogram/host-bridge/payment.test.js', + 'miniprogram/shell/payment.test.js', + 'scripts/miniprogram-web-view-auth.test.ts', + ], + }, + { + capability: 'share.setTarget', + files: [ + 'miniprogram/host-bridge/protocol.js', + 'miniprogram/host-bridge/webView.js', + 'miniprogram/shell/webView.js', + ], + snippets: [ + ['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_TARGET_MESSAGE_TYPE'], + ['miniprogram/host-bridge/webView.js', 'resolveShareTargetFromWebViewMessage'], + ['miniprogram/shell/webView.js', 'handleWebViewMessage'], + ['miniprogram/shell/webView.js', '_currentShareTarget'], + ], + tests: [ + 'miniprogram/host-bridge/protocol.test.js', + 'miniprogram/host-bridge/webView.test.js', + 'miniprogram/shell/webView.test.js', + ], + }, + { + capability: 'share.open', + files: [ + 'miniprogram/host-bridge/protocol.js', + 'miniprogram/host-bridge/webView.js', + 'miniprogram/host-bridge/shareGrid.js', + 'miniprogram/shell/webView.js', + 'miniprogram/shell/shareGrid.js', + 'miniprogram/pages/share-grid/index.js', + ], + snippets: [ + ['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_GRID_PAGE_URL'], + ['miniprogram/host-bridge/webView.js', 'buildWebViewSharePath'], + ['miniprogram/host-bridge/shareGrid.js', 'buildShareGridTilePlan'], + ['miniprogram/shell/webView.js', 'onShareAppMessage'], + ['miniprogram/shell/webView.js', 'onShareTimeline'], + ['miniprogram/shell/shareGrid.js', 'wx.saveImageToPhotosAlbum'], + ['miniprogram/pages/share-grid/index.js', 'createWechatShareGridPage'], + ], + tests: [ + 'miniprogram/host-bridge/protocol.test.js', + 'miniprogram/host-bridge/webView.test.js', + 'miniprogram/host-bridge/shareGrid.test.js', + 'miniprogram/shell/webView.test.js', + 'miniprogram/shell/shareGrid.test.js', + ], + }, + { + capability: 'navigation.openNativePage', + files: [ + 'miniprogram/host-bridge/protocol.js', + 'miniprogram/host-bridge/subscribeMessage.js', + 'miniprogram/shell/subscribeMessage.js', + 'miniprogram/pages/subscribe-message/index.js', + 'src/services/wechatMiniProgramSubscribe.ts', + ], + snippets: [ + ['miniprogram/host-bridge/protocol.js', 'WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL'], + ['miniprogram/host-bridge/subscribeMessage.js', 'wx.requestSubscribeMessage'], + ['miniprogram/host-bridge/subscribeMessage.js', 'createSubscribeMessagePageController'], + ['miniprogram/shell/subscribeMessage.js', 'createSubscribeMessagePage'], + ['miniprogram/pages/subscribe-message/index.js', 'GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID'], + ['src/services/wechatMiniProgramSubscribe.ts', 'requestGenerationResultSubscribePermission'], + ['src/services/wechatMiniProgramSubscribe.ts', 'navigateHostNativePage'], + ['src/services/wechatMiniProgramSubscribe.ts', 'MINI_PROGRAM_SUBSCRIBE_MESSAGE_PAGE_URL'], + ], + tests: [ + 'miniprogram/host-bridge/protocol.test.js', + 'miniprogram/host-bridge/subscribeMessage.test.js', + 'miniprogram/shell/subscribeMessage.test.js', + 'src/services/wechatMiniProgramSubscribe.test.ts', + ], + }, +]; +const mobileCapabilityFlowContracts = [ + { + capability: 'host.getRuntime', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/runtime.ts', + 'apps/mobile-shell/src/host-bridge/capabilities.ts', + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'getMobileHostBridgeRuntimeResponse(request)'], + ['apps/mobile-shell/src/host-bridge/runtime.ts', 'getMobileHostBridgeRuntime'], + ['apps/mobile-shell/src/host-bridge/runtime.ts', 'resolveMobileHostCapabilities(platform)'], + ['apps/mobile-shell/src/shell/ShellApp.tsx', 'capabilities: resolveMobileHostCapabilities()'], + ['apps/mobile-shell/scripts/check-config.mjs', 'mobile shell dispatch must delegate host.getRuntime to runtime.ts'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/runtime.test.ts', + 'apps/mobile-shell/src/host-bridge/capabilities.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + ], + }, + { + capability: 'appearance.getColorScheme', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/appearance.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'getMobileHostBridgeAppearanceColorScheme(request)'], + ['apps/mobile-shell/src/host-bridge/appearance.ts', 'Appearance.getColorScheme()'], + ['apps/mobile-shell/src/host-bridge/appearance.ts', 'normalizeHostBridgeColorScheme'], + ['apps/mobile-shell/scripts/check-config.mjs', 'Appearance.getColorScheme()'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/appearance.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'host.events', + files: [ + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/shell/ShellApp.tsx', 'injectHostBridgeEvent'], + ['apps/mobile-shell/scripts/check-config.mjs', "hostBridgeEvent('app.lifecycle')"], + ], + tests: [ + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'app.lifecycle', + files: [ + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/shell/ShellApp.tsx', "injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state))"], + ['apps/mobile-shell/scripts/check-config.mjs', "hostBridgeEvent('app.lifecycle')"], + ], + tests: [ + 'apps/mobile-shell/src/shell/lifecycle.test.ts', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + ], + }, + { + capability: 'share.setTarget', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/share.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'setMobileHostBridgeShareTarget(request)'], + ['apps/mobile-shell/src/host-bridge/share.ts', 'normalizeHostBridgeShareOpenPayload'], + ['apps/mobile-shell/src/host-bridge/share.ts', 'currentShareTarget = normalizedTarget.payload'], + ['apps/mobile-shell/scripts/check-config.mjs', 'share.setTarget'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/share.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'navigation.openNativePage', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/navigation.ts', + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'openMobileHostBridgeNativePage(request, navigation)'], + ['apps/mobile-shell/src/host-bridge/navigation.ts', 'navigation.baseWebUrlOptions'], + ['apps/mobile-shell/src/host-bridge/navigation.ts', 'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,'], + ['apps/mobile-shell/src/shell/ShellApp.tsx', 'openWebViewUrl(url)'], + ['apps/mobile-shell/src/shell/ShellApp.tsx', 'setWebUrl(url)'], + ['apps/mobile-shell/scripts/check-config.mjs', 'navigation.openNativePage'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/navigation.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + ], + }, + { + capability: 'navigation.canGoBack', + files: [ + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/src/shell/webViewHistory.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/shell/ShellApp.tsx', "injectHostBridgeEvent('navigation.canGoBack'"], + ['apps/mobile-shell/src/shell/webViewHistory.ts', 'parseMobileWebViewHistoryStateMessage'], + ['apps/mobile-shell/scripts/check-config.mjs', "lastHostBridgeEvent('navigation.canGoBack')"], + ], + tests: [ + 'apps/mobile-shell/src/shell/webViewHistory.test.ts', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + ], + }, + { + capability: 'app.reloadWebView', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/navigation.ts', + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'reloadMobileHostBridgeWebView(request, navigation)'], + ['apps/mobile-shell/src/host-bridge/navigation.ts', 'navigation.reloadWebView()'], + ['apps/mobile-shell/src/shell/ShellApp.tsx', 'reloadWebView: reloadCurrentWebView'], + ['apps/mobile-shell/scripts/check-config.mjs', 'app.reloadWebView'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/navigation.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + ], + }, + { + capability: 'app.openExternalUrl', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/navigation.ts', + 'apps/mobile-shell/src/shell/navigation.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'openMobileHostBridgeExternalUrl(request)'], + ['apps/mobile-shell/src/host-bridge/navigation.ts', 'normalizeHostBridgeExternalUrlPayload'], + ['apps/mobile-shell/src/shell/navigation.ts', 'navigator.openURL(externalUrl)'], + ['apps/mobile-shell/scripts/check-config.mjs', 'mobile shell app.openExternalUrl must normalize payloads'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/navigation.test.ts', + 'apps/mobile-shell/src/shell/navigation.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'network.status', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/network.ts', + 'apps/mobile-shell/src/shell/network.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'getMobileHostBridgeNetworkStatus(request)'], + ['apps/mobile-shell/src/host-bridge/network.ts', 'getMobileNetworkStatus()'], + ['apps/mobile-shell/src/shell/network.ts', 'Network.getNetworkStateAsync()'], + ['apps/mobile-shell/scripts/check-config.mjs', 'network status unavailable'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/network.test.ts', + 'apps/mobile-shell/src/shell/network.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'clipboard.writeText', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/clipboard.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'writeMobileHostBridgeClipboardText(request)'], + ['apps/mobile-shell/src/host-bridge/clipboard.ts', 'Clipboard.setStringAsync(clipboardText.text)'], + ['apps/mobile-shell/src/host-bridge/clipboard.ts', 'normalizeHostBridgeClipboardText'], + ['apps/mobile-shell/scripts/check-config.mjs', 'writeMobileHostBridgeClipboardText'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/clipboard.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'clipboard.readText', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/clipboard.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'readMobileHostBridgeClipboardText(request)'], + ['apps/mobile-shell/src/host-bridge/clipboard.ts', 'Clipboard.getStringAsync()'], + ['apps/mobile-shell/src/host-bridge/clipboard.ts', 'normalizeHostBridgeClipboardText(rawText)'], + ['apps/mobile-shell/scripts/check-config.mjs', 'readMobileHostBridgeClipboardText'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/clipboard.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.exportText', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'exportMobileHostBridgeTextFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'exportTextFile(request.payload)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'normalizeHostBridgeExportFileName'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.exportText'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.importText', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'importMobileHostBridgeTextFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'importTextFile()'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'normalizeImportedTextMimeType'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.importText'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.importDocument', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'importMobileHostBridgeDocumentFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'importDocumentFile()'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'normalizeImportedDocumentMimeType'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.importDocument'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.exportImage', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'exportMobileHostBridgeImageFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'exportImageFile(request.payload)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'normalizedBase64Data(exportPayload?.base64Data)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'ensureImageBytesMatchMimeType'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.exportImage'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.importImage', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'importMobileHostBridgeImageFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'ImagePicker.launchImageLibraryAsync'], + ['apps/mobile-shell/src/host-bridge/filePayloads.ts', 'imagePickerResultToImportPayload'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.importImage'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.importAudio', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'importMobileHostBridgeAudioFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'importAudioFile()'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'normalizeImportedAudioMimeType'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.importAudio'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.exportAudio', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'exportMobileHostBridgeAudioFile(request)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'exportAudioFile(request.payload)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'normalizedBase64Data(exportPayload?.base64Data)'], + ['apps/mobile-shell/src/host-bridge/files.ts', 'ensureAudioBytesMatchMimeType'], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.exportAudio'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/filePayloads.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'haptics.impact', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/haptics.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'runMobileHostBridgeHapticsImpact(request)'], + ['apps/mobile-shell/src/host-bridge/haptics.ts', 'Haptics.impactAsync(toExpoImpactStyle(style))'], + ['apps/mobile-shell/src/host-bridge/haptics.ts', 'normalizeHostBridgeHapticsImpactStyle'], + ['apps/mobile-shell/scripts/check-config.mjs', 'haptics impact unavailable'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/haptics.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'app.setBadgeCount', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/badge.ts', + 'apps/mobile-shell/src/host-bridge/capabilities.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'setMobileAppBadgeCount(request)'], + ['apps/mobile-shell/src/host-bridge/badge.ts', 'Notifications.setBadgeCountAsync'], + ['apps/mobile-shell/src/host-bridge/capabilities.ts', 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES'], + ['apps/mobile-shell/scripts/check-config.mjs', 'iOS mobile shell capabilities missing app.setBadgeCount'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/badge.test.ts', + 'apps/mobile-shell/src/host-bridge/capabilities.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'notification.showLocal', + files: [ + 'apps/mobile-shell/app.json', + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/notifications.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + 'apps/mobile-shell/scripts/check-expo-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + ['apps/mobile-shell/app.json', 'android.permission.POST_NOTIFICATIONS'], + [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'showMobileHostBridgeLocalNotification(request)', + ], + [ + 'apps/mobile-shell/src/host-bridge/notifications.ts', + 'Notifications.scheduleNotificationAsync', + ], + [ + 'apps/mobile-shell/src/host-bridge/notifications.ts', + 'Notifications.setNotificationChannelAsync', + ], + [ + 'apps/mobile-shell/src/host-bridge/notifications.ts', + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', + ], + [ + 'apps/mobile-shell/scripts/check-config.mjs', + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', + ], + [ + 'apps/mobile-shell/scripts/check-expo-config.mjs', + 'android.permission.POST_NOTIFICATIONS', + ], + [nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/notifications.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'file.captureImage', + files: [ + 'apps/mobile-shell/app.json', + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/files.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + ['apps/mobile-shell/app.json', 'expo-image-picker'], + [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'captureMobileHostBridgeImageFile(request)', + ], + [ + 'apps/mobile-shell/src/host-bridge/files.ts', + 'ImagePicker.requestCameraPermissionsAsync', + ], + [ + 'apps/mobile-shell/src/host-bridge/files.ts', + 'ImagePicker.launchCameraAsync', + ], + ['apps/mobile-shell/scripts/check-config.mjs', 'file.captureImage'], + [nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/files.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'scanner.scanQrCode', + files: [ + 'apps/mobile-shell/app.json', + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/scanner.ts', + 'apps/mobile-shell/src/shell/QrScannerOverlay.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + ['apps/mobile-shell/app.json', 'expo-camera'], + [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'scanMobileHostBridgeQrCode(request)', + ], + [ + 'apps/mobile-shell/src/host-bridge/scanner.ts', + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', + ], + [ + 'apps/mobile-shell/src/shell/QrScannerOverlay.tsx', + 'Camera.requestCameraPermissionsAsync', + ], + ['apps/mobile-shell/src/shell/QrScannerOverlay.tsx', 'CameraView'], + ['apps/mobile-shell/scripts/check-config.mjs', 'scanner.scanQrCode'], + [nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/scanner.test.ts', + 'apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'network.statusChanged', + files: [ + 'apps/mobile-shell/src/shell/network.ts', + 'apps/mobile-shell/src/shell/ShellApp.tsx', + 'apps/mobile-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + [ + 'apps/mobile-shell/src/shell/network.ts', + 'Network.addNetworkStateListener', + ], + [ + 'apps/mobile-shell/src/shell/ShellApp.tsx', + "injectHostBridgeEvent('network.statusChanged', payload)", + ], + [ + 'apps/mobile-shell/src/shell/ShellApp.tsx', + "logMobileHostEventFailure('network.statusChanged', error)", + ], + ['apps/mobile-shell/scripts/check-config.mjs', 'network.statusChanged'], + [nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'], + ], + tests: [ + 'apps/mobile-shell/src/shell/network.test.ts', + 'apps/mobile-shell/src/shell/ShellApp.test.tsx', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, + { + capability: 'share.open', + files: [ + 'apps/mobile-shell/src/host-bridge/dispatch.ts', + 'apps/mobile-shell/src/host-bridge/share.ts', + 'apps/mobile-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'openShare(request)'], + ['apps/mobile-shell/src/host-bridge/share.ts', 'Share.share'], + [ + 'apps/mobile-shell/src/host-bridge/share.ts', + 'normalizeHostBridgeShareOpenPayload', + ], + ['apps/mobile-shell/scripts/check-config.mjs', 'share.open'], + [nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'], + ], + tests: [ + 'apps/mobile-shell/src/host-bridge/share.test.ts', + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + ], + }, +]; +const desktopCapabilityFlowContracts = [ + { + capability: 'host.getRuntime', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'desktop_host_bridge_runtime_response(&request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs', 'shell: "tauri_desktop"'], + ['apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs', 'capabilities: capabilities()'], + ['apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs', 'pub(crate) fn capabilities()'], + ['apps/desktop-shell/scripts/check-config.mjs', 'host.getRuntime'], + ], + }, + { + capability: 'appearance.getColorScheme', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs', + 'apps/desktop-shell/src-tauri/src/shell/runtime.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'desktop_appearance_color_scheme(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs', 'window.theme()'], + ['apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs', '"colorScheme": color_scheme'], + ['apps/desktop-shell/src-tauri/src/shell/runtime.rs', 'color_scheme_from_theme'], + ['apps/desktop-shell/scripts/check-config.mjs', 'appearance.getColorScheme'], + ], + }, + { + capability: 'host.events', + files: [ + 'apps/desktop-shell/src-tauri/src/shell/events.rs', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/shell/events.rs', 'const HOST_BRIDGE_EVENTS'], + ['apps/desktop-shell/src-tauri/src/shell/events.rs', "window.dispatchEvent(new MessageEvent('message'"], + ['apps/desktop-shell/src-tauri/src/app.rs', 'replay_desktop_webview_state(&window)'], + ['apps/desktop-shell/scripts/check-config.mjs', 'host.events'], + ], + }, + { + capability: 'app.lifecycle', + files: [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs', + 'apps/desktop-shell/src-tauri/src/shell/events.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/app.rs', 'register_desktop_lifecycle_events(&window)'], + ['apps/desktop-shell/src-tauri/src/shell/lifecycle.rs', 'resolve_desktop_lifecycle_payload'], + ['apps/desktop-shell/src-tauri/src/shell/lifecycle.rs', 'emit_current_desktop_lifecycle_event'], + ['apps/desktop-shell/src-tauri/src/shell/events.rs', '"app.lifecycle"'], + ['apps/desktop-shell/scripts/check-config.mjs', 'app.lifecycle'], + ], + }, + { + capability: 'share.setTarget', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/share.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'set_desktop_host_bridge_share_target(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/share.rs', 'share_text_from_value(target)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/share.rs', '*current_target = Some(target.clone())'], + ['apps/desktop-shell/scripts/check-config.mjs', 'share.setTarget'], + ], + }, + { + capability: 'navigation.openNativePage', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', + 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'open_desktop_host_bridge_native_page(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', 'desktop_native_page_url_from_request'], + ['apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', 'window.navigate(url)'], + ['apps/desktop-shell/src-tauri/src/shell/navigation.rs', 'normalize_native_page_url'], + ['apps/desktop-shell/scripts/check-config.mjs', 'navigation.openNativePage'], + ], + }, + { + capability: 'navigation.canGoBack', + files: [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', + 'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/app.rs', 'register_desktop_navigation_events(&window)?'], + ['apps/desktop-shell/src-tauri/src/shell/navigation.rs', 'desktop_navigation_state_script()'], + ['apps/desktop-shell/src-tauri/src/shell/navigation.rs', '"navigation.canGoBack"'], + ['apps/desktop-shell/src-tauri/src/shell/lifecycle.rs', 'register_desktop_navigation_events(window)'], + ['apps/desktop-shell/scripts/check-config.mjs', 'navigation.canGoBack'], + ], + }, + { + capability: 'app.reloadWebView', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'reload_desktop_host_bridge_webview(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', 'window.reload()'], + ['apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', 'webview reload unavailable'], + ['apps/desktop-shell/scripts/check-config.mjs', 'app.reloadWebView'], + ], + }, + { + capability: 'app.openExternalUrl', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', + 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-opener'], + ['apps/desktop-shell/src-tauri/src/app.rs', 'tauri_plugin_opener::init()'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'open_desktop_host_bridge_external_url(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs', 'desktop_external_url_from_request'], + ['apps/desktop-shell/src-tauri/src/shell/navigation.rs', 'app.opener()'], + ['apps/desktop-shell/scripts/check-config.mjs', 'app.openExternalUrl'], + ], + }, + { + capability: 'app.setTitle', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/title.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'set_desktop_host_bridge_window_title(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/title.rs', 'normalize_window_title'], + ['apps/desktop-shell/src-tauri/src/host_bridge/title.rs', 'window.set_title(&title)'], + ['apps/desktop-shell/scripts/check-config.mjs', 'app.setTitle'], + ], + }, + { + capability: 'app.setBadgeCount', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'set_desktop_app_badge_count(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/badge.rs', 'badge_count_payload(request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/badge.rs', 'window.set_badge_count(count)'], + ['apps/desktop-shell/scripts/check-config.mjs', 'app.setBadgeCount'], + ], + }, + { + capability: 'network.status', + files: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/network.rs', + 'apps/desktop-shell/src-tauri/src/shell/network.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'resolve_desktop_host_bridge_network_status(&request).await'], + ['apps/desktop-shell/src-tauri/src/host_bridge/network.rs', 'resolve_desktop_network_status'], + ['apps/desktop-shell/src-tauri/src/shell/network.rs', 'desktop_network_status_payload'], + ['apps/desktop-shell/scripts/check-config.mjs', 'network.status'], + ], + }, + { + capability: 'clipboard.writeText', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-clipboard-manager'], + ['apps/desktop-shell/src-tauri/src/app.rs', 'tauri_plugin_clipboard_manager::init()'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'write_desktop_host_bridge_clipboard_text(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', 'write_desktop_clipboard_text(app, text)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', 'app.clipboard()'], + ['apps/desktop-shell/scripts/check-config.mjs', 'clipboard.writeText'], + ], + }, + { + capability: 'clipboard.readText', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-clipboard-manager'], + ['apps/desktop-shell/src-tauri/src/app.rs', 'tauri_plugin_clipboard_manager::init()'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'read_desktop_host_bridge_clipboard_text(&app, &request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', 'read_desktop_clipboard_text(app)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', '"text": text'], + ['apps/desktop-shell/scripts/check-config.mjs', 'clipboard.readText'], + ], + }, + { + capability: 'file.exportText', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'export_desktop_host_bridge_text_file(&app, &request).await'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'export_text_payload(request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'write_export_text_file(path, content)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', 'export_text_payload'], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.exportText'], + ], + }, + { + capability: 'file.importText', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'import_desktop_host_bridge_text_file(&app, &request).await'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'import_text_file_payload(path)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', 'import_text_file_payload'], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.importText'], + ], + }, + { + capability: 'file.importImage', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'import_desktop_host_bridge_image_file(&app, &request).await'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'add_filter("Image", &["png", "jpg", "jpeg", "webp"])'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'import_image_file_payload(path, "selected", None)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', 'import_image_file_payload'], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.importImage'], + ], + }, + { + capability: 'file.importAudio', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'import_desktop_host_bridge_audio_file(&app, &request).await'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'import_audio_file_payload(path)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', 'import_audio_file_payload'], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.importAudio'], + ], + }, + { + capability: 'file.exportAudio', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', 'export_desktop_host_bridge_audio_file(&app, &request).await'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'export_audio_payload(request)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/files.rs', 'write_export_bytes_file(path, bytes)'], + ['apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', 'export_audio_payload'], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.exportAudio'], + ], + }, + { + capability: 'notification.showLocal', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'tauri-plugin-notification', + ], + [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'tauri_plugin_notification::init()', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'show_desktop_local_notification(&app, &request)', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs', + 'app.notification()', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs', + 'request_permission()', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs', + 'notification.show()', + ], + [ + 'apps/desktop-shell/scripts/check-config.mjs', + 'notification.showLocal', + ], + [nativeShellPlanPath, 'Tauri 壳'], + ], + }, + { + capability: 'file.importDocument', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/app.rs', 'tauri_plugin_dialog::init()'], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'import_desktop_host_bridge_document_file(&app, &request).await', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'add_filter(\n "Document"', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'blocking_pick_file()', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'import_document_file_payload(path)', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'import_document_file_payload', + ], + [ + 'apps/desktop-shell/scripts/check-config.mjs', + 'file.importDocument', + ], + [nativeShellPlanPath, 'Tauri 壳'], + ], + }, + { + capability: 'file.exportImage', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + ['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'], + ['apps/desktop-shell/src-tauri/src/app.rs', 'tauri_plugin_dialog::init()'], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'export_desktop_host_bridge_image_file(&app, &request).await', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'add_filter("Image", &["png", "jpg", "jpeg", "webp"])', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'blocking_save_file()', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/files.rs', + 'write_export_bytes_file(path, bytes)', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs', + 'export_image_payload', + ], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.exportImage'], + [nativeShellPlanPath, 'Tauri 壳'], + ], + }, + { + capability: 'file.imageDropped', + files: [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/shell/file_drop.rs', + 'apps/desktop-shell/src-tauri/src/shell/events.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'register_desktop_file_drop_events(&window)', + ], + [ + 'apps/desktop-shell/src-tauri/src/shell/file_drop.rs', + 'DragDropEvent::Drop', + ], + [ + 'apps/desktop-shell/src-tauri/src/shell/file_drop.rs', + 'host_bridge_event_script("file.imageDropped", payload)', + ], + [ + 'apps/desktop-shell/src-tauri/src/shell/file_drop.rs', + 'import_image_file_payload(path.clone(), "dropped", Some(position))', + ], + [ + 'apps/desktop-shell/src-tauri/src/shell/events.rs', + 'file.imageDropped', + ], + ['apps/desktop-shell/scripts/check-config.mjs', 'file.imageDropped'], + [nativeShellPlanPath, 'Tauri 壳'], + ], + }, + { + capability: 'share.open', + files: [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/share.rs', + 'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', + 'apps/desktop-shell/scripts/check-config.mjs', + nativeShellPlanPath, + ], + snippets: [ + [ + 'apps/desktop-shell/src-tauri/Cargo.toml', + 'tauri-plugin-clipboard-manager', + ], + [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'tauri_plugin_clipboard_manager::init()', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'open_desktop_host_bridge_share(&app, &request)', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/share.rs', + 'normalize_public_share_url', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/share.rs', + 'write_desktop_clipboard_text(app, &share_text)', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/share.rs', + '"action": "copied_to_clipboard"', + ], + [ + 'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs', + 'write_desktop_clipboard_text', + ], + ['apps/desktop-shell/scripts/check-config.mjs', 'share.open'], + [nativeShellPlanPath, 'Tauri 壳'], + ], + }, +]; +const h5NativeAppRouteFlowContracts = [ + { + route: '/child-motion-demo', + label: 'child motion demo', + files: [ + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + 'src/routing/appRoutes.tsx', + 'src/routing/appRoutes.test.ts', + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + ], + snippets: [ + [ + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + "navigateHostNativePage('/child-motion-demo')", + ], + [ + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + "window.location.assign('/child-motion-demo')", + ], + ['src/routing/appRoutes.tsx', "normalizedPath === '/child-motion-demo'"], + ['src/routing/appRoutes.test.ts', "matchAppRoute('/child-motion-demo')"], + [ + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + "request.method === 'navigation.openNativePage'", + ], + [ + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + "url === '/child-motion-demo'", + ], + ], + tests: [ + 'src/routing/appRoutes.test.ts', + ], + targetedTests: [ + { + filePath: 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + name: 'native app opens child motion demo through host navigation bridge', + }, + ], + }, +]; +const expectedWechatHostBridgeFiles = [ + 'dispatch.js', + 'payment.js', + 'payment.test.js', + 'protocol.js', + 'protocol.test.js', + 'shareGrid.js', + 'shareGrid.test.js', + 'subscribeMessage.js', + 'subscribeMessage.test.js', + 'webView.js', + 'webView.test.js', +]; +const expectedWechatShellFiles = [ + 'payment.js', + 'payment.test.js', + 'shareGrid.js', + 'shareGrid.test.js', + 'subscribeMessage.js', + 'subscribeMessage.test.js', + 'webView.js', + 'webView.test.js', +]; +const expectedWechatPageFilesByRoute = { + 'share-grid': ['index.js', 'index.json', 'index.wxml', 'index.wxss'], + 'subscribe-message': ['index.js', 'index.json', 'index.wxml', 'index.wxss'], + 'web-view': [ + 'index.js', + 'index.json', + 'index.style.test.js', + 'index.wxml', + 'index.wxss', + ], + 'wechat-pay': ['index.js', 'index.json', 'index.wxml', 'index.wxss'], +}; +const expectedMobileHostBridgeFiles = [ + 'appearance.test.ts', + 'appearance.ts', + 'badge.test.ts', + 'badge.ts', + 'bridge.test.ts', + 'bridge.ts', + 'capabilities.test.ts', + 'capabilities.ts', + 'clipboard.test.ts', + 'clipboard.ts', + 'dispatch.test.ts', + 'dispatch.ts', + 'filePayloads.test.ts', + 'filePayloads.ts', + 'files.test.ts', + 'files.ts', + 'haptics.test.ts', + 'haptics.ts', + 'navigation.test.ts', + 'navigation.ts', + 'network.test.ts', + 'network.ts', + 'notifications.test.ts', + 'notifications.ts', + 'protocol.test.ts', + 'protocol.ts', + 'runtime.test.ts', + 'runtime.ts', + 'scanner.test.ts', + 'scanner.ts', + 'share.test.ts', + 'share.ts', +]; +const expectedMobileSrcRootEntries = [ + 'dir:host-bridge', + 'dir:shell', + 'file:env.d.ts', +]; +const expectedMobileShellFiles = [ + 'QrScannerOverlay.test.tsx', + 'QrScannerOverlay.tsx', + 'ShellApp.test.tsx', + 'ShellApp.tsx', + 'deepLink.test.ts', + 'deepLink.ts', + 'lifecycle.test.ts', + 'lifecycle.ts', + 'loadFailure.test.ts', + 'loadFailure.ts', + 'navigation.test.ts', + 'navigation.ts', + 'network.test.ts', + 'network.ts', + 'runtime.test.ts', + 'runtime.ts', + 'safeArea.test.ts', + 'safeArea.ts', + 'url.test.ts', + 'url.ts', + 'webViewGlobals.d.ts', + 'webViewHistory.test.ts', + 'webViewHistory.ts', + 'webViewPolicy.test.ts', + 'webViewPolicy.ts', +]; +const expectedDesktopHostBridgeRustFiles = [ + 'appearance.rs', + 'badge.rs', + 'capabilities.rs', + 'clipboard.rs', + 'dispatch.rs', + 'file_payloads.rs', + 'files.rs', + 'mod.rs', + 'navigation.rs', + 'network.rs', + 'notifications.rs', + 'protocol.rs', + 'runtime.rs', + 'share.rs', + 'title.rs', +]; +const expectedDesktopShellRustFiles = [ + 'deep_link.rs', + 'events.rs', + 'file_drop.rs', + 'lifecycle.rs', + 'mod.rs', + 'navigation.rs', + 'network.rs', + 'runtime.rs', + 'tray.rs', + 'url.rs', + 'webview.rs', + 'window_state.rs', +]; +const expectedHostBridgeModuleTaxonomy = { + allShells: ['dispatch', 'protocol'], + nativeAppShells: [ + 'appearance', + 'badge', + 'capabilities', + 'clipboard', + 'file-payloads', + 'files', + 'navigation', + 'network', + 'notifications', + 'runtime', + 'share', + ], + mobileOnly: ['bridge', 'haptics', 'scanner'], + desktopOnly: ['mod', 'title'], + wechatOnly: ['payment', 'shareGrid', 'subscribeMessage', 'webView'], +}; +const documentedShellLayerGroups = [ + { + label: 'wechat host bridge files', + files: expectedWechatHostBridgeFiles + .filter((fileName) => !fileName.includes('.test.')) + .map((fileName) => `miniprogram/host-bridge/${fileName}`), + }, + { + label: 'wechat shell files', + files: expectedWechatShellFiles + .filter((fileName) => !fileName.includes('.test.')) + .map((fileName) => `miniprogram/shell/${fileName}`), + }, + { + label: 'wechat page wrapper files', + files: Object.entries(expectedWechatPageFilesByRoute).flatMap( + ([route, files]) => + files + .filter((fileName) => !fileName.includes('.test.')) + .map((fileName) => `miniprogram/pages/${route}/${fileName}`), + ), + }, + { + label: 'mobile src root entries', + files: ['apps/mobile-shell/src/env.d.ts'], + }, + { + label: 'mobile host bridge files', + files: expectedMobileHostBridgeFiles + .filter((fileName) => !fileName.includes('.test.')) + .map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`), + }, + { + label: 'mobile shell files', + files: expectedMobileShellFiles + .filter((fileName) => !fileName.includes('.test.')) + .map((fileName) => `apps/mobile-shell/src/shell/${fileName}`), + }, + { + label: 'desktop entrypoint files', + files: [ + 'apps/desktop-shell/src-tauri/src/app.rs', + 'apps/desktop-shell/src-tauri/src/main.rs', + ], + }, + { + label: 'desktop host bridge files', + files: expectedDesktopHostBridgeRustFiles.map( + (fileName) => `apps/desktop-shell/src-tauri/src/host_bridge/${fileName}`, + ), + }, + { + label: 'desktop shell files', + files: expectedDesktopShellRustFiles.map( + (fileName) => `apps/desktop-shell/src-tauri/src/shell/${fileName}`, + ), + }, +]; +const capabilityListMarkers = { + desktop: '桌面壳当前真实能力完整清单为', + desktopCurrentState: '当前真实能力为', + mobile: '移动壳当前通用真实能力完整清单为', + mobileCurrentState: '首轮真实能力包括', + mobileIosExtra: '移动壳 iOS 额外真实能力为', + wechat: '微信小程序壳当前真实能力完整清单为', +}; +const sharedHostBridgeContractPath = + 'packages/shared/src/contracts/hostBridge.ts'; +const productionShellExtensions = new Set([ + '.js', + '.json', + '.mjs', + '.plist', + '.rs', + '.toml', + '.ts', + '.tsx', + '.wxml', + '.wxss', +]); +const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']); +const productionShellExcludedSegments = new Set([ + '.expo', + '.expo-export-smoke', + 'node_modules', + 'target', + 'test-utils', +]); +const productionShellExcludedPaths = new Set([ + 'apps/desktop-shell/src-tauri/gen', + 'apps/desktop-shell/src-tauri/permissions/autogenerated', +]); +const generatedNativeShellArtifactPaths = [ + 'build/native', + 'dist', + 'apps/mobile-shell/.expo', + 'apps/mobile-shell/.expo-export-smoke', + 'apps/desktop-shell/src-tauri/target', + 'apps/desktop-shell/src-tauri/gen', + 'apps/desktop-shell/src-tauri/permissions/autogenerated', +]; +const generatedNativeShellArtifactIgnoreProbePaths = [ + 'build/native/probe', + 'dist/probe', + 'apps/mobile-shell/.expo/probe', + 'apps/mobile-shell/.expo-export-smoke/probe', + 'apps/desktop-shell/src-tauri/target/probe', + 'apps/desktop-shell/src-tauri/gen/probe', + 'apps/desktop-shell/src-tauri/permissions/autogenerated/probe', +]; +const productionShellDevScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'place' + 'holder', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '占' + '位', + '模' + '拟', + '伪' + '造', + '未' + '实现', + '临' + '时', + '后' + '续', +]; +const h5HostBridgeCallChainDevScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '模' + '拟', + '伪' + '造', +]; + +const h5HostBridgeTests = [ + 'packages/shared/src/contracts/hostBridge.test.ts', + 'src/services/host-bridge/hostBridge.test.ts', + 'src/services/host-bridge/nativeAppHostBridge.test.ts', + 'src/App.test.tsx', + 'src/components/auth/AuthGate.test.tsx', + 'src/hooks/useHostNavigationCanGoBack.test.tsx', + 'src/components/bark-battle-creation/BarkBattleResultView.test.tsx', + 'src/components/common/CreativeAudioInputPanel.test.tsx', + 'src/components/common/PublishShareModal.test.tsx', + 'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx', + 'src/components/creation-agent/CreationAgentWorkspace.test.tsx', + 'src/components/visual-novel-result/VisualNovelResultView.test.tsx', + 'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts', + 'src/components/platform-entry/platformHostBridgeSync.test.ts', + 'src/components/platform-entry/platformHostNotificationModel.test.ts', + 'src/routing/appRoutes.test.ts', + 'src/services/runtimeAudioFeedback.test.ts', + 'src/services/clipboard.test.ts', + 'src/services/appTitle.test.ts', +]; +const h5PlatformHostBridgeIntegrationTest = + 'native app jump hop draft completion sends host notification and badge from platform shell'; +const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap( + (contract) => + (contract.targetedTests ?? []).map((test) => ({ + label: `h5-native-app-route-${contract.route}`, + command: npmCommand, + args: ['run', 'test', '--', test.filePath, '-t', test.name], + })), +); + +const wechatShellTests = [ + 'miniprogram/host-bridge/protocol.test.js', + 'miniprogram/host-bridge/webView.test.js', + 'miniprogram/host-bridge/payment.test.js', + 'miniprogram/host-bridge/shareGrid.test.js', + 'miniprogram/host-bridge/subscribeMessage.test.js', + 'miniprogram/shell/webView.test.js', + 'miniprogram/shell/payment.test.js', + 'miniprogram/shell/shareGrid.test.js', + 'miniprogram/shell/subscribeMessage.test.js', + 'miniprogram/pages/web-view/index.style.test.js', + 'src/services/wechatMiniProgramSubscribe.test.ts', + 'scripts/miniprogram-web-view-auth.test.ts', +]; + +const steps = [ + { + label: 'h5-host-bridge-tests', + command: npmCommand, + args: ['run', 'test', '--', ...h5HostBridgeTests], + }, + { + label: 'h5-platform-host-bridge-integration', + command: npmCommand, + args: [ + 'run', + 'test', + '--', + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + '-t', + h5PlatformHostBridgeIntegrationTest, + ], + }, + ...h5NativeAppRouteFlowTestSteps, + { + label: 'wechat-shell-tests', + command: npmCommand, + args: ['run', 'test', '--', ...wechatShellTests], + }, + { + label: 'mobile-shell-typecheck', + command: npmCommand, + args: ['run', 'mobile-shell:typecheck'], + }, + { + label: 'mobile-shell-test', + command: npmCommand, + args: ['run', 'mobile-shell:test'], + }, + { + label: 'mobile-shell-eas-build-config-smoke', + command: npmCommand, + args: ['run', 'mobile-shell:build-config'], + }, + { + label: 'mobile-shell-expo-config-smoke', + command: npmCommand, + args: ['run', 'mobile-shell:config'], + }, + { + label: 'mobile-shell-expo-export-smoke', + command: npmCommand, + args: ['run', 'mobile-shell:export'], + }, + { + label: 'desktop-shell-typecheck', + command: npmCommand, + args: ['run', 'desktop-shell:typecheck'], + }, + { + label: 'desktop-shell-test', + command: npmCommand, + args: ['run', 'desktop-shell:test'], + }, + { + label: 'desktop-shell-release-build-smoke', + command: npmCommand, + args: ['run', 'desktop-shell:build', '--', '--no-bundle'], + }, + { + label: 'desktop-shell-stage-release-binary', + command: npmCommand, + args: ['run', 'desktop-shell:stage-release-binary'], + }, +]; + +function shouldScanProductionShellFile(filePath) { + const normalizedPath = filePath.split(path.sep).join('/'); + if ( + normalizedPath.includes('.test.') || + normalizedPath.endsWith('/scripts/check-config.mjs') + ) { + return false; + } + + return productionShellExtensions.has(path.extname(filePath)); +} + +function shouldScanH5ProductionSourceFile(filePath) { + const normalizedPath = filePath.split(path.sep).join('/'); + if ( + normalizedPath.includes('.test.') || + normalizedPath.includes('/test-utils/') || + normalizedPath.includes('/services/host-bridge/') + ) { + return false; + } + + return h5ProductionSourceExtensions.has(path.extname(filePath)); +} + +function assertNoTrackedGeneratedNativeShellArtifacts() { + const result = spawnSync('git', ['ls-files', ...generatedNativeShellArtifactPaths], { + cwd: process.cwd(), + encoding: 'utf8', + }); + if (result.error) { + throw new Error(`unable to check generated native shell artifacts: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `unable to check generated native shell artifacts: ${result.stderr.trim()}`, + ); + } + + const trackedArtifacts = result.stdout + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); + if (trackedArtifacts.length > 0) { + throw new Error( + `generated native shell artifacts must stay untracked: ${trackedArtifacts.join(', ')}`, + ); + } +} + +function assertGeneratedNativeShellArtifactsAreIgnored() { + const result = spawnSync( + 'git', + ['check-ignore', '-v', ...generatedNativeShellArtifactIgnoreProbePaths], + { + cwd: process.cwd(), + encoding: 'utf8', + }, + ); + if (result.error) { + throw new Error( + `unable to check generated native shell artifact gitignore rules: ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `generated native shell artifacts must be gitignored: ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + + const ignoredArtifacts = new Set( + result.stdout + .split('\n') + .map((entry) => entry.trim().split(/\t/).pop()) + .filter(Boolean), + ); + const missingArtifacts = generatedNativeShellArtifactIgnoreProbePaths.filter( + (artifactPath) => !ignoredArtifacts.has(artifactPath), + ); + if (missingArtifacts.length > 0) { + throw new Error( + `generated native shell artifacts missing gitignore coverage: ${missingArtifacts.join(', ')}`, + ); + } +} + +function collectProductionShellFiles(entryPath) { + const normalizedPath = entryPath.split(path.sep).join('/'); + if (productionShellExcludedPaths.has(normalizedPath)) { + return []; + } + + if (!fs.existsSync(entryPath)) { + throw new Error(`production shell scan path does not exist: ${entryPath}`); + } + + const stats = fs.statSync(entryPath); + if (stats.isDirectory()) { + const name = path.basename(entryPath); + if (productionShellExcludedSegments.has(name)) { + return []; + } + + return fs + .readdirSync(entryPath, { withFileTypes: true }) + .flatMap((entry) => collectProductionShellFiles(path.join(entryPath, entry.name))); + } + + return shouldScanProductionShellFile(entryPath) ? [entryPath] : []; +} + +function collectFiles(entryPath, shouldIncludeFile) { + if (!fs.existsSync(entryPath)) { + throw new Error(`scan path does not exist: ${entryPath}`); + } + + const stats = fs.statSync(entryPath); + if (stats.isDirectory()) { + const name = path.basename(entryPath); + if (productionShellExcludedSegments.has(name)) { + return []; + } + + return fs + .readdirSync(entryPath, { withFileTypes: true }) + .flatMap((entry) => collectFiles(path.join(entryPath, entry.name), shouldIncludeFile)); + } + + return shouldIncludeFile(entryPath) ? [entryPath] : []; +} + +function normalizeModulePath(modulePath) { + return modulePath.split(path.sep).join('/').replace(/\.(jsx?|tsx?)$/, ''); +} + +function importedModulePath(fromFile, specifier) { + if (specifier === '@') { + return '.'; + } + if (specifier.startsWith('@/')) { + return normalizeModulePath(specifier.slice(2)); + } + if (!specifier.startsWith('.')) { + return specifier; + } + + return normalizeModulePath(path.normalize(path.join(path.dirname(fromFile), specifier))); +} + +function extractImportSpecifiers(source) { + const specifiers = []; + for (const match of source.matchAll(/import\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g)) { + specifiers.push(match[1]); + } + for (const match of source.matchAll(/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+['"]([^'"]+)['"]/g)) { + specifiers.push(match[1]); + } + for (const match of source.matchAll(/import\s*['"]([^'"]+)['"]/g)) { + specifiers.push(match[1]); + } + + return specifiers; +} + +function extractNamedImportsFromModule(source, fromFile, targetModule) { + const importedNames = new Set(); + const importPattern = + /import\s+(?:type\s+)?(?:\{([\s\S]*?)\}|[A-Za-z0-9_$]+\s*,\s*\{([\s\S]*?)\})\s+from\s+['"]([^'"]+)['"]/g; + const exportPattern = + /export\s+(?:type\s+)?\{([\s\S]*?)\}\s+from\s+['"]([^'"]+)['"]/g; + const collectNamedImports = (importBody) => { + for (const rawImport of importBody.split(',')) { + const cleanedImport = rawImport + .replace(/\btype\s+/g, '') + .trim(); + if (!cleanedImport) { + continue; + } + importedNames.add(cleanedImport.split(/\s+as\s+/)[0]?.trim() ?? ''); + } + }; + + for (const match of source.matchAll(importPattern)) { + if (importedModulePath(fromFile, match[3]) !== targetModule) { + continue; + } + + collectNamedImports(match[1] ?? match[2] ?? ''); + } + + for (const match of source.matchAll(exportPattern)) { + if (importedModulePath(fromFile, match[2]) !== targetModule) { + continue; + } + + collectNamedImports(match[1] ?? ''); + } + + return importedNames; +} + +function collectH5HostBridgeCallChainFiles() { + const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile); + const wrapperModules = new Set( + h5HostBridgeCallChainWrapperFiles.map(normalizeModulePath), + ); + const scannedFiles = new Set(); + + for (const file of sourceFiles) { + const source = fs.readFileSync(file, 'utf8'); + const imports = extractImportSpecifiers(source).map((specifier) => + importedModulePath(file, specifier), + ); + const facadeImports = extractNamedImportsFromModule( + source, + file, + h5HostBridgeFacadeModule, + ); + const importsScannedFacadeCapability = [...facadeImports].some((importName) => + h5HostBridgeScannedFacadeImports.has(importName), + ); + if ( + importsScannedFacadeCapability || + imports.some((specifier) => wrapperModules.has(specifier)) + ) { + scannedFiles.add(file); + } + } + + for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) { + scannedFiles.add(wrapperFile); + } + + const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter( + (file) => !scannedFiles.has(file), + ); + if (missingRequiredFiles.length > 0) { + throw new Error( + `H5 HostBridge call chain scan is missing required files: ${missingRequiredFiles.join(', ')}`, + ); + } + + return [...scannedFiles].sort(); +} + +function assertH5NativeAppTransportFacadeBoundary() { + const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile); + const directTransportImports = []; + + for (const file of sourceFiles) { + const source = fs.readFileSync(file, 'utf8'); + const imports = extractImportSpecifiers(source).map((specifier) => + importedModulePath(file, specifier), + ); + if (imports.includes(h5NativeAppHostBridgeTransportModule)) { + directTransportImports.push(file); + } + } + + if (directTransportImports.length > 0) { + throw new Error( + `H5 production code must use the HostBridge facade instead of native app transport directly: ${directTransportImports.join(', ')}`, + ); + } +} + +function assertNoDevScaffoldTermsInFiles(files, terms) { + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const lowerSource = source.toLowerCase(); + for (const term of terms) { + const matchIndex = lowerSource.indexOf(term.toLowerCase()); + if (matchIndex === -1) { + continue; + } + + const line = + source.slice(0, matchIndex).split('\n').length; + throw new Error( + `production native shell source must not include ${term}: ${file}:${line}`, + ); + } + } +} + +function assertNoProductionShellDevScaffoldTerms() { + assertNoDevScaffoldTermsInFiles( + productionShellScanRoots.flatMap(collectProductionShellFiles), + productionShellDevScaffoldTerms, + ); + assertNoDevScaffoldTermsInFiles( + collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles), + h5HostBridgeCallChainDevScaffoldTerms, + ); +} + +function assertSameList(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some((value, index) => value !== expected[index]) + ) { + throw new Error( + `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, + ); + } +} + +function assertUniqueList(values, label) { + const seen = new Set(); + const duplicates = values.filter((value) => { + if (seen.has(value)) { + return true; + } + seen.add(value); + return false; + }); + if (duplicates.length > 0) { + throw new Error(`${label} must not contain duplicate entries: ${duplicates.join(', ')}`); + } +} + +function assertSameSet(actual, expected, label) { + const sortedActual = [...actual].sort(); + const sortedExpected = [...expected].sort(); + assertSameList(sortedActual, sortedExpected, label); +} + +function readDirectoryEntryList(directory, label) { + const entries = fs + .readdirSync(directory, { withFileTypes: true }) + .map((entry) => { + if (entry.isFile()) { + return `file:${entry.name}`; + } + if (entry.isDirectory()) { + return `dir:${entry.name}`; + } + throw new Error(`${label} must not contain special entries: ${entry.name}`); + }) + .sort(); + if (entries.length === 0) { + throw new Error(`${label} must not be empty`); + } + return entries; +} + +function readDirectoryFileList(directory, label) { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile()) { + throw new Error(`${label} must not contain nested entries: ${entry.name}`); + } + files.push(entry.name); + } + return files.sort(); +} + +function readDirectoryNameList(directory, label) { + const directories = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) { + throw new Error(`${label} must not contain root files: ${entry.name}`); + } + directories.push(entry.name); + } + return directories.sort(); +} + +function assertShellLayerLayoutDocumented(source, label) { + const canonicalMarker = '结构门禁按完整相对路径反查文档和目录'; + const pointerMarker = + '当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径'; + if (!source.includes(pointerMarker)) { + throw new Error(`${label} must point short shell file lists to the canonical full-path section`); + } + const canonicalStart = source.indexOf(canonicalMarker); + if (canonicalStart < 0) { + throw new Error(`${label} missing canonical shell layer layout section`); + } + const canonicalSource = source.slice(canonicalStart); + for (const group of documentedShellLayerGroups) { + for (const fileName of group.files) { + if (!canonicalSource.includes(fileName)) { + throw new Error(`${label} missing ${group.label}: ${fileName}`); + } + } + } +} + +function extractTsStringArray(source, exportName, seen = new Set()) { + if (seen.has(exportName)) { + throw new Error(`cyclic string array export ${exportName}`); + } + + const match = source.match( + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + const nextSeen = new Set(seen); + nextSeen.add(exportName); + const entries = []; + for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) { + if (entry[1]) { + entries.push(...extractTsStringArray(source, entry[1], nextSeen)); + } else { + entries.push(entry[2]); + } + } + + return entries; +} + +function extractRustCapabilities(source) { + const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/); + if (!match) { + throw new Error('unable to read desktop shell capabilities'); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function extractRustStringArray(source, constName) { + const match = source.match( + new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`), + ); + if (!match) { + throw new Error(`unable to read Rust ${constName}`); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function extractStringConst(source, constName) { + const match = source.match( + new RegExp(`const ${constName}\\s*=\\s*['"]([^'"]+)['"];`), + ); + if (!match) { + throw new Error(`unable to read string const ${constName}`); + } + + return match[1]; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function extractFunctionSource(source, functionName) { + const declarationStart = source.indexOf(`function ${functionName}`); + const exportedDeclarationStart = source.indexOf(`export function ${functionName}`); + const start = + exportedDeclarationStart === -1 + ? declarationStart + : exportedDeclarationStart; + if (start === -1) { + throw new Error(`unable to read function ${functionName}`); + } + + const openBrace = source.indexOf('{', start); + if (openBrace === -1) { + throw new Error(`unable to read function body ${functionName}`); + } + + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') { + depth += 1; + } else if (source[index] === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + } + + throw new Error(`unterminated function body ${functionName}`); +} + +function extractRustFunctionSource(source, functionName) { + const start = source.indexOf(`fn ${functionName}`); + if (start === -1) { + throw new Error(`unable to read Rust function ${functionName}`); + } + + const openBrace = source.indexOf('{', start); + if (openBrace === -1) { + throw new Error(`unable to read Rust function body ${functionName}`); + } + + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') { + depth += 1; + } else if (source[index] === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + } + + throw new Error(`unterminated Rust function body ${functionName}`); +} + +function extractTsStringObject(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Object.fromEntries( + [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ + entry[1], + entry[2], + ]), + ); +} + +function assertH5HostBridgeEventSubscriptionGates() { + const sharedContractSource = fs.readFileSync( + sharedHostBridgeContractPath, + 'utf8', + ); + const h5HostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/hostBridge.ts', + 'utf8', + ); + const sharedEvents = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_EVENTS', + ); + + assertSameList( + h5HostBridgeEventSubscriptionFacades.map((entry) => entry.eventName), + sharedEvents, + 'H5 HostBridge event subscription facade coverage', + ); + + const helperSource = extractFunctionSource( + h5HostBridgeSource, + 'canUseNativeHostEventCapability', + ); + if ( + !helperSource.includes("canUseNativeHostCapability('host.events')") || + !helperSource.includes('canUseNativeHostCapability(capability)') + ) { + throw new Error( + 'H5 HostBridge event capability helper must require host.events and the event capability', + ); + } + + const subscribedEvents = [ + ...h5HostBridgeSource.matchAll( + /subscribeNativeAppHostBridgeEvent(?:<[^>]+>)?\(\s*['"]([^'"]+)['"]/g, + ), + ].map((entry) => entry[1]); + assertSameList( + subscribedEvents, + sharedEvents, + 'H5 HostBridge subscribed event list', + ); + + for (const { functionName, eventName } of h5HostBridgeEventSubscriptionFacades) { + const functionSource = extractFunctionSource(h5HostBridgeSource, functionName); + if ( + !functionSource.includes(`canUseNativeHostEventCapability('${eventName}')`) + ) { + throw new Error( + `${functionName} must gate ${eventName} with host.events and the event capability`, + ); + } + + const directCapabilityPattern = new RegExp( + `canUseNativeHostCapability\\('${escapeRegExp(eventName)}'\\)`, + ); + if (directCapabilityPattern.test(functionSource)) { + throw new Error( + `${functionName} must not bypass canUseNativeHostEventCapability for ${eventName}`, + ); + } + } +} + +function assertH5HostBridgePayloadBoundaries() { + const sharedContractSource = fs.readFileSync( + 'packages/shared/src/contracts/hostBridge.ts', + 'utf8', + ); + const h5HostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/hostBridge.ts', + 'utf8', + ); + const h5HostBridgeTestSource = fs.readFileSync( + 'src/services/host-bridge/hostBridge.test.ts', + 'utf8', + ); + const nativeRequestCapabilities = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_METHODS', + ).filter((method) => method !== 'host.getRuntime'); + + for (const sharedBoundary of [ + 'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', + 'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', + 'HOST_BRIDGE_APP_TITLE_MAX_LENGTH', + ]) { + if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) { + throw new Error( + `H5 HostBridge facade must import shared HostBridge boundary ${sharedBoundary}`, + ); + } + if (new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(h5HostBridgeSource)) { + throw new Error( + `H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`, + ); + } + } + + for (const mimeLiteral of [ + "'text/plain'", + "'text/markdown'", + "'text/csv'", + "'application/json'", + "'image/png'", + "'image/jpeg'", + "'image/webp'", + "'audio/mpeg'", + "'audio/mp4'", + "'audio/wav'", + "'audio/ogg'", + "'audio/webm'", + ]) { + if (h5HostBridgeSource.includes(mimeLiteral)) { + throw new Error( + `H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`, + ); + } + } + + if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) { + throw new Error( + 'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', + ); + } + if (h5HostBridgeSource.includes('timeoutMs: 30000')) { + throw new Error( + 'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', + ); + } + if (h5HostBridgeSource.includes('timeoutMs: 60000')) { + throw new Error( + 'H5 HostBridge facade must use shared HOST_BRIDGE_SCANNER_TIMEOUT_MS', + ); + } + if ( + !h5HostBridgeSource.includes( + 'return normalizeHostBridgeExternalUrlPayload(trimmedUrl);', + ) || + !h5HostBridgeSource.includes( + 'return normalizeHostBridgeExternalUrlPayload(\n new URL(trimmedUrl, window.location.origin).toString(),\n );', + ) || + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostExternalUrlPayload(url);', + ) || + !h5HostBridgeSource.includes( + "return await requestNativeHostBoolean(\n 'app.openExternalUrl',\n normalizedPayload,\n );", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary', + ); + } + if ( + !h5HostBridgeSource.includes('function normalizeNativeAppPageUrl(url: string)') || + !h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") || + !h5HostBridgeSource.includes( + "nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN", + ) || + !h5HostBridgeSource.includes('const normalizedUrl = normalizeNativeAppPageUrl(url);') || + !h5HostBridgeSource.includes("{ url: normalizedUrl },") + ) { + throw new Error( + 'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage', + ); + } + if ( + !h5HostBridgeSource.includes( + "'[host-bridge] wechat mini program navigation failed'", + ) || + !h5HostBridgeSource.includes('reject(new Error(errorMessage));') || + h5HostBridgeSource.includes( + "console.error(\n '[host-bridge] wechat mini program navigation failed'", + ) || + h5HostBridgeSource.includes('reject(new Error(error?.errMsg || errorMessage));') + ) { + throw new Error( + 'H5 HostBridge facade must not expose wx.miniProgram.navigateTo native failures', + ); + } + for (const snippet of [ + 'hides wechat mini program navigation native failure details', + "errMsg: 'navigateTo:fail private native detail'", + "rejects.toThrow(\n '请在微信小程序内完成登录',", + "rejects.not.toThrow(\n 'private native detail',", + "expect(consoleError.mock.calls.flat()).not.toContain(navigationError)", + ]) { + if (!h5HostBridgeTestSource.includes(snippet)) { + throw new Error(`H5 HostBridge navigation failure test must include ${snippet}`); + } + } + if ( + !h5HostBridgeSource.includes( + 'absolutizeHostSharePayloadUrls(params),', + ) || + !h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") || + !h5HostBridgeSource.includes( + "return await requestNativeHostBoolean(\n 'share.open',\n normalizedPayload.payload,\n );", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize share.open payloads with the shared share boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostBridgeShareOpenPayload(message);', + ) || + !h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") || + !h5HostBridgeSource.includes("'share.setTarget', {\n target: message,") + ) { + throw new Error( + 'H5 HostBridge facade must validate native share.setTarget payloads before sending them to native shells', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostBridgeExportTextPayload(params);', + ) || + !h5HostBridgeSource.includes( + "'file.exportText',\n normalizedPayload,", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize file.exportText payloads with the shared text export boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostBridgeExportImagePayload(params);', + ) || + !h5HostBridgeSource.includes( + "'file.exportImage',\n normalizedPayload,", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize file.exportImage payloads with the shared image export boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);', + ) || + !h5HostBridgeSource.includes( + "'file.exportAudio',\n normalizedPayload,", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize file.exportAudio payloads with the shared audio export boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const clipboardText = normalizeHostBridgeClipboardText(text);', + ) || + !h5HostBridgeSource.includes( + "return await requestNativeHostBoolean('clipboard.writeText', clipboardText);", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize clipboard.writeText payloads with the shared clipboard boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const clipboardText = normalizeHostBridgeClipboardText(', + ) || + !h5HostBridgeSource.includes( + "await requestNativeAppHostBridge(\n 'clipboard.readText',", + ) || + !h5HostBridgeSource.includes( + 'return clipboardText ?? false;', + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize clipboard.readText results with the shared clipboard boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const style = normalizeHostBridgeHapticsImpactStyle(params.style);', + ) || + !h5HostBridgeSource.includes( + "return await requestNativeHostBoolean('haptics.impact', { style });", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize haptics.impact payloads with the shared style boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'colorScheme: normalizeHostBridgeColorScheme(result?.colorScheme),', + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize appearance.getColorScheme results with the shared color scheme boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const connectionType = normalizeHostBridgeConnectionType(\n payload?.connectionType ?? payload?.nativeType,\n );', + ) || + !h5HostBridgeSource.includes( + 'listener(normalizeHostNetworkStatus(payload));', + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize network.status results and network.statusChanged events with shared network boundaries', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const normalizedTitle = normalizeHostBridgeAppTitle(title);', + ) || + !h5HostBridgeSource.includes( + "return await requestNativeHostBoolean('app.setTitle', normalizedTitle);", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary', + ); + } + for (const nativeCapability of nativeRequestCapabilities) { + if ( + h5HostBridgeSource.includes( + `runtime.hostCapabilities.includes('${nativeCapability}')`, + ) || + h5HostBridgeSource.includes( + `runtime.hostCapabilities.includes("${nativeCapability}")`, + ) + ) { + throw new Error( + `H5 HostBridge facade must gate ${nativeCapability} through canUseNativeHostCapability()`, + ); + } + if ( + !h5HostBridgeSource.includes( + `canUseNativeHostCapability('${nativeCapability}')`, + ) + ) { + throw new Error( + `H5 HostBridge facade must check ${nativeCapability} with canUseNativeHostCapability()`, + ); + } + } + for (const importBoundary of [ + { + functionName: 'importHostTextFile', + normalizer: 'normalizeHostBridgeImportTextResult', + preserveCancellation: true, + }, + { + functionName: 'importHostDocumentFile', + normalizer: 'normalizeHostBridgeImportDocumentResult', + preserveCancellation: true, + }, + { + functionName: 'importHostImageFile', + normalizer: 'normalizeHostBridgeImportImageResult', + preserveCancellation: true, + }, + { + functionName: 'captureHostImageFile', + normalizer: 'normalizeHostBridgeImportImageResult', + preserveCancellation: true, + }, + { + functionName: 'importHostAudioFile', + normalizer: 'normalizeHostBridgeImportAudioResult', + preserveCancellation: true, + }, + { + functionName: 'subscribeHostImageDrop', + normalizer: 'normalizeHostBridgeImportImageResult', + preserveCancellation: false, + }, + ]) { + const functionSource = extractFunctionSource( + h5HostBridgeSource, + importBoundary.functionName, + ); + if (!functionSource.includes(`${importBoundary.normalizer}(`)) { + throw new Error( + `${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`, + ); + } + if ( + importBoundary.preserveCancellation && + (!functionSource.includes('isCancelledHostBridgeError(error)') || + !functionSource.includes('return null;')) + ) { + throw new Error( + `${importBoundary.functionName} must preserve native user cancellation as null`, + ); + } + } + const unsupportedHelperSource = extractFunctionSource( + h5HostBridgeSource, + 'isUnsupportedHostBridgeError', + ); + if (unsupportedHelperSource.includes("'cancelled'")) { + throw new Error( + 'H5 HostBridge facade must not treat native user cancellation as unsupported capability', + ); + } + for (const localMimeSet of [ + 'HOST_BRIDGE_TEXT_MIME_TYPE_SET', + 'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET', + 'HOST_BRIDGE_IMAGE_MIME_TYPE_SET', + 'HOST_BRIDGE_AUDIO_MIME_TYPE_SET', + ]) { + if (h5HostBridgeSource.includes(localMimeSet)) { + throw new Error( + `H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`, + ); + } + } +} + +function assertH5NativeAppTransportTimeoutBoundaries() { + const nativeAppHostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/nativeAppHostBridge.ts', + 'utf8', + ); + + for (const sharedBoundary of [ + 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', + 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', + ]) { + if (!nativeAppHostBridgeSource.includes(`${sharedBoundary},`)) { + throw new Error( + `H5 native app transport must import shared timeout boundary ${sharedBoundary}`, + ); + } + } + + for (const staleTimeoutBoundary of [ + 'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS', + 'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS', + ]) { + if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) { + throw new Error( + `H5 native app transport must not redeclare timeout boundary ${staleTimeoutBoundary}`, + ); + } + } +} + +function assertH5NativeAppMessageSourceBoundaries() { + const nativeAppHostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/nativeAppHostBridge.ts', + 'utf8', + ); + const nativeAppHostBridgeTestSource = fs.readFileSync( + 'src/services/host-bridge/nativeAppHostBridge.test.ts', + 'utf8', + ); + + const requiredSourceSnippets = [ + 'function isNativeInjectedMessageEvent(event: MessageEvent)', + 'event.source && event.source !== nativeWindow', + 'event.origin && event.origin !== nativeWindow.location.origin', + 'if (!isNativeInjectedMessageEvent(event))', + ]; + for (const snippet of requiredSourceSnippets) { + if (!nativeAppHostBridgeSource.includes(snippet)) { + throw new Error( + `H5 native app transport must verify injected message source and origin: ${snippet}`, + ); + } + } + + const requiredTestSnippets = [ + '忽略非当前窗口来源伪造的 HostBridge 回包', + 'source: channel.port1', + '忽略非当前页面来源伪造的宿主事件', + "origin: 'https://sandbox.genarrative.invalid'", + 'expect(listener).not.toHaveBeenCalled();', + ]; + for (const snippet of requiredTestSnippets) { + if (!nativeAppHostBridgeTestSource.includes(snippet)) { + throw new Error( + `H5 native app transport source boundary test must include ${snippet}`, + ); + } + } +} + +function extractDocumentCapabilityList(source, marker) { + return extractDocumentCapabilityListBefore(source, marker, '。'); +} + +function extractDocumentCapabilityListBefore(source, marker, terminator) { + const markerIndex = source.indexOf(marker); + if (markerIndex === -1) { + throw new Error(`native shell plan missing ${marker}`); + } + + const sentenceEnd = source.indexOf(terminator, markerIndex); + const sentence = source.slice( + markerIndex, + sentenceEnd === -1 ? undefined : sentenceEnd, + ); + return [...sentence.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]); +} + +function extractDocumentMethodTable(source) { + const start = source.indexOf('首批 method:'); + if (start === -1) { + throw new Error('native shell plan missing HostBridge method table'); + } + + const end = source.indexOf('每个 method 都必须', start); + if (end === -1) { + throw new Error('native shell plan method table missing end marker'); + } + + return [...source.slice(start, end).matchAll(/^\| `([^`]+)` \|/gm)].map( + (entry) => entry[1], + ); +} + +function assertNativeShellScaffoldScanWording(source, label) { + if ( + source.includes('三端生产壳临时替身词扫描') || + source.includes('三端壳生产源码') || + source.includes('壳生产源码禁替身') || + !source.includes('H5 HostBridge 真实调用链的临时替身词扫描') + ) { + throw new Error( + `${label} must document production scaffold scanning for the H5 HostBridge call chain`, + ); + } +} + +function assertNativeShellCapabilityPlan() { + const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8'); + const hostBridgeProtocolDocSource = fs.readFileSync( + hostBridgeProtocolDocPath, + 'utf8', + ); + const developmentWorkflowDocSource = fs.readFileSync( + developmentWorkflowDocPath, + 'utf8', + ); + const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8'); + if ( + planSource.includes('permissions` 必须只包含 `core:default`') || + planSource.includes('permissions=["core:default","allow-host-bridge-request"]') + ) { + throw new Error( + 'native shell plan must not document core:default as a desktop capability permission', + ); + } + if (!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')) { + throw new Error( + 'native shell plan must document the minimal desktop capability permission', + ); + } + for (const staleShareWording of [ + '深链、系统分享、即时本地通知', + '重复执行支付、登录、系统分享、文件导入导出', + '发布分享弹窗只有声明 `share.open` 时才显示“系统分享”', + '发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作', + ]) { + if ( + planSource.includes(staleShareWording) || + hostBridgeProtocolDocSource.includes(staleShareWording) || + decisionLogDocSource.includes(staleShareWording) + ) { + throw new Error( + `native shell docs must describe share.open as a host-specific controlled share action: ${staleShareWording}`, + ); + } + } + for (const requiredShareWording of [ + '深链、受控分享动作、即时本地通知', + '重复 `id` 不得重复执行支付、登录、受控分享动作、文件导入导出', + '发布分享弹窗在 Expo 移动壳声明 `share.open` 时提供“系统分享”动作', + '发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”', + '并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达', + ]) { + if ( + !planSource.includes(requiredShareWording) && + !hostBridgeProtocolDocSource.includes(requiredShareWording) && + !decisionLogDocSource.includes(requiredShareWording) + ) { + throw new Error( + `native shell docs missing host-specific share.open wording: ${requiredShareWording}`, + ); + } + } + for (const staleMobilePermissionText of [ + 'Android `permissions` 不手写显式权限', + '最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`', + '移动拍摄不请求麦克风权限', + ]) { + if ( + planSource.includes(staleMobilePermissionText) || + hostBridgeProtocolDocSource.includes(staleMobilePermissionText) + ) { + throw new Error( + `native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`, + ); + } + } + assertNativeShellScaffoldScanWording(planSource, 'native shell plan'); + assertNativeShellScaffoldScanWording( + hostBridgeProtocolDocSource, + 'HostBridge protocol document', + ); + assertNativeShellScaffoldScanWording( + developmentWorkflowDocSource, + 'development workflow document', + ); + assertNativeShellScaffoldScanWording( + decisionLogDocSource, + 'decision log document', + ); + assertShellLayerLayoutDocumented(planSource, 'native shell plan'); + assertShellLayerLayoutDocumented( + hostBridgeProtocolDocSource, + 'HostBridge protocol document', + ); + + const desktopCapabilitySource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs', + 'utf8', + ); + const sharedContractSource = fs.readFileSync(sharedHostBridgeContractPath, 'utf8'); + const sharedMethods = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_METHODS'); + const sharedEvents = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EVENTS'); + const sharedWechatCapabilities = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES', + ); + const mobileCapabilities = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', + ); + const iosMobileCapabilities = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', + ); + const iosExtraCapabilities = iosMobileCapabilities.filter( + (capability) => !mobileCapabilities.includes(capability), + ); + const sharedDesktopCapabilities = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', + ); + const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource); + const desktopEventSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/shell/events.rs', + 'utf8', + ); + const mobileDispatchTestSource = fs.readFileSync( + 'apps/mobile-shell/src/host-bridge/dispatch.test.ts', + 'utf8', + ); + const mobileBridgeTestSource = fs.readFileSync( + 'apps/mobile-shell/src/host-bridge/bridge.test.ts', + 'utf8', + ); + const desktopDispatchSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs', + 'utf8', + ); + const wechatProtocol = requireCommonJsModule( + 'miniprogram/host-bridge/protocol.js', + ); + + assertSameList( + wechatProtocol.WECHAT_HOST_CAPABILITIES ?? [], + sharedWechatCapabilities, + 'wechat mini program runtime capability profile', + ); + + const desktopEventWhitelist = extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'); + const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) => + sharedEvents.includes(capability), + ); + assertSameList( + desktopEventWhitelist, + sharedDesktopEvents, + 'desktop shell runtime event whitelist', + ); + if (sharedDesktopCapabilities.includes('network.statusChanged')) { + throw new Error( + 'desktop shell must not declare network.statusChanged until Rust owns a real event source', + ); + } + if (desktopEventWhitelist.includes('network.statusChanged')) { + throw new Error( + 'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source', + ); + } + + assertSameList( + desktopCapabilities, + sharedDesktopCapabilities, + 'desktop shell runtime capability profile', + ); + for (const [source, label, snippets] of [ + [ + mobileDispatchTestSource, + 'mobile dispatch undeclared method test', + [ + 'HOST_BRIDGE_METHODS.filter', + '!HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)', + "expect(response.error.code).toBe('unsupported_method')", + ], + ], + [ + mobileBridgeTestSource, + 'mobile bridge undeclared method test', + [ + 'HOST_BRIDGE_METHODS.filter', + '!HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)', + "expect(failedResponse.error.code).toBe('unsupported_method')", + ], + ], + ]) { + for (const snippet of snippets) { + if (!source.includes(snippet)) { + throw new Error(`${label} must derive unsupported methods from shared method list`); + } + } + } + for (const snippet of [ + 'HOST_BRIDGE_METHODS\n .iter()', + '.filter(|method| !desktop_capabilities.contains(method))', + 'let response = resolve_host_bridge_request(request(method));', + 'assert_eq!(error.code, "unsupported_method");', + ]) { + if (!desktopDispatchSource.includes(snippet)) { + throw new Error('desktop dispatch test must derive unsupported methods from Rust capability list'); + } + } + + assertSameList( + extractDocumentMethodTable(planSource), + [ + ...sharedMethods.slice(0, 8), + 'app.lifecycle', + 'navigation.canGoBack', + ...sharedMethods.slice(8, 12), + 'network.statusChanged', + ...sharedMethods.slice(12, 22), + sharedMethods[22], + 'file.imageDropped', + ...sharedMethods.slice(23), + ], + 'native shell documented method table', + ); + assertSameList( + extractDocumentCapabilityList(planSource, capabilityListMarkers.wechat), + sharedWechatCapabilities, + 'wechat mini program documented capabilities', + ); + assertSameList( + extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile), + mobileCapabilities, + 'mobile shell documented common capabilities', + ); + assertSameSet( + extractDocumentCapabilityListBefore( + planSource, + capabilityListMarkers.mobileCurrentState, + ';', + ).filter((capability) => capability !== 'Android 返回键回退'), + mobileCapabilities, + 'mobile shell current state documented capabilities', + ); + assertSameList( + extractDocumentCapabilityList(planSource, capabilityListMarkers.mobileIosExtra), + iosExtraCapabilities, + 'mobile shell documented iOS extra capabilities', + ); + assertSameList( + extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop), + desktopCapabilities, + 'desktop shell documented capabilities', + ); + assertSameSet( + extractDocumentCapabilityListBefore( + planSource, + capabilityListMarkers.desktopCurrentState, + ';', + ), + desktopCapabilities, + 'desktop shell current state documented capabilities', + ); +} + +function assertExternalUrlProtocolParity() { + const sharedContractSource = fs.readFileSync( + sharedHostBridgeContractPath, + 'utf8', + ); + const desktopNavigationSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', + 'utf8', + ); + + assertSameList( + extractRustStringArray(desktopNavigationSource, 'EXTERNAL_URL_PROTOCOLS'), + extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS'), + 'desktop shell external URL protocol list', + ); +} + +function requireCommonJsModule(modulePath) { + const absolutePath = path.resolve(modulePath); + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + URL, + URLSearchParams, + }; + + vm.runInNewContext(fs.readFileSync(absolutePath, 'utf8'), sandbox, { + filename: absolutePath, + }); + + return module.exports; +} + +function pageRouteFromMiniProgramUrl(url) { + return String(url).split('?')[0].replace(/^\//, ''); +} + +function assertHttpsDomain(value, label) { + const trimmed = String(value ?? '').trim(); + let url; + try { + url = new URL(trimmed); + } catch { + throw new Error(`${label} must be a valid HTTPS domain`); + } + + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.pathname !== '/' || + url.search || + url.hash || + (trimmed !== url.origin && trimmed !== `${url.origin}/`) || + url.hostname === 'localhost' || + /^\d+\.\d+\.\d+\.\d+$/u.test(url.hostname) || + url.hostname.includes(':') || + !url.hostname.includes('.') + ) { + throw new Error(`${label} must be an HTTPS domain without path or query`); + } +} + +function assertWechatMiniProgramRouteParity() { + const sharedContractSource = fs.readFileSync( + sharedHostBridgeContractPath, + 'utf8', + ); + const appConfig = JSON.parse(fs.readFileSync('miniprogram/app.json', 'utf8')); + const runtimeConfig = requireCommonJsModule('miniprogram/config.js'); + const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js'); + const webViewBridgeSource = fs.readFileSync( + 'miniprogram/host-bridge/webView.js', + 'utf8', + ); + const webViewShellSource = fs.readFileSync( + 'miniprogram/shell/webView.js', + 'utf8', + ); + const appPageRoutesSource = fs.readFileSync( + 'src/routing/appPageRoutes.ts', + 'utf8', + ); + const h5HostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/hostBridge.ts', + 'utf8', + ); + const h5SubscribeSource = fs.readFileSync( + 'src/services/wechatMiniProgramSubscribe.ts', + 'utf8', + ); + + assertSameList( + appConfig.pages ?? [], + [ + protocol.WECHAT_WEB_VIEW_PAGE_URL, + protocol.WECHAT_SHARE_GRID_PAGE_URL, + protocol.WECHAT_PAY_PAGE_URL, + protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL, + ].map(pageRouteFromMiniProgramUrl), + 'wechat mini program app pages', + ); + + const h5RoutePairs = [ + ['MINI_PROGRAM_AUTH_PAGE_URL', protocol.WECHAT_AUTH_PAGE_URL], + ['MINI_PROGRAM_PAY_PAGE_URL', protocol.WECHAT_PAY_PAGE_URL], + ['MINI_PROGRAM_SHARE_GRID_PAGE_URL', protocol.WECHAT_SHARE_GRID_PAGE_URL], + ]; + for (const [constName, expectedValue] of h5RoutePairs) { + const actualValue = extractStringConst(h5HostBridgeSource, constName); + if (actualValue !== expectedValue) { + throw new Error( + `H5 HostBridge ${constName} drifted: expected ${expectedValue} but got ${actualValue}`, + ); + } + } + + const h5SubscribePageUrl = extractStringConst( + h5SubscribeSource, + 'MINI_PROGRAM_SUBSCRIBE_MESSAGE_PAGE_URL', + ); + if (h5SubscribePageUrl !== protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL) { + throw new Error( + `H5 subscribe page URL drifted: expected ${protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL} but got ${h5SubscribePageUrl}`, + ); + } + + if ( + extractStringConst(webViewBridgeSource, 'WEB_VIEW_SHARE_PATH') !== + protocol.WECHAT_WEB_VIEW_PAGE_URL + ) { + throw new Error('wechat mini program share path must use the web-view page route'); + } + + if ( + extractStringConst(webViewBridgeSource, 'SHARE_TARGET_MESSAGE_TYPE') !== + protocol.WECHAT_SHARE_TARGET_MESSAGE_TYPE + ) { + throw new Error('wechat mini program share target message type drifted'); + } + + for (const [configKey, label] of [ + ['WEB_VIEW_ENTRY_URL', 'wechat release web-view entry URL'], + ['DEV_WEB_VIEW_ENTRY_URL', 'wechat dev web-view entry URL'], + ['API_BASE_URL', 'wechat release API base URL'], + ['DEV_API_BASE_URL', 'wechat dev API base URL'], + ]) { + assertHttpsDomain(runtimeConfig[configKey], label); + } + + const sourceQuery = runtimeConfig.WEB_VIEW_SOURCE_QUERY ?? {}; + const sharedRuntimeContextQueryKey = extractTsStringObject( + sharedContractSource, + 'HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY', + ); + const sharedPreservedRuntimeContextQueryKeys = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', + ); + const sharedWechatSourceQuery = extractTsStringObject( + sharedContractSource, + 'HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY', + ); + assertSameList( + Object.keys(sourceQuery), + Object.keys(sharedWechatSourceQuery), + 'wechat web-view source query keys', + ); + if ( + Object.entries(sharedWechatSourceQuery).some( + ([key, value]) => sourceQuery[key] !== value, + ) + ) { + throw new Error('wechat web-view source query drifted from HostBridge runtime markers'); + } + assertSameList( + sharedPreservedRuntimeContextQueryKeys, + [ + sharedRuntimeContextQueryKey.clientType, + sharedRuntimeContextQueryKey.clientRuntime, + sharedRuntimeContextQueryKey.miniProgramEnv, + sharedRuntimeContextQueryKey.hostShell, + sharedRuntimeContextQueryKey.hostPlatform, + sharedRuntimeContextQueryKey.hostVersion, + sharedRuntimeContextQueryKey.bridgeVersion, + sharedRuntimeContextQueryKey.hostCapabilities, + ], + 'preserved HostBridge runtime context query keys', + ); + if ( + !appPageRoutesSource.includes( + 'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', + ) || + !appPageRoutesSource.includes( + 'APP_RUNTIME_CONTEXT_QUERY_KEYS =\n HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', + ) + ) { + throw new Error('H5 app routes must preserve HostBridge runtime context keys from shared contract'); + } + if ( + !h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY') || + h5HostBridgeSource.includes("params.get('clientType')") || + h5HostBridgeSource.includes("params.get('clientRuntime')") || + h5HostBridgeSource.includes("params.get('hostCapabilities')") || + h5HostBridgeSource.includes("params.get('miniProgramEnv')") + ) { + throw new Error('H5 HostBridge runtime parser must read query keys from shared contract'); + } + for (const snippet of [ + "readWebViewSourceQueryValue('clientType')", + "readWebViewSourceQueryValue('clientRuntime')", + ]) { + if (!webViewShellSource.includes(snippet)) { + throw new Error('wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY'); + } + } + if ( + webViewShellSource.includes('MINI_PROGRAM_CLIENT_TYPE') || + webViewShellSource.includes('MINI_PROGRAM_CLIENT_RUNTIME') + ) { + throw new Error('wechat request runtime markers must not be duplicated outside WEB_VIEW_SOURCE_QUERY'); + } +} + +function assertFileIncludesSnippet(filePath, snippet, label) { + const source = fs.readFileSync(filePath, 'utf8'); + if (!source.includes(snippet)) { + throw new Error(`${label} must include ${snippet} in ${filePath}`); + } +} + +function assertWechatMiniProgramCapabilityFlows() { + const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js'); + const declaredCapabilities = protocol.WECHAT_HOST_CAPABILITIES ?? []; + const declaredCapabilitySet = new Set(declaredCapabilities); + const contractedCapabilities = wechatCapabilityFlowContracts.map( + ({ capability }) => capability, + ); + + assertSameList( + contractedCapabilities, + declaredCapabilities, + 'wechat mini program capability flow contracts', + ); + + const wechatShellTestSet = new Set(wechatShellTests); + for (const contract of wechatCapabilityFlowContracts) { + if (!declaredCapabilitySet.has(contract.capability)) { + throw new Error( + `wechat capability flow contract declared for missing capability: ${contract.capability}`, + ); + } + + for (const filePath of contract.files) { + if (!fs.existsSync(filePath)) { + throw new Error( + `wechat ${contract.capability} flow file is missing: ${filePath}`, + ); + } + } + + for (const [filePath, snippet] of contract.snippets) { + assertFileIncludesSnippet( + filePath, + snippet, + `wechat ${contract.capability} flow`, + ); + } + + for (const testPath of contract.tests) { + if (!wechatShellTestSet.has(testPath)) { + throw new Error( + `wechat ${contract.capability} flow test is not part of check:native-shells: ${testPath}`, + ); + } + } + } +} + +function assertExpoMobileCapabilityFlows() { + const sharedContractSource = fs.readFileSync(sharedHostBridgeContractPath, 'utf8'); + const declaredCapabilities = [ + ...extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', + ), + ...extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES', + ), + ]; + const declaredCapabilitySet = new Set(declaredCapabilities); + const contractedCapabilities = mobileCapabilityFlowContracts.map( + ({ capability }) => capability, + ); + + assertUniqueList( + contractedCapabilities, + 'Expo mobile capability flow contracts', + ); + const missingCapabilityContracts = declaredCapabilities.filter( + (capability) => !contractedCapabilities.includes(capability), + ); + if (missingCapabilityContracts.length > 0) { + throw new Error( + `Expo mobile declared capabilities missing flow contracts: ${missingCapabilityContracts.join(', ')}`, + ); + } + + const mobileShellTestSet = new Set( + expectedMobileHostBridgeFiles + .filter((fileName) => fileName.includes('.test.')) + .map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`) + .concat( + expectedMobileShellFiles + .filter((fileName) => fileName.includes('.test.')) + .map((fileName) => `apps/mobile-shell/src/shell/${fileName}`), + ), + ); + + for (const contract of mobileCapabilityFlowContracts) { + if (!declaredCapabilitySet.has(contract.capability)) { + throw new Error( + `Expo mobile capability flow contract declared for missing capability: ${contract.capability}`, + ); + } + + for (const filePath of contract.files) { + if (!fs.existsSync(filePath)) { + throw new Error( + `Expo mobile ${contract.capability} flow file is missing: ${filePath}`, + ); + } + } + + for (const [filePath, snippet] of contract.snippets) { + assertFileIncludesSnippet( + filePath, + snippet, + `Expo mobile ${contract.capability} flow`, + ); + } + + for (const testPath of contract.tests) { + if (!mobileShellTestSet.has(testPath)) { + throw new Error( + `Expo mobile ${contract.capability} flow test is not part of mobile-shell:test: ${testPath}`, + ); + } + } + } +} + +function assertTauriDesktopCapabilityFlows() { + const sharedContractSource = fs.readFileSync(sharedHostBridgeContractPath, 'utf8'); + const declaredCapabilities = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', + ); + const declaredCapabilitySet = new Set(declaredCapabilities); + const contractedCapabilities = desktopCapabilityFlowContracts.map( + ({ capability }) => capability, + ); + + assertUniqueList( + contractedCapabilities, + 'Tauri desktop capability flow contracts', + ); + const missingCapabilityContracts = declaredCapabilities.filter( + (capability) => !contractedCapabilities.includes(capability), + ); + if (missingCapabilityContracts.length > 0) { + throw new Error( + `Tauri desktop declared capabilities missing flow contracts: ${missingCapabilityContracts.join(', ')}`, + ); + } + const desktopShellTestStep = steps.some( + (step) => + step.label === 'desktop-shell-test' && + step.args[0] === 'run' && + step.args[1] === 'desktop-shell:test', + ); + if (!desktopShellTestStep) { + throw new Error('Tauri desktop capability flows must be guarded by desktop-shell:test'); + } + + for (const contract of desktopCapabilityFlowContracts) { + if (!declaredCapabilitySet.has(contract.capability)) { + throw new Error( + `Tauri desktop capability flow contract declared for missing capability: ${contract.capability}`, + ); + } + + for (const filePath of contract.files) { + if (!fs.existsSync(filePath)) { + throw new Error( + `Tauri desktop ${contract.capability} flow file is missing: ${filePath}`, + ); + } + } + + for (const [filePath, snippet] of contract.snippets) { + assertFileIncludesSnippet( + filePath, + snippet, + `Tauri desktop ${contract.capability} flow`, + ); + } + + const testedRustFlowFiles = contract.files.filter((filePath) => { + if (!filePath.startsWith('apps/desktop-shell/src-tauri/src/') || !filePath.endsWith('.rs')) { + return false; + } + const source = fs.readFileSync(filePath, 'utf8'); + return source.includes('#[test]'); + }); + if (testedRustFlowFiles.length === 0) { + throw new Error( + `Tauri desktop ${contract.capability} flow must include at least one Rust unit-tested module`, + ); + } + } +} + +function assertH5NativeAppRouteFlows() { + const h5HostBridgeTestSet = new Set(h5HostBridgeTests); + for (const contract of h5NativeAppRouteFlowContracts) { + for (const filePath of contract.files) { + if (!fs.existsSync(filePath)) { + throw new Error( + `H5 native app ${contract.label} route flow file is missing: ${filePath}`, + ); + } + } + + for (const [filePath, snippet] of contract.snippets) { + assertFileIncludesSnippet( + filePath, + snippet, + `H5 native app ${contract.label} route flow`, + ); + } + + for (const testPath of contract.tests) { + if (!h5HostBridgeTestSet.has(testPath)) { + throw new Error( + `H5 native app ${contract.label} route flow test is not part of check:native-shells: ${testPath}`, + ); + } + } + + for (const test of contract.targetedTests ?? []) { + assertFileIncludesSnippet( + test.filePath, + `test('${test.name}'`, + `H5 native app ${contract.label} route flow test`, + ); + const stepRunsTest = h5NativeAppRouteFlowTestSteps.some( + (step) => step.args.includes(test.filePath) && step.args.includes(test.name), + ); + if (!stepRunsTest) { + throw new Error( + `H5 native app ${contract.label} route flow targeted test is not part of check:native-shells: ${test.filePath}`, + ); + } + } + } +} + +function assertH5NativeSharePresentation() { + const modalSource = fs.readFileSync( + 'src/components/common/PublishShareModal.tsx', + 'utf8', + ); + const modalTestSource = fs.readFileSync( + 'src/components/common/PublishShareModal.test.tsx', + 'utf8', + ); + for (const snippet of [ + 'function resolveNativeSharePresentation', + "hostShell === 'tauri_desktop'", + "idleLabel: '复制分享文案'", + "successLabel: '已复制'", + "failedLabel: '复制失败'", + "idleLabel: '系统分享'", + "successLabel: '已打开'", + "failedLabel: '分享失败'", + ]) { + if (!modalSource.includes(snippet)) { + throw new Error(`PublishShareModal native share presentation missing: ${snippet}`); + } + } + + for (const snippet of [ + 'uses clipboard copy wording for Tauri native host share action', + 'hostShell=tauri_desktop', + "queryByRole('button', { name: '系统分享' })", + "getByRole('button', { name: '复制分享文案' })", + "getByRole('button', { name: '已复制' })", + 'keeps system share wording for Expo native host share action', + 'hostShell=expo_mobile', + "getByRole('button', { name: '系统分享' })", + "getByRole('button', { name: '已打开' })", + ]) { + if (!modalTestSource.includes(snippet)) { + throw new Error(`PublishShareModal native share test missing: ${snippet}`); + } + } +} + +function assertWechatPaymentResultBoundaries() { + const paymentSource = fs.readFileSync( + 'miniprogram/host-bridge/payment.js', + 'utf8', + ); + const paymentTestSource = fs.readFileSync( + 'miniprogram/host-bridge/payment.test.js', + 'utf8', + ); + + for (const snippet of [ + "function normalizePayError()", + "return 'wechat payment unavailable'", + 'function logWechatPayFailure(label, _error)', + 'console.error(`[wechat-pay] ${label}`)', + "logWechatPayFailure('parse params failed', error)", + "logWechatPayFailure('requestVirtualPayment unavailable')", + "logWechatPayFailure('requestVirtualPayment failed', error)", + ]) { + if (!paymentSource.includes(snippet)) { + throw new Error(`wechat payment bridge must include ${snippet}`); + } + } + if ( + paymentSource.includes('JSON.stringify({\n errCode') || + paymentSource.includes('String(error.errMsg || error)') || + paymentSource.includes("console.error('[wechat-pay] parse params failed', error)") || + paymentSource.includes("console.error('[wechat-pay] requestVirtualPayment unavailable',") || + paymentSource.includes("console.error('[wechat-pay] requestVirtualPayment failed', error)") + ) { + throw new Error('wechat payment bridge must not expose native payment errors to H5'); + } + for (const snippet of [ + 'maps virtual payment cancel errCode to cancel result', + 'logs virtual payment unavailable without exposing capability details', + 'hides ordinary payment native failure details from H5 result', + "errorMessage: 'wechat payment unavailable'", + "expect(console.error.mock.calls).toContainEqual([\n '[wechat-pay] requestVirtualPayment unavailable',", + "expect(console.error).toHaveBeenCalledWith(\n '[wechat-pay] requestVirtualPayment failed'", + 'expect(console.error.mock.calls.flat()).not.toContain(payError)', + ]) { + if (!paymentTestSource.includes(snippet)) { + throw new Error(`wechat payment bridge test must include ${snippet}`); + } + } +} + +function assertWechatSubscribeResultBoundaries() { + const subscribeSource = fs.readFileSync( + 'miniprogram/host-bridge/subscribeMessage.js', + 'utf8', + ); + const subscribeTestSource = fs.readFileSync( + 'miniprogram/host-bridge/subscribeMessage.test.js', + 'utf8', + ); + + for (const snippet of [ + "WECHAT_SUBSCRIBE_UNAVAILABLE_REASON = 'wechat subscribe unavailable'", + 'function logWechatSubscribeFailure(label, _error)', + 'console.error(`[subscribe-message] ${label}`)', + "logWechatSubscribeFailure('request failed', error)", + 'WECHAT_SUBSCRIBE_UNAVAILABLE_REASON', + ]) { + if (!subscribeSource.includes(snippet)) { + throw new Error(`wechat subscribe bridge must include ${snippet}`); + } + } + if ( + subscribeSource.includes("error && error.errMsg ? error.errMsg : 'failed'") || + subscribeSource.includes("console.error('[subscribe-message] request failed', error)") + ) { + throw new Error('wechat subscribe bridge must not expose native subscribe errors to H5'); + } + for (const snippet of [ + 'hides requestSubscribeMessage native failure details from H5 result', + 'wechat%20subscribe%20unavailable', + "expect(console.error).toHaveBeenCalledWith('[subscribe-message] request failed')", + 'expect(console.error.mock.calls.flat()).not.toContain(subscribeError)', + ]) { + if (!subscribeTestSource.includes(snippet)) { + throw new Error(`wechat subscribe bridge test must include ${snippet}`); + } + } +} + +function assertWechatAuthFailureBoundaries() { + const webViewShellSource = fs.readFileSync( + 'miniprogram/shell/webView.js', + 'utf8', + ); + const authTestSource = fs.readFileSync( + 'scripts/miniprogram-web-view-auth.test.ts', + 'utf8', + ); + + for (const snippet of [ + 'function logMiniProgramEnvReadFailure(_error)', + "console.warn('[web-view] read mini program env failed')", + 'logMiniProgramEnvReadFailure(error)', + ]) { + if (!webViewShellSource.includes(snippet)) { + throw new Error(`wechat web-view env diagnostics must include ${snippet}`); + } + } + if ( + webViewShellSource.includes( + "console.warn('[web-view] read mini program env failed', error)", + ) + ) { + throw new Error('wechat web-view env diagnostics must not log native error details'); + } + + for (const snippet of [ + "WECHAT_LOGIN_UNAVAILABLE_MESSAGE = '微信登录失败,请稍后重试。'", + "WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE = '绑定手机号失败,请稍后重试。'", + "WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE = '需要授权手机号后才能完成绑定。'", + 'function logWebViewAuthFailure(label, _detail)', + 'console.error(`[web-view] ${label}`)', + "logWebViewAuthFailure('parse auth result failed', error)", + "logWebViewAuthFailure('wx.login returned no code', result)", + "logWebViewAuthFailure('wx.login failed', error)", + "logWebViewAuthFailure('mini program login failed', response)", + "logWebViewAuthFailure('mini program login request failed', error)", + "logWebViewAuthFailure('mini program bind phone failed', response)", + "logWebViewAuthFailure('mini program bind phone request failed', error)", + "logWebViewAuthFailure('auth flow failed', error)", + "logWebViewAuthFailure('bind phone auth declined', detail)", + "logWebViewAuthFailure('bind phone failed', error)", + 'errorMessage: WECHAT_LOGIN_UNAVAILABLE_MESSAGE', + 'errorMessage: WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE', + 'errorMessage: WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE', + ]) { + if (!webViewShellSource.includes(snippet)) { + throw new Error(`wechat auth shell must include ${snippet}`); + } + } + for (const forbiddenSnippet of [ + "reject(new Error(error.errMsg || '微信登录失败'))", + "reject(new Error(error.errMsg || '微信登录请求失败'))", + "reject(new Error(error.errMsg || '绑定手机号请求失败'))", + "error && error.message ? error.message : '微信登录失败,请稍后重试。'", + "detail.errMsg || '需要授权手机号后才能完成绑定。'", + 'response.data.error.message', + "console.error('[web-view] parse auth result failed', error)", + "console.error('[web-view] wx.login returned no code', result)", + "console.error('[web-view] wx.login failed', error)", + "console.error('[web-view] mini program login failed', response)", + "console.error('[web-view] mini program login request failed', error)", + "console.error('[web-view] mini program bind phone failed', response)", + "console.error('[web-view] mini program bind phone request failed', error)", + "console.error('[web-view] auth flow failed', error)", + "console.error('[web-view] bind phone auth declined', detail)", + "console.error('[web-view] bind phone failed', error)", + ]) { + if (webViewShellSource.includes(forbiddenSnippet)) { + throw new Error(`wechat auth shell must not expose native failure detail via ${forbiddenSnippet}`); + } + } + for (const snippet of [ + '微信登录失败不向页面透出原生错误', + '绑定手机号失败不向页面透出后端错误体', + '拒绝手机号授权不向页面透出微信原生错误', + "expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。')", + "expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。')", + "expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。')", + "expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed')", + "expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed')", + 'expect(console.error.mock.calls.flat()).not.toContain(loginError)', + "expect(console.error).toHaveBeenCalledWith(\n '[web-view] mini program bind phone failed'", + "expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed')", + "expect(console.error.mock.calls.flat()).not.toContain('private backend detail')", + "expect(console.error).toHaveBeenCalledWith(\n '[web-view] bind phone auth declined'", + 'expect(console.error.mock.calls.flat()).not.toContain(authDeclined)', + ]) { + if (!authTestSource.includes(snippet)) { + throw new Error(`wechat auth boundary test must include ${snippet}`); + } + } +} + +function assertWechatWebViewPageEventBoundaries() { + const webViewShellSource = fs.readFileSync( + 'miniprogram/shell/webView.js', + 'utf8', + ); + const webViewTestSource = fs.readFileSync( + 'miniprogram/shell/webView.test.js', + 'utf8', + ); + + for (const snippet of [ + 'function logWebViewPageEvent(label, _detail)', + 'function logWebViewPageFailure(label, _detail)', + 'console.info(`[web-view] ${label}`)', + 'console.error(`[web-view] ${label}`)', + "logWebViewPageEvent('loaded', event.detail)", + "logWebViewPageFailure('load failed', event.detail)", + "logWebViewPageEvent('message', event.detail)", + ]) { + if (!webViewShellSource.includes(snippet)) { + throw new Error(`wechat web-view page event diagnostics must include ${snippet}`); + } + } + + for (const forbiddenSnippet of [ + "console.info('[web-view] loaded', event.detail)", + "console.error('[web-view] load failed', event.detail)", + "console.info('[web-view] message', event.detail)", + ]) { + if (webViewShellSource.includes(forbiddenSnippet)) { + throw new Error(`wechat web-view page event diagnostics must not expose native detail via ${forbiddenSnippet}`); + } + } + + for (const snippet of [ + 'logs web-view page events without native detail payloads', + "expect(console.info).toHaveBeenCalledWith('[web-view] loaded')", + "expect(console.error).toHaveBeenCalledWith('[web-view] load failed')", + 'expect(console.info.mock.calls.flat()).not.toContain(loadDetail)', + 'expect(console.error.mock.calls.flat()).not.toContain(errorDetail)', + "expect(console.info).toHaveBeenCalledWith('[web-view] message')", + 'expect(console.info.mock.calls.flat()).not.toContain(webViewDetail)', + ]) { + if (!webViewTestSource.includes(snippet)) { + throw new Error(`wechat web-view page event boundary test must include ${snippet}`); + } + } +} + +function assertWechatShareGridFailureBoundaries() { + const shareGridSource = fs.readFileSync( + 'miniprogram/shell/shareGrid.js', + 'utf8', + ); + const shareGridTestSource = fs.readFileSync( + 'miniprogram/shell/shareGrid.test.js', + 'utf8', + ); + + for (const snippet of [ + "WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE = '九宫切图保存失败。'", + 'function logShareGridFailure(label, _error)', + 'console.error(`[share-grid] ${label}`)', + 'logShareGridFailure(label, error)', + "logShareGridFailure('save failed', error)", + 'reject(new Error(WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE))', + 'errorMessage: WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE', + ]) { + if (!shareGridSource.includes(snippet)) { + throw new Error(`wechat share-grid shell must include ${snippet}`); + } + } + for (const forbiddenSnippet of [ + "reject(new Error(error.errMsg || '封面下载失败'))", + "reject(new Error(error.errMsg || '读取封面失败'))", + "reject(new Error(error.errMsg || '导出切图失败'))", + "reject(new Error(error.errMsg || '保存到相册失败'))", + "error && error.message ? error.message : '九宫切图保存失败。'", + "console.error(`[share-grid] ${label}`, error)", + "console.error('[share-grid] save failed', error)", + ]) { + if (shareGridSource.includes(forbiddenSnippet)) { + throw new Error(`wechat share-grid shell must not expose native failure detail via ${forbiddenSnippet}`); + } + } + for (const snippet of [ + 'hides downloadFile native failure details from the page', + "expect(page.data.errorMessage).toBe('九宫切图保存失败。')", + "expect(page.data.errorMessage).not.toContain('private native download detail')", + "expect(consoleError).toHaveBeenCalledWith('[share-grid] download failed')", + "expect(consoleError).toHaveBeenCalledWith('[share-grid] save failed')", + "expect(consoleError.mock.calls.flat()).not.toContain('private native download detail')", + ]) { + if (!shareGridTestSource.includes(snippet)) { + throw new Error(`wechat share-grid boundary test must include ${snippet}`); + } + } +} + +function assertDesktopNavigationEventBoundaries() { + const desktopShellNavigationSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', + 'utf8', + ); + const desktopNavigationStateScriptSource = extractRustFunctionSource( + desktopShellNavigationSource, + 'desktop_navigation_state_script', + ); + + if ( + !desktopNavigationStateScriptSource.includes( + "console.warn('desktop navigation state sync failed')", + ) + ) { + throw new Error( + 'desktop shell navigation state diagnostics must log a stable label', + ); + } + if ( + desktopNavigationStateScriptSource.includes( + "console.warn('desktop navigation state sync failed', error)", + ) + ) { + throw new Error( + 'desktop shell navigation state diagnostics must not log native error objects', + ); + } +} + +function assertHostBridgeLayerLayout() { + for (const [label, values] of [ + ['wechat host bridge files expectation', expectedWechatHostBridgeFiles], + ['wechat shell files expectation', expectedWechatShellFiles], + ['wechat page routes expectation', Object.keys(expectedWechatPageFilesByRoute)], + ['mobile host bridge files expectation', expectedMobileHostBridgeFiles], + ['mobile shell src root entries expectation', expectedMobileSrcRootEntries], + ['mobile shell files expectation', expectedMobileShellFiles], + ['desktop host bridge Rust files expectation', expectedDesktopHostBridgeRustFiles], + ['desktop shell Rust files expectation', expectedDesktopShellRustFiles], + ]) { + assertUniqueList(values, label); + } + for (const [label, values] of Object.entries(expectedHostBridgeModuleTaxonomy)) { + assertUniqueList(values, `host bridge ${label} module taxonomy`); + } + for (const [route, expectedFiles] of Object.entries( + expectedWechatPageFilesByRoute, + )) { + assertUniqueList(expectedFiles, `wechat ${route} page wrapper files expectation`); + } + + assertSameList( + readDirectoryFileList( + 'miniprogram/host-bridge', + 'wechat host bridge files', + ), + expectedWechatHostBridgeFiles, + 'wechat host bridge files', + ); + assertHostBridgeModuleTaxonomy(); + + assertSameList( + readDirectoryFileList('miniprogram/shell', 'wechat shell files'), + expectedWechatShellFiles, + 'wechat shell files', + ); + + assertSameList( + readDirectoryNameList('miniprogram/pages', 'wechat page directories'), + Object.keys(expectedWechatPageFilesByRoute).sort(), + 'wechat page directories', + ); + + for (const [route, expectedFiles] of Object.entries( + expectedWechatPageFilesByRoute, + )) { + assertSameList( + readDirectoryFileList( + `miniprogram/pages/${route}`, + `wechat ${route} page wrapper files`, + ), + expectedFiles, + `wechat ${route} page wrapper files`, + ); + const pagePath = `miniprogram/pages/${route}/index.js`; + const source = fs.readFileSync(pagePath, 'utf8'); + if (!source.includes("require('../../shell/")) { + throw new Error(`${pagePath} must import from miniprogram/shell`); + } + if ( + source.includes("require('../../host-bridge/") || + source.includes("require('./index.shared')") + ) { + throw new Error(`${pagePath} must not import bridge logic directly`); + } + } + + assertSameList( + readDirectoryEntryList('apps/mobile-shell/src', 'mobile shell src root entries'), + expectedMobileSrcRootEntries, + 'mobile shell src root entries', + ); + + assertSameList( + readDirectoryFileList( + 'apps/mobile-shell/src/host-bridge', + 'mobile host bridge files', + ), + expectedMobileHostBridgeFiles, + 'mobile host bridge files', + ); + + assertSameList( + readDirectoryFileList('apps/mobile-shell/src/shell', 'mobile shell files'), + expectedMobileShellFiles, + 'mobile shell files', + ); + + const mobileAppSource = fs.readFileSync('apps/mobile-shell/App.tsx', 'utf8'); + if (!mobileAppSource.includes("import ShellApp from './src/shell/ShellApp';")) { + throw new Error('mobile shell App.tsx must import from apps/mobile-shell/src/shell'); + } + if (mobileAppSource.includes('./src/host-bridge/')) { + throw new Error('mobile shell App.tsx must not import HostBridge directly'); + } + + assertSameList( + readDirectoryEntryList( + 'apps/desktop-shell/src-tauri/src', + 'desktop shell Rust root entries', + ), + ['dir:host_bridge', 'dir:shell', 'file:app.rs', 'file:main.rs'], + 'desktop shell Rust root entries', + ); + + const desktopMainSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/main.rs', + 'utf8', + ); + const desktopAppSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/app.rs', + 'utf8', + ); + if (!desktopMainSource.includes('mod app;') || !desktopMainSource.includes('app::run();')) { + throw new Error('desktop shell main.rs must stay a thin app entrypoint'); + } + if ( + desktopMainSource.includes('tauri::Builder::default()') || + desktopMainSource.includes('tauri::generate_handler!') + ) { + throw new Error('desktop shell main.rs must not own Tauri app setup'); + } + if ( + !desktopAppSource.includes('tauri::Builder::default()') || + !desktopAppSource.includes('crate::host_bridge::host_bridge_request') + ) { + throw new Error('desktop shell app.rs must own Tauri app setup'); + } + + assertSameList( + readDirectoryFileList( + 'apps/desktop-shell/src-tauri/src/host_bridge', + 'desktop host bridge Rust files', + ), + expectedDesktopHostBridgeRustFiles, + 'desktop host bridge Rust files', + ); + + assertSameList( + readDirectoryFileList( + 'apps/desktop-shell/src-tauri/src/shell', + 'desktop shell Rust files', + ), + expectedDesktopShellRustFiles, + 'desktop shell Rust files', + ); +} + +function normalizeHostBridgeModuleNames(files) { + return files + .filter((fileName) => !fileName.includes('.test.')) + .map((fileName) => + fileName + .replace(/\.(js|rs|ts)$/, '') + .replace('filePayloads', 'file-payloads') + .replace('file_payloads', 'file-payloads'), + ) + .sort(); +} + +function assertHostBridgeModuleTaxonomy() { + const wechatModules = normalizeHostBridgeModuleNames(expectedWechatHostBridgeFiles); + const mobileModules = normalizeHostBridgeModuleNames(expectedMobileHostBridgeFiles); + const desktopModules = normalizeHostBridgeModuleNames( + expectedDesktopHostBridgeRustFiles, + ); + const allShellModules = [...wechatModules, ...mobileModules, ...desktopModules]; + const countInShells = (moduleName) => + [wechatModules, mobileModules, desktopModules].filter((modules) => + modules.includes(moduleName), + ).length; + const actualAllShells = allShellModules + .filter((moduleName, index, modules) => modules.indexOf(moduleName) === index) + .filter((moduleName) => countInShells(moduleName) === 3) + .sort(); + const actualNativeAppShells = mobileModules + .filter((moduleName) => desktopModules.includes(moduleName)) + .filter((moduleName) => !wechatModules.includes(moduleName)) + .sort(); + const actualMobileOnly = mobileModules + .filter( + (moduleName) => + !wechatModules.includes(moduleName) && !desktopModules.includes(moduleName), + ) + .sort(); + const actualDesktopOnly = desktopModules + .filter( + (moduleName) => + !wechatModules.includes(moduleName) && !mobileModules.includes(moduleName), + ) + .sort(); + const actualWechatOnly = wechatModules + .filter( + (moduleName) => + !mobileModules.includes(moduleName) && !desktopModules.includes(moduleName), + ) + .sort(); + + assertSameList( + actualAllShells, + expectedHostBridgeModuleTaxonomy.allShells, + 'all-shell HostBridge module taxonomy', + ); + assertSameList( + actualNativeAppShells, + expectedHostBridgeModuleTaxonomy.nativeAppShells, + 'native-app HostBridge module taxonomy', + ); + assertSameList( + actualMobileOnly, + expectedHostBridgeModuleTaxonomy.mobileOnly, + 'mobile-only HostBridge module taxonomy', + ); + assertSameList( + actualDesktopOnly, + expectedHostBridgeModuleTaxonomy.desktopOnly, + 'desktop-only HostBridge module taxonomy', + ); + assertSameList( + actualWechatOnly, + expectedHostBridgeModuleTaxonomy.wechatOnly, + 'wechat-only HostBridge module taxonomy', + ); +} + +function assertDesktopReleaseBinaryArtifact() { + const executableName = + process.platform === 'win32' + ? 'genarrative-desktop-shell.exe' + : 'genarrative-desktop-shell'; + const executablePath = path.join( + 'build', + 'native', + 'desktop', + executableName, + ); + + if (!fs.existsSync(executablePath)) { + throw new Error(`desktop release binary is missing: ${executablePath}`); + } + + const stat = fs.statSync(executablePath); + if (!stat.isFile() || stat.size < 1024 * 1024) { + throw new Error('desktop release binary must be a real non-empty executable file'); + } + + const header = fs.readFileSync(executablePath, { start: 0, end: 7 }); + if (process.platform === 'linux') { + const isElf = + header[0] === 0x7f && + header[1] === 0x45 && + header[2] === 0x4c && + header[3] === 0x46; + if (!isElf || (stat.mode & 0o111) === 0) { + throw new Error('desktop Linux release binary must be an executable ELF file'); + } + return; + } + + if (process.platform === 'darwin') { + const machMagic = header.readUInt32BE(0); + const isMachO = + machMagic === 0xcafebabe || + machMagic === 0xcafed00d || + machMagic === 0xfeedface || + machMagic === 0xfeedfacf; + if (!isMachO || (stat.mode & 0o111) === 0) { + throw new Error('desktop macOS release binary must be an executable Mach-O file'); + } + return; + } + + if (process.platform === 'win32') { + if (header[0] !== 0x4d || header[1] !== 0x5a) { + throw new Error('desktop Windows release binary must be a PE executable'); + } + } +} + +for (const step of steps) { + console.log(`[check:native-shells] ${step.label}`); + const result = spawnSync(step.command, step.args, { + cwd: process.cwd(), + stdio: 'inherit', + }); + + if (result.error) { + console.error( + `[check:native-shells] failed to start ${step.label}: ${result.error.message}`, + ); + process.exit(1); + } + + if (result.signal) { + console.error( + `[check:native-shells] ${step.label} was terminated by signal ${result.signal}`, + ); + process.exit(1); + } + + if ((result.status ?? 0) !== 0) { + process.exit(result.status ?? 1); + } +} + +console.log('[check:native-shells] desktop-release-binary-artifact'); +assertDesktopReleaseBinaryArtifact(); + +console.log('[check:native-shells] host-bridge-layer-layout'); +assertHostBridgeLayerLayout(); + +console.log('[check:native-shells] native-shell-capability-plan'); +assertNativeShellCapabilityPlan(); + +console.log('[check:native-shells] external-url-protocol-parity'); +assertExternalUrlProtocolParity(); + +console.log('[check:native-shells] wechat-mini-program-route-parity'); +assertWechatMiniProgramRouteParity(); + +console.log('[check:native-shells] wechat-mini-program-capability-flows'); +assertWechatMiniProgramCapabilityFlows(); + +console.log('[check:native-shells] expo-mobile-capability-flows'); +assertExpoMobileCapabilityFlows(); + +console.log('[check:native-shells] tauri-desktop-capability-flows'); +assertTauriDesktopCapabilityFlows(); + +console.log('[check:native-shells] h5-native-app-route-flows'); +assertH5NativeAppRouteFlows(); + +console.log('[check:native-shells] h5-native-share-presentation'); +assertH5NativeSharePresentation(); + +console.log('[check:native-shells] wechat-payment-result-boundaries'); +assertWechatPaymentResultBoundaries(); + +console.log('[check:native-shells] wechat-subscribe-result-boundaries'); +assertWechatSubscribeResultBoundaries(); + +console.log('[check:native-shells] wechat-auth-failure-boundaries'); +assertWechatAuthFailureBoundaries(); + +console.log('[check:native-shells] wechat-web-view-page-event-boundaries'); +assertWechatWebViewPageEventBoundaries(); + +console.log('[check:native-shells] wechat-share-grid-failure-boundaries'); +assertWechatShareGridFailureBoundaries(); + +console.log('[check:native-shells] desktop-navigation-event-boundaries'); +assertDesktopNavigationEventBoundaries(); + +console.log('[check:native-shells] h5-host-bridge-event-subscription-gates'); +assertH5HostBridgeEventSubscriptionGates(); + +console.log('[check:native-shells] h5-host-bridge-payload-boundaries'); +assertH5HostBridgePayloadBoundaries(); + +console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries'); +assertH5NativeAppTransportTimeoutBoundaries(); + +console.log('[check:native-shells] h5-native-app-message-source-boundaries'); +assertH5NativeAppMessageSourceBoundaries(); + +console.log('[check:native-shells] h5-native-app-transport-facade-boundary'); +assertH5NativeAppTransportFacadeBoundary(); + +console.log('[check:native-shells] generated-native-shell-artifact-boundary'); +assertNoTrackedGeneratedNativeShellArtifacts(); +assertGeneratedNativeShellArtifactsAreIgnored(); + +console.log('[check:native-shells] production-shell-dev-scaffold-scan'); +assertNoProductionShellDevScaffoldTerms(); + +console.log('[check:native-shells] OK'); diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index 7292a28c7..2eacaca7c 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -209,6 +209,34 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { ), 'current release 必须包含 Pingora 直连 drop-in 模板。', ); + assertFileExists( + path.join( + releaseDir, + 'deploy/systemd/genarrative-external-generation-worker@.service', + ), + 'current release 必须包含外部生成 worker systemd 模板。', + ); + assertFileExists( + path.join( + releaseDir, + 'deploy/systemd/genarrative-external-generation-controller.service', + ), + 'current release 必须包含外部生成 worker controller systemd 单元。', + ); + assertFileExists( + path.join( + fixture.systemdUnitDir, + 'genarrative-external-generation-worker@.service', + ), + 'API deploy 必须把随包外部生成 worker 模板安装到 systemd unit 目录。', + ); + assertFileExists( + path.join( + fixture.systemdUnitDir, + 'genarrative-external-generation-controller.service', + ), + 'API deploy 必须把随包外部生成 worker controller 单元安装到 systemd unit 目录。', + ); assertFileExists( path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'), 'current release 必须包含 Pingora env 示例。', @@ -260,6 +288,11 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { ); const commandsLog = readFileSync(fixture.commandsLog, 'utf8'); + assertIncludes( + commandsLog, + 'systemctl daemon-reload', + '安装 worker systemd 单元后必须 daemon-reload。', + ); assertIncludes( commandsLog, 'systemctl restart genarrative-api.service', @@ -1136,6 +1169,7 @@ function prepareFixture(name) { const maintenanceFile = path.join(root, 'maintenance', 'enabled'); const fakeBin = path.join(root, 'bin'); const commandsLog = path.join(root, 'commands.log'); + const systemdUnitDir = path.join(root, 'etc', 'systemd', 'system'); const workerStateFile = path.join(root, 'worker-service-enabled'); const pingoraStateFile = path.join(root, 'pingora-service-active'); const version = `20260614-${name}`; @@ -1299,6 +1333,20 @@ function prepareFixture(name) { 'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf', ), ); + copyFile( + 'deploy/systemd/genarrative-external-generation-worker@.service', + path.join( + sourceDir, + 'deploy/systemd/genarrative-external-generation-worker@.service', + ), + ); + copyFile( + 'deploy/systemd/genarrative-external-generation-controller.service', + path.join( + sourceDir, + 'deploy/systemd/genarrative-external-generation-controller.service', + ), + ); copyFile( 'deploy/pingora/pingora-gateway.env.example', path.join(sourceDir, 'deploy/pingora/pingora-gateway.env.example'), @@ -1360,11 +1408,11 @@ function prepareFixture(name) { ' fi', ' exit 0', 'fi', - 'if [[ "$1 $2" == "enable --now" && "${3:-}" == "genarrative-external-generation-worker@1.service" ]]; then', + 'if [[ "$1 ${2:-}" == "enable --now" && "${3:-}" == "genarrative-external-generation-worker@1.service" ]]; then', ' printf "enabled\\n" > "${worker_state_file}"', ' exit 0', 'fi', - 'if [[ "$1 $2 ${3:-}" == "is-active --quiet genarrative-pingora-gateway.service" ]]; then', + 'if [[ "$1 ${2:-} ${3:-}" == "is-active --quiet genarrative-pingora-gateway.service" ]]; then', ' if [[ "${FAKE_PINGORA_ACTIVE:-true}" == "true" || -f "${pingora_state_file}" ]]; then', ' exit 0', ' fi', @@ -1443,6 +1491,7 @@ function prepareFixture(name) { maintenanceFile, fakeBin, commandsLog, + systemdUnitDir, workerStateFile, pingoraStateFile, version, @@ -1534,6 +1583,7 @@ function runDeploy(fixture, options = {}) { FAKE_RELEASE_ROOT: fixture.releaseRoot, FAKE_RELEASE_VERSION: fixture.version, FAKE_WORKER_STATE_FILE: fixture.workerStateFile, + GENARRATIVE_SYSTEMD_UNIT_DIR: fixture.systemdUnitDir, }, }, ); diff --git a/scripts/check-production-api-release.mjs b/scripts/check-production-api-release.mjs index 652f09a32..8495269f1 100644 --- a/scripts/check-production-api-release.mjs +++ b/scripts/check-production-api-release.mjs @@ -244,6 +244,20 @@ function assertApiReleaseContainsPingoraDirectDependencies() { ), 'API release 必须包含 Pingora 直连 drop-in 模板。', ); + assertFileExists( + path.join( + releaseDir, + 'deploy/systemd/genarrative-external-generation-worker@.service', + ), + 'API release 必须包含外部生成 worker systemd 模板。', + ); + assertFileExists( + path.join( + releaseDir, + 'deploy/systemd/genarrative-external-generation-controller.service', + ), + 'API release 必须包含外部生成 worker controller systemd 单元。', + ); assertFileExists( path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'), 'API release 必须包含 Pingora env 示例。', diff --git a/scripts/check-wechat-miniprogram-auth-smoke.mjs b/scripts/check-wechat-miniprogram-auth-smoke.mjs index 373e0327a..5ca40df79 100644 --- a/scripts/check-wechat-miniprogram-auth-smoke.mjs +++ b/scripts/check-wechat-miniprogram-auth-smoke.mjs @@ -54,22 +54,27 @@ if (failures.length > 0) { console.log('\n[wechat-miniprogram-auth-smoke] 通过'); function checkMiniProgramShell() { - const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); + const shellPath = join(repoRoot, 'miniprogram', 'shell', 'webView.js'); + const webViewBridgePath = join(repoRoot, 'miniprogram', 'host-bridge', 'webView.js'); + const pagePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml'); const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts'); + ensureNeedles(pagePath, ["require('../../shell/webView')"]); ensureNeedles(shellPath, [ '/api/auth/wechat/miniprogram-login', '/api/auth/wechat/bind-phone', "'x-client-type': MINI_PROGRAM_CLIENT_TYPE", "'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME", - 'auth_provider', - 'auth_token', - 'auth_binding_status', 'bindingStatus', 'pending_bind_phone', 'wechatPhoneCode', ]); + ensureNeedles(webViewBridgePath, [ + 'auth_provider', + 'auth_token', + 'auth_binding_status', + ]); ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']); diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index abbea0b48..716ce298f 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -421,6 +421,54 @@ ensure_default_worker_service() { systemctl enable --now "${default_service}" } +install_release_systemd_unit() { + local source_path="$1" + local unit_name="$2" + local label="$3" + local unit_dir="${GENARRATIVE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}" + + if [[ ! -f "${source_path}" ]]; then + echo "[production-api-deploy] 发布产物缺少${label}: ${source_path}" >&2 + return 1 + fi + if [[ "${unit_dir}" != /* ]]; then + echo "[production-api-deploy] systemd unit 目录必须使用绝对路径: ${unit_dir}" >&2 + return 1 + fi + + echo "[production-api-deploy] 安装${label}: ${unit_name}" + run_privileged install -d -m 0755 "${unit_dir}" + run_privileged install -m 0644 "${source_path}" "${unit_dir}/${unit_name}" +} + +install_worker_systemd_units() { + local release_dir="$1" + local pattern="$2" + local controller_service="$3" + local installed_any=0 + + if [[ "${pattern}" == "genarrative-external-generation-worker@*.service" ]]; then + install_release_systemd_unit \ + "${release_dir}/deploy/systemd/genarrative-external-generation-worker@.service" \ + "genarrative-external-generation-worker@.service" \ + "外部生成 worker systemd 模板" + installed_any=1 + fi + + if [[ "${controller_service}" == "genarrative-external-generation-controller.service" ]]; then + install_release_systemd_unit \ + "${release_dir}/deploy/systemd/genarrative-external-generation-controller.service" \ + "genarrative-external-generation-controller.service" \ + "外部生成 worker controller systemd 单元" + installed_any=1 + fi + + if [[ "${installed_any}" -eq 1 ]]; then + echo "[production-api-deploy] 重新加载 systemd unit。" + systemctl daemon-reload + fi +} + restart_worker_services() { local pattern="$1" local services=() @@ -917,6 +965,8 @@ if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then ensure_pingora_shadow_service "${PINGORA_SERVICE_NAME}" "${PINGORA_SHADOW_ENV_FILE}" fi +install_worker_systemd_units "${RELEASE_DIR}" "${WORKER_SERVICE_PATTERN}" "${WORKER_CONTROLLER_SERVICE}" + echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}" restart_worker_services "${WORKER_SERVICE_PATTERN}" diff --git a/scripts/dev-stack-port-utils.mjs b/scripts/dev-stack-port-utils.mjs index bbd8f6e54..c119ea46e 100644 --- a/scripts/dev-stack-port-utils.mjs +++ b/scripts/dev-stack-port-utils.mjs @@ -479,6 +479,7 @@ export async function findAvailablePort({ reservedPorts = new Set(), maxAttempts = null, portRange = null, + strict = false, }) { const range = normalizePortRange(portRange); const startPort = normalizePort(preferredPort, 0); @@ -501,6 +502,18 @@ export async function findAvailablePort({ throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label} 内`); } + if (strict && startPort !== 0) { + if (reservedPorts.has(startPort)) { + throw new Error(`端口 ${host}:${startPort} 已被当前 dev 启动流程占用,无法严格使用该端口`); + } + + if (await isPortAvailable({host, port: startPort})) { + return startPort; + } + + throw new Error(`端口 ${host}:${startPort} 不可用,无法严格使用该端口`); + } + const boundedAttempts = range ? Number.isFinite(maxAttempts) ? Math.min(Math.max(0, maxAttempts), range.end - startPort) @@ -565,6 +578,7 @@ export async function resolveDevStackPorts(config) { preferredPort: portConfig.preferredPort, reservedPorts, portRange: portConfig.portRange, + strict: Boolean(portConfig.strict), }); reservedPorts.add(resolvedPort); result[name] = resolvedPort; diff --git a/scripts/dev-stack-port-utils.test.ts b/scripts/dev-stack-port-utils.test.ts index 5521538d6..353b7a100 100644 --- a/scripts/dev-stack-port-utils.test.ts +++ b/scripts/dev-stack-port-utils.test.ts @@ -58,6 +58,23 @@ describe('dev stack port utils', () => { } }); + it('严格端口模式在端口被占用时直接失败而不是漂移', async () => { + const server = await reservePort(0); + const port = server.address().port; + + try { + await expect( + findAvailablePort({ + host: '127.0.0.1', + preferredPort: port, + strict: true, + }), + ).rejects.toThrow('无法严格使用该端口'); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + it('端口查找不会越过 Linux 用户端口段', async () => { await expect( findAvailablePort({ diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 74f315aa1..b5c204c0e 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -221,6 +221,7 @@ function parseArgs(argv, baseEnv) { migrationBootstrapSecretMode: 'auto', watch: false, interactive: true, + strictWebPort: false, }; for (let index = 0; index < args.length; index += 1) { @@ -256,6 +257,9 @@ function parseArgs(argv, baseEnv) { options.webPort = normalizePort(readValue(), options.webPort); explicitOptions.add('webPort'); break; + case '--strict-web-port': + options.strictWebPort = true; + break; case '--admin-web-host': options.adminWebHost = readValue(); explicitOptions.add('adminWebHost'); @@ -1046,13 +1050,15 @@ class DevRunner { async resolvePorts(command) { const {options} = this; const portConfig = {}; + const portRangeFor = (optionName) => + this.explicitOptions.has(optionName) ? null : this.state.portRange; if (command === 'all' || command === 'spacetime') { if (!options.skipSpacetime && !this.state.spacetimeReused) { portConfig.spacetime = { host: options.spacetimeHost, preferredPort: options.spacetimePort, - portRange: this.state.portRange, + portRange: portRangeFor('spacetimePort'), }; } } @@ -1061,7 +1067,7 @@ class DevRunner { portConfig.api = { host: options.apiHost, preferredPort: options.apiPort, - portRange: this.state.portRange, + portRange: portRangeFor('apiPort'), }; } @@ -1069,7 +1075,8 @@ class DevRunner { portConfig.web = { host: options.webHost, preferredPort: options.webPort, - portRange: this.state.portRange, + portRange: portRangeFor('webPort'), + strict: options.strictWebPort, }; } @@ -1077,7 +1084,7 @@ class DevRunner { portConfig.adminWeb = { host: options.adminWebHost, preferredPort: options.adminWebPort, - portRange: this.state.portRange, + portRange: portRangeFor('adminWebPort'), }; } diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index d80c7d9ec..1d7e4c515 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -139,6 +139,36 @@ describe('dev scheduler argument routing', () => { } }); + linuxTest('Linux 桌面壳显式指定 web-port 时不被系统级端口段改写', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-')); + try { + const {command, explicitOptions, options} = parseArgs( + ['web', '--web-port', '3000', '--strict-web-port'], + { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir, + }, + ); + const runner = new DevRunner(options, { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir, + }, explicitOptions); + + await runner.prepareLinuxPortRange(command); + expect(runner.state.portRange.label).toBe('22000-22099'); + expect(runner.options.webPort).toBe(3000); + expect(runner.options.apiPort).toBe(22001); + expect(runner.options.spacetimePort).toBe(22002); + expect(runner.options.adminWebPort).toBe(22003); + } finally { + rmSync(tempDir, {recursive: true, force: true}); + } + }); + test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); Object.defineProperty(process, 'platform', { diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts index ccf24a855..3b1a674d8 100644 --- a/scripts/miniprogram-web-view-auth.test.ts +++ b/scripts/miniprogram-web-view-auth.test.ts @@ -5,12 +5,17 @@ import vm from 'node:vm'; import { beforeEach, describe, expect, test, vi } from 'vitest'; const repoRoot = process.cwd(); -const pageScriptPath = join( +const shellScriptPath = join( repoRoot, 'miniprogram', - 'pages', - 'web-view', - 'index.js', + 'shell', + 'webView.js', +); +const webViewHostBridgePath = join( + repoRoot, + 'miniprogram', + 'host-bridge', + 'webView.js', ); type MiniProgramPage = { @@ -21,6 +26,9 @@ type MiniProgramPage = { onShareTimeline: () => Record; onShow: () => void; consumePayResult: () => void; + handleGetPhoneNumber: (event: { + detail?: Record; + }) => Promise; }; function createWxMock() { @@ -43,45 +51,28 @@ function loadWebViewPage( wxMock: ReturnType, configOverrides: Record = {}, ) { - let pageConfig: Record | null = null; - const source = readFileSync(pageScriptPath, 'utf8'); - const sandbox = { - console, - getCurrentPages: () => [], - module: { exports: {} }, - Page(config: Record) { - pageConfig = config; + (globalThis as unknown as { wx: ReturnType }).wx = + wxMock; + const webViewBridge = loadCommonJsModule(webViewHostBridgePath, {}); + const shellModule = loadCommonJsModule(shellScriptPath, { + '../config': { + API_BASE_URL: 'https://www.genarrative.world/', + DEV_API_BASE_URL: 'https://dev.genarrative.world/', + DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', + MINI_PROGRAM_APP_ID: 'wx-test-app', + MINI_PROGRAM_ENV: 'release', + WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', + WEB_VIEW_SOURCE_QUERY: { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }, + ...configOverrides, }, - setTimeout(callback: () => void) { - callback(); - return 1; - }, - require(requestPath: string) { - if (requestPath === '../../config') { - return { - API_BASE_URL: 'https://www.genarrative.world/', - DEV_API_BASE_URL: 'https://dev.genarrative.world/', - DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', - MINI_PROGRAM_APP_ID: 'wx-test-app', - MINI_PROGRAM_ENV: 'release', - WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', - WEB_VIEW_SOURCE_QUERY: { - clientType: 'mini_program', - clientRuntime: 'wechat_mini_program', - }, - ...configOverrides, - }; - } - throw new Error(`Unexpected require: ${requestPath}`); - }, - wx: wxMock, + '../host-bridge/webView': webViewBridge, + }) as { + createWechatWebViewPage: () => Record; }; - - vm.runInNewContext(source, sandbox, { filename: pageScriptPath }); - - if (!pageConfig) { - throw new Error('web-view page did not call Page()'); - } + const pageConfig = shellModule.createWechatWebViewPage(); const page = { ...pageConfig, @@ -94,9 +85,38 @@ function loadWebViewPage( return page; } +function loadCommonJsModule( + filePath: string, + requireMap: Record, +) { + const source = readFileSync(filePath, 'utf8'); + const module = { exports: {} as Record }; + const sandbox = { + console, + getCurrentPages: () => [], + module, + exports: module.exports, + setTimeout(callback: () => void) { + callback(); + return 1; + }, + require(requestPath: string) { + if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) { + return requireMap[requestPath]; + } + throw new Error(`Unexpected require: ${requestPath}`); + }, + wx: globalThis.wx, + }; + + vm.runInNewContext(source, sandbox, { filename: filePath }); + return module.exports; +} + describe('mini-program web-view auth page', () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); test('默认进入时不预登录,直接打开未登录 web-view', async () => { @@ -264,6 +284,10 @@ describe('mini-program web-view auth page', () => { data: { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', + user: { + displayName: '陶泥玩家', + publicUserCode: 'SY-12345678', + }, }, }); }); @@ -283,4 +307,76 @@ describe('mini-program web-view auth page', () => { expect(page.data.loading).toBe(false); expect(page.data.phoneBindingRequired).toBe(true); }); + + test('微信登录失败不向页面透出原生错误', async () => { + const wxMock = createWxMock(); + const loginError = { errMsg: 'login:fail private native detail' }; + wxMock.login.mockImplementation(({ fail }) => { + fail(loginError); + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({ authAction: 'login', returnTo: 'previous' }); + + expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。'); + expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed'); + expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed'); + expect(console.error.mock.calls.flat()).not.toContain(loginError); + expect(page.data.phoneBindingRequired).toBe(false); + }); + + test('绑定手机号失败不向页面透出后端错误体', async () => { + const wxMock = createWxMock(); + const page = loadWebViewPage(wxMock); + page.data.authResult = { + token: 'jwt-pending-wechat', + bindingStatus: 'pending_bind_phone', + }; + wxMock.request.mockImplementation(({ success }) => { + success({ + statusCode: 500, + data: { + error: { + message: 'private backend detail', + }, + }, + }); + }); + + await page.handleGetPhoneNumber({ + detail: { + code: 'wechat-phone-code', + }, + }); + + expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。'); + expect(console.error).toHaveBeenCalledWith( + '[web-view] mini program bind phone failed', + ); + expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed'); + expect(console.error.mock.calls.flat()).not.toContain('private backend detail'); + }); + + test('拒绝手机号授权不向页面透出微信原生错误', async () => { + const wxMock = createWxMock(); + const page = loadWebViewPage(wxMock); + page.data.authResult = { + token: 'jwt-pending-wechat', + bindingStatus: 'pending_bind_phone', + }; + const authDeclined = { + errMsg: 'getPhoneNumber:fail private native detail', + }; + + await page.handleGetPhoneNumber({ + detail: authDeclined, + }); + + expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。'); + expect(page.data.errorMessage).not.toContain('private native detail'); + expect(console.error).toHaveBeenCalledWith( + '[web-view] bind phone auth declined', + ); + expect(console.error.mock.calls.flat()).not.toContain(authDeclined); + }); }); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index ccc02ac3d..d8f7da577 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1028,7 +1028,7 @@ mod tests { started_at: Some("2026-06-03T00:00:00Z".to_string()), completed_at: None, updated_at: "2026-06-03T00:00:00Z".to_string(), - updated_at_micros: 1_748_908_800_000_000, + updated_at_micros: 1_780_444_800_000_000, lease_token: lease_token.map(ToOwned::to_owned), } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 1bbffb369..eb0c1cc91 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,12 +1,25 @@ /* @vitest-environment jsdom */ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ComponentProps } from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import App from './App'; -import { resolveInitialSelectionStageFromPath } from './routing/appPageRoutes'; +import { AuthUiContext } from './components/auth/AuthUiContext'; +import type { PlatformEntryFlowShellProps } from './components/platform-entry'; +import { + APP_HISTORY_STATE_KEY, + resolveInitialSelectionStageFromPath, +} from './routing/appPageRoutes'; +import { + canUseNativeHostCapability, + resetHostRuntimeCacheForTest, +} from './services/host-bridge/hostBridge'; +import { resetNativeAppHostBridgeForTest } from './services/host-bridge/nativeAppHostBridge'; + +const appTitleMock = vi.hoisted(() => ({ + syncAppTitle: vi.fn(), +})); function mockMatchMedia(matches: boolean) { Object.defineProperty(window, 'matchMedia', { @@ -25,61 +38,116 @@ function mockMatchMedia(matches: boolean) { }); } -vi.mock('./components/platform-entry/PlatformEntryFlowShell', async () => { - const React = await import('react'); - type PlatformEntryFlowShellProps = ComponentProps< - typeof import('./components/platform-entry/PlatformEntryFlowShell').PlatformEntryFlowShell - >; - +vi.mock('./services/appTitle', async (importOriginal) => { + const actual = await importOriginal(); return { - PlatformEntryFlowShell: ({ - selectionStage, - setSelectionStage, - }: PlatformEntryFlowShellProps) => - React.createElement( - 'div', - null, - React.createElement( - 'div', - { 'data-testid': 'selection-stage' }, - selectionStage, - ), - React.createElement( - 'button', - { - type: 'button', - onClick: () => { - setSelectionStage('image-editor', { - path: '/editor/canvas?projectid=project-from-test', - }); - }, - }, - '打开最近项目', - ), - ), + ...actual, + syncAppTitle: appTitleMock.syncAppTitle, }; }); -vi.mock('./RpgRuntimeApp', () => ({ - RpgRuntimeApp: () =>
运行态
, +vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({ + PlatformEntryFlowShell: ({ + handleCustomWorldSelect, + setSelectionStage, + selectionStage, + }: PlatformEntryFlowShellProps) => ( +
+
{selectionStage}
+
+ {canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'} +
+ + + +
+ ), })); +vi.mock('./RpgRuntimeApp', () => ({ + RpgRuntimeApp: ({ onExitRuntime }: { onExitRuntime: () => void }) => ( + + ), +})); + +function renderApp() { + return render( + undefined), + musicVolume: 0.6, + openAccountModal: vi.fn(), + openLoginModal: vi.fn(), + openSettingsModal: vi.fn(), + platformTheme: 'light', + requireAuth: vi.fn(), + setCurrentUser: vi.fn(), + setMusicVolume: vi.fn(), + setPlatformTheme: vi.fn(), + settingsError: null, + user: null, + }} + > + + , + ); +} + afterEach(() => { + appTitleMock.syncAppTitle.mockReset(); + window.history.replaceState(null, '', '/'); + delete window.__TAURI__; + delete window.ReactNativeWebView; + resetHostRuntimeCacheForTest(); + resetNativeAppHostBridgeForTest(); vi.restoreAllMocks(); }); describe('resolveInitialSelectionStageFromPath', () => { - it('opens the desktop root route on the creation home stage', () => { + test('桌面端根路径进入创作主页', () => { expect(resolveInitialSelectionStageFromPath('/', true)).toBe( 'creation-home', ); }); - it('keeps the mobile root route on the platform stage', () => { + test('移动端根路径仍进入平台首页', () => { expect(resolveInitialSelectionStageFromPath('/', false)).toBe('platform'); }); - it('keeps explicit routes mapped to their own stages on desktop', () => { + test('显式创作路由保持原目标阶段', () => { expect(resolveInitialSelectionStageFromPath('/creation/puzzle', true)).toBe( 'puzzle-agent-workspace', ); @@ -89,25 +157,150 @@ describe('resolveInitialSelectionStageFromPath', () => { }); }); +describe('App title sync', () => { + test('主站阶段变化会同步浏览器与宿主标题', () => { + renderApp(); + + expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿'); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' })); + }); + + expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( + '拼图创作 - 陶泥儿', + ); + }); + + test('RPG runtime 进入和退出时同步窗口标题', async () => { + renderApp(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '进入 RPG' })); + }); + + expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( + 'RPG 运行中 - 陶泥儿', + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '退出 RPG' })); + }); + + expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿'); + }); + + test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => { + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostShell=tauri_desktop', + ); + window.__TAURI__ = { + core: { + invoke: vi.fn(async (_command, args) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities: ['host.getRuntime', 'share.open'], + }, + }; + }), + }, + }; + + renderApp(); + + expect(screen.getByTestId('share-capability').textContent).toBe( + 'disabled', + ); + await screen.findByText('enabled'); + expect(screen.getByTestId('share-capability').textContent).toBe( + 'enabled', + ); + }); + + test('原生壳直达二级页面时补齐 H5 返回锚点', async () => { + window.history.replaceState( + null, + '', + '/creation/puzzle?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack', + ); + window.__TAURI__ = { + core: { + invoke: vi.fn(async (_command, args) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities: ['host.events', 'navigation.canGoBack'], + }, + }; + }), + }, + }; + + renderApp(); + + expect(screen.getByTestId('selection-stage').textContent).toBe( + 'puzzle-agent-workspace', + ); + expect(window.location.pathname).toBe('/creation/puzzle'); + expect(window.location.search).toBe( + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack', + ); + await waitFor(() => { + expect(canUseNativeHostCapability('navigation.canGoBack')).toBe(true); + }); + + await act(async () => { + window.history.back(); + }); + + await waitFor(() => { + expect(window.location.pathname).toBe('/'); + }); + expect(window.location.search).toBe( + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack', + ); + expect(screen.getByTestId('selection-stage').textContent).toBe('platform'); + }); +}); + describe('App navigation history', () => { - it('renders the desktop home URL with the creation home stage', () => { + test('桌面端首页 URL 渲染为创作主页阶段', () => { mockMatchMedia(true); window.history.replaceState(null, '', '/'); - render(); + renderApp(); expect(screen.getByTestId('selection-stage').textContent).toBe( 'creation-home', ); }); - it('keeps project canvas navigation as one history entry with projectid', async () => { + test('项目画布导航只写入一次带 projectid 的历史记录', async () => { mockMatchMedia(true); window.history.replaceState(null, '', '/creation'); const pushStateSpy = vi.spyOn(window.history, 'pushState'); const user = userEvent.setup(); - render(); + renderApp(); await user.click(screen.getByRole('button', { name: '打开最近项目' })); @@ -117,7 +310,7 @@ describe('App navigation history', () => { }); expect(pushStateSpy).toHaveBeenCalledTimes(1); expect(pushStateSpy).toHaveBeenCalledWith( - null, + { [APP_HISTORY_STATE_KEY]: true }, '', '/editor/canvas?projectid=project-from-test', ); diff --git a/src/App.tsx b/src/App.tsx index 7fd3d6412..1bff59a58 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,16 +14,27 @@ import type { CustomWorldRuntimeLaunchOptions, SelectionStage, } from './components/platform-entry/platformEntryTypes'; +import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack'; import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes'; import { APP_RUNTIME_ROUTES, + isAppHistoryState, normalizeAppPath, pushAppHistoryPath, + replaceAppHistoryPath, readPublicWorkCodeFromLocationSearch, resolveInitialSelectionStageFromPath, resolvePathForSelectionStage, } from './routing/appPageRoutes'; import type { RpgRuntimeAppIntent } from './RpgRuntimeApp'; +import { + resolveAppTitleForSelectionStage, + syncAppTitle, +} from './services/appTitle'; +import { + refreshNativeAppHostRuntime, + subscribeHostRuntimeChange, +} from './services/host-bridge/hostBridge'; import type { CustomWorldProfile } from './types'; const RpgRuntimeApp = lazy(async () => { @@ -54,8 +65,13 @@ function isRpgRuntimeRoute(pathname: string) { export default function App() { const authUi = useAuthUi(); const runtimeIntentTokenRef = useRef(0); + const hasHostNavigationAnchorRef = useRef( + isAppHistoryState(window.history.state), + ); + const hostNavigation = useHostNavigationCanGoBack(); const [runtimeIntent, setRuntimeIntent] = useState(null); + const [, setHostRuntimeRevision] = useState(0); const [isRuntimeActive, setIsRuntimeActive] = useState(() => isRpgRuntimeRoute(window.location.pathname), ); @@ -79,8 +95,22 @@ export default function App() { [], ); + useEffect(() => { + const unsubscribe = subscribeHostRuntimeChange(() => { + setHostRuntimeRevision((revision) => revision + 1); + }); + + void refreshNativeAppHostRuntime(); + + return unsubscribe; + }, []); + useEffect(() => { const syncStageFromHistory = () => { + hasHostNavigationAnchorRef.current = isAppHistoryState( + window.history.state, + ); + if (isRpgRuntimeRoute(window.location.pathname)) { setIsRuntimeActive(true); return; @@ -99,6 +129,31 @@ export default function App() { return () => window.removeEventListener('popstate', syncStageFromHistory); }, []); + useEffect(() => { + if ( + !hostNavigation.isSupported || + hostNavigation.canGoBack || + isRuntimeActive || + selectionStage === 'platform' || + isAppHistoryState(window.history.state) || + hasHostNavigationAnchorRef.current + ) { + return; + } + + const currentPath = normalizeAppPath(window.location.pathname); + const currentSearch = window.location.search; + + hasHostNavigationAnchorRef.current = true; + replaceAppHistoryPath('/'); + pushAppHistoryPath(`${currentPath}${currentSearch}`); + }, [ + hostNavigation.canGoBack, + hostNavigation.isSupported, + isRuntimeActive, + selectionStage, + ]); + const createRuntimeIntent = useCallback( (intent: Omit) => { runtimeIntentTokenRef.current += 1; @@ -148,6 +203,14 @@ export default function App() { ? 'bg-white p-0' : 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4'; + useEffect(() => { + syncAppTitle( + isRuntimeActive + ? 'RPG 运行中 - 陶泥儿' + : resolveAppTitleForSelectionStage(selectionStage), + ); + }, [isRuntimeActive, selectionStage]); + if (isRuntimeActive) { return ( }> diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index 31c6675fc..25a3dbe69 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -13,6 +13,7 @@ import { useState } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService'; +import * as hostBridgeServices from '../services/host-bridge/hostBridge'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import type { CustomWorldNpc, @@ -29,6 +30,8 @@ import { afterEach(() => { cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); vi.mock('../data/characterPresets', async () => { @@ -159,6 +162,11 @@ vi.mock('../services/customWorldCoverAssetService', () => ({ uploadCustomWorldCoverImage: vi.fn(), })); +vi.mock('../services/host-bridge/hostBridge', () => ({ + canUseNativeHostCapability: vi.fn(() => false), + importHostImageFile: vi.fn(), +})); + function createBackstoryReveal() { return { publicSummary: '公开背景', @@ -1741,6 +1749,108 @@ test('开局场景列表与详情幕预览复用同一套幕级图片', async () ).toBe('/generated-custom-world-scenes/camp-act-2.png'); }); +test('场景图片参考图在原生壳内优先走 HostBridge 图片导入并进入生成 payload', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({ + action: 'selected', + fileName: 'native-scene-reference.png', + base64Data: + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', + mimeType: 'image/png', + bytes: 68, + }); + mockedRpgCreationAssetClient.generateSceneImage.mockClear(); + mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ + imageSrc: '/generated-custom-world-scenes/native-reference-scene.png', + assetId: 'asset-native-reference-scene', + model: 'wan2.2-t2i-flash', + size: '1280*720', + taskId: 'task-native-reference-scene', + prompt: '带参考图的场景图', + }); + + class MockFileReader { + result: string | null = null; + error: Error | null = null; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsDataURL() { + this.result = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII='; + this.onload?.(); + } + } + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + const user = userEvent.setup(); + render(); + + const firstActCard = getSceneActCard(0); + await user.click( + within(firstActCard).getByRole('button', { name: '配置背景' }), + ); + await user.click(screen.getByRole('button', { name: 'AI生成' })); + await waitFor(() => { + expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); + }); + + await user.click(screen.getByText('上传自定义参考图')); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + expect(screen.getByText('已载入自定义参考图')).toBeTruthy(); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: '开始生成' })); + await waitFor(() => { + expect( + mockedRpgCreationAssetClient.generateSceneImage, + ).toHaveBeenCalledTimes(1); + }); + + const payload = + mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0]; + expect(payload?.referenceImageSrc).toMatch(/^data:image\/png;base64,/u); +}); + +test('场景图片参考图取消原生导入时不触发浏览器文件输入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + const user = userEvent.setup(); + render(); + + const firstActCard = getSceneActCard(0); + await user.click( + within(firstActCard).getByRole('button', { name: '配置背景' }), + ); + await user.click(screen.getByRole('button', { name: 'AI生成' })); + await waitFor(() => { + expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); + }); + + await user.click(screen.getByText('上传自定义参考图')); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(screen.queryByText('已载入自定义参考图')).toBeNull(); +}); + test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ @@ -2194,3 +2304,149 @@ test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async '/generated-custom-world-covers/world-1/uploaded/cover.webp', ); }); + +test('作品封面上传在原生壳内优先走 HostBridge 图片导入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({ + action: 'selected', + fileName: 'native-cover.png', + base64Data: + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', + mimeType: 'image/png', + bytes: 68, + }); + + class MockFileReader { + result: string | null = null; + error: Error | null = null; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsDataURL() { + this.result = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII='; + this.onload?.(); + } + } + + class MockImage { + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + naturalWidth = 1920; + naturalHeight = 1080; + + set src(_value: string) { + this.onload?.(); + } + } + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); + vi.stubGlobal('Image', MockImage as unknown as typeof Image); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('上传封面')); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + expect(screen.getByText('裁剪上传封面')).toBeTruthy(); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect( + screen.getByRole('img', { name: '上传封面裁剪预览' }), + ).toBeTruthy(); +}); + +test('作品封面取消原生导入时不触发浏览器文件输入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('上传封面')); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(screen.queryByText('裁剪上传封面')).toBeNull(); +}); + +test('作品封面参考图在原生壳内优先走 HostBridge 图片导入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({ + action: 'selected', + fileName: 'native-cover-reference.png', + base64Data: + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', + mimeType: 'image/png', + bytes: 68, + }); + + class MockFileReader { + result: string | null = null; + error: Error | null = null; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsDataURL() { + this.result = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII='; + this.onload?.(); + } + } + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'AI 生成' })); + await user.click(screen.getByText('上传封面参考图')); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + expect(screen.getByText('已载入封面参考图')).toBeTruthy(); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(screen.getByRole('img', { name: '封面参考图' })).toBeTruthy(); +}); + +test('作品封面参考图取消原生导入时不触发浏览器文件输入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'AI 生成' })); + await user.click(screen.getByText('上传封面参考图')); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(screen.queryByText('已载入封面参考图')).toBeNull(); +}); diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index cd28c3762..5a7714f6b 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -7,9 +7,15 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments'; -import { AuthGate, setAuthGateReloadForTest } from './AuthGate'; +import { + AuthGate, + setAuthGateBrowserReloadForTest, + setAuthGateReloadForTest, +} from './AuthGate'; import { useAuthUi } from './AuthUiContext'; +const browserReloadMock = vi.hoisted(() => vi.fn()); + const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), @@ -26,8 +32,6 @@ const authMocks = vi.hoisted(() => ({ getAuthAuditLogs: vi.fn(), getAuthRiskBlocks: vi.fn(), getAuthSessions: vi.fn(), - isWechatMiniProgramWebViewRuntime: vi.fn(() => false), - requestWechatMiniProgramPhoneLogin: vi.fn(), revokeAuthSessions: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -62,14 +66,10 @@ vi.mock('../../services/authService', () => ({ getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: authMocks.getAuthSessions, getCaptchaChallengeFromError: vi.fn(() => null), - isWechatMiniProgramWebViewRuntime: - authMocks.isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAuthUser: authMocks.logoutAuthUser, - requestWechatMiniProgramPhoneLogin: - authMocks.requestWechatMiniProgramPhoneLogin, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, revokeAuthSessions: authMocks.revokeAuthSessions, @@ -78,6 +78,28 @@ vi.mock('../../services/authService', () => ({ startWechatLogin: authMocks.startWechatLogin, })); +const hostBridgeMocks = vi.hoisted(() => ({ + getHostRuntime: vi.fn(() => ({ + kind: 'browser', + clientType: null as string | null, + clientRuntime: null as string | null, + hostShell: null as string | null, + hostPlatform: null as string | null, + hostVersion: null as string | null, + hostCapabilities: [], + nativeRuntimeTrusted: false, + miniProgramEnv: null as string | null, + })), + requestHostLogin: vi.fn(), + reloadHostWebView: vi.fn(), +})); + +vi.mock('../../services/host-bridge/hostBridge', () => ({ + getHostRuntime: hostBridgeMocks.getHostRuntime, + requestHostLogin: hostBridgeMocks.requestHostLogin, + reloadHostWebView: hostBridgeMocks.reloadHostWebView, +})); + vi.mock('../../hooks/useGameSettings', () => ({ useGameSettings: () => ({ musicVolume: 0.42, @@ -118,6 +140,7 @@ beforeEach(() => { window.localStorage.clear(); window.history.replaceState(null, '', '/'); setAuthGateReloadForTest(vi.fn()); + setAuthGateBrowserReloadForTest(browserReloadMock); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getStoredAccessToken.mockReturnValue(''); @@ -165,12 +188,24 @@ beforeEach(() => { expiresInSeconds: 300, }); authMocks.startWechatLogin.mockResolvedValue(undefined); - authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false); - authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true); + hostBridgeMocks.getHostRuntime.mockReturnValue({ + kind: 'browser', + clientType: null, + clientRuntime: null, + hostShell: null, + hostPlatform: null, + hostVersion: null, + hostCapabilities: [], + nativeRuntimeTrusted: false, + miniProgramEnv: null, + }); + hostBridgeMocks.requestHostLogin.mockResolvedValue(true); + hostBridgeMocks.reloadHostWebView.mockResolvedValue(false); }); afterEach(() => { setAuthGateReloadForTest(null); + setAuthGateBrowserReloadForTest(null); }); async function acceptLegalConsent( @@ -471,7 +506,17 @@ test('auth gate opens a login modal for protected actions and resumes after logi test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => { const user = userEvent.setup(); - authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true); + hostBridgeMocks.getHostRuntime.mockReturnValue({ + kind: 'wechat_mini_program', + clientType: null, + clientRuntime: 'wechat_mini_program', + hostShell: null, + hostPlatform: null, + hostVersion: null, + hostCapabilities: [], + nativeRuntimeTrusted: false, + miniProgramEnv: null, + }); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone', 'wechat'], }); @@ -485,13 +530,11 @@ test('auth gate uses mini program auth bridge instead of opening login modal in await user.click(await screen.findByRole('button', { name: '进入作品' })); await waitFor(() => { - expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes( - 1, - ); + expect(hostBridgeMocks.requestHostLogin).toHaveBeenCalledTimes(1); }); expect(authMocks.startWechatLogin).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); - expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled(); + expect(hostBridgeMocks.getHostRuntime).toHaveBeenCalled(); }); test('login modal requires first-time legal consent before sms login', async () => { @@ -777,6 +820,56 @@ test('logout withdraws user context before backend request finishes', async () = expect(reload).toHaveBeenCalledTimes(1); }); +test('auth state reload uses native host webview reload before browser reload', async () => { + const user = userEvent.setup(); + setAuthGateReloadForTest(null); + hostBridgeMocks.reloadHostWebView.mockResolvedValueOnce(true); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '退出登录' })); + + await waitFor(() => { + expect(hostBridgeMocks.reloadHostWebView).toHaveBeenCalledTimes(1); + }); + expect(browserReloadMock).not.toHaveBeenCalled(); +}); + +test('auth state reload falls back to browser reload when native host cannot reload', async () => { + const user = userEvent.setup(); + setAuthGateReloadForTest(null); + hostBridgeMocks.reloadHostWebView.mockResolvedValueOnce(false); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '退出登录' })); + + await waitFor(() => { + expect(hostBridgeMocks.reloadHostWebView).toHaveBeenCalledTimes(1); + expect(browserReloadMock).toHaveBeenCalledTimes(1); + }); +}); + test('auth gate shows sms send feedback in the login modal', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 87161bac9..715c72586 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components */ + import { type ReactNode, useCallback, @@ -32,19 +34,22 @@ import { getAuthSessions, getCaptchaChallengeFromError, getCurrentAuthUser, - isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, logoutAuthUser, redeemRegistrationInviteCode, - requestWechatMiniProgramPhoneLogin, resetPassword, revokeAuthSessions, sendPhoneLoginCode, setStoredLastLoginPhone, startWechatLogin, } from '../../services/authService'; +import { + getHostRuntime, + reloadHostWebView, + requestHostLogin, +} from '../../services/host-bridge/hostBridge'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { AccountModal } from './AccountModal'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; @@ -66,12 +71,31 @@ type AuthStatus = const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password']; -let reloadCurrentPageForAuthStateChange = () => { +let reloadBrowserPageForAuthStateChange = () => { window.location.reload(); }; +function reloadHostPageForAuthStateChange() { + void reloadHostWebView() + .then((handled) => { + if (!handled) { + reloadBrowserPageForAuthStateChange(); + } + }) + .catch(() => { + reloadBrowserPageForAuthStateChange(); + }); +} + +let reloadCurrentPageForAuthStateChange = reloadHostPageForAuthStateChange; + export function setAuthGateReloadForTest(handler: (() => void) | null) { reloadCurrentPageForAuthStateChange = + handler ?? reloadHostPageForAuthStateChange; +} + +export function setAuthGateBrowserReloadForTest(handler: (() => void) | null) { + reloadBrowserPageForAuthStateChange = handler ?? (() => { window.location.reload(); @@ -328,7 +352,7 @@ export function AuthGate({ children }: AuthGateProps) { const requestMiniProgramLogin = useCallback(() => { setWechatLoading(true); setError(''); - void requestWechatMiniProgramPhoneLogin() + void requestHostLogin() .catch((miniProgramError) => { setError( miniProgramError instanceof Error @@ -349,7 +373,7 @@ export function AuthGate({ children }: AuthGateProps) { } pendingProtectedActionRef.current = postLoginAction ?? null; - if (isWechatMiniProgramWebViewRuntime()) { + if (getHostRuntime().kind === 'wechat_mini_program') { setShowLoginModal(false); requestMiniProgramLogin(); return; @@ -761,7 +785,7 @@ export function AuthGate({ children }: AuthGateProps) { { - window.location.reload(); + reloadCurrentPageForAuthStateChange(); }} > 重新尝试 diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index d763296cf..669704f3d 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -7,7 +7,7 @@ import type { AuthLoginMethod, } from '../../services/authService'; import { getStoredLastLoginPhone } from '../../services/authService'; -import { isWechatMiniProgramWebViewRuntime } from '../../services/authService'; +import { getHostRuntime } from '../../services/host-bridge/hostBridge'; import { LegalDocumentModal } from '../common/LegalDocumentModal'; import { getLegalDocument, @@ -96,7 +96,7 @@ export function LoginScreen({ const passwordLoginEnabled = true; const phoneLoginEnabled = true; const wechatLoginEnabled = availableLoginMethods.includes('wechat'); - const miniProgramRuntime = isWechatMiniProgramWebViewRuntime(); + const miniProgramRuntime = getHostRuntime().kind === 'wechat_mini_program'; const [activeLoginTab, setActiveLoginTab] = useState('phone'); useEffect(() => { diff --git a/src/components/bark-battle-creation/BarkBattleResultView.test.tsx b/src/components/bark-battle-creation/BarkBattleResultView.test.tsx index a6c5787df..41039a294 100644 --- a/src/components/bark-battle-creation/BarkBattleResultView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleResultView.test.tsx @@ -2,12 +2,16 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { regenerateBarkBattleImageAsset, uploadBarkBattleAsset, } from '../../services/bark-battle-creation'; +import { + canUseNativeHostCapability, + importHostImageFile, +} from '../../services/host-bridge/hostBridge'; import { BarkBattleResultView } from './BarkBattleResultView'; vi.mock('../../services/bark-battle-creation', () => ({ @@ -15,6 +19,11 @@ vi.mock('../../services/bark-battle-creation', () => ({ uploadBarkBattleAsset: vi.fn(), })); +vi.mock('../../services/host-bridge/hostBridge', () => ({ + canUseNativeHostCapability: vi.fn(), + importHostImageFile: vi.fn(), +})); + vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, @@ -41,6 +50,13 @@ const draft = { }; describe('BarkBattleResultView', () => { + beforeEach(() => { + vi.mocked(uploadBarkBattleAsset).mockReset(); + vi.mocked(regenerateBarkBattleImageAsset).mockReset(); + vi.mocked(canUseNativeHostCapability).mockReset(); + vi.mocked(importHostImageFile).mockReset(); + }); + it('exposes draft preview actions before publish', async () => { const user = userEvent.setup(); const onStartTestRun = vi.fn(); @@ -168,6 +184,153 @@ describe('BarkBattleResultView', () => { ); }); + it('imports replacement image assets through native HostBridge', async () => { + const user = userEvent.setup(); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + const onDraftChange = vi.fn(); + vi.mocked(canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(importHostImageFile).mockResolvedValue({ + action: 'selected', + fileName: '玩家形象.png', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + }); + vi.mocked(uploadBarkBattleAsset).mockResolvedValue({ + assetObjectId: 'asset-player-1', + assetKind: 'bark_battle_player_character_image', + objectKey: 'generated-bark-battle-assets/player.png', + assetSrc: '/generated-bark-battle-assets/player.png', + }); + + try { + render( + {}} + onDraftChange={onDraftChange} + onStartTestRun={() => {}} + onPublish={() => {}} + />, + ); + + const playerSlot = screen + .getByRole('heading', { name: '玩家形象' }) + .closest('article'); + expect(playerSlot).toBeTruthy(); + await user.click( + within(playerSlot as HTMLElement).getByRole('button', { + name: '上传', + }), + ); + + await waitFor(() => { + expect(uploadBarkBattleAsset).toHaveBeenCalledWith( + expect.objectContaining({ + slot: 'player-character', + draftId: 'bark-battle-draft-1', + file: expect.any(File), + }), + ); + }); + + const uploadedFile = vi.mocked(uploadBarkBattleAsset).mock.calls[0]?.[0] + .file; + expect(importHostImageFile).toHaveBeenCalledTimes(1); + expect(uploadedFile?.name).toBe('玩家形象.png'); + expect(uploadedFile?.type).toBe('image/png'); + expect(uploadedFile?.size).toBe(5); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ + playerCharacterImageSrc: '/generated-bark-battle-assets/player.png', + }), + ); + } finally { + inputClickSpy.mockRestore(); + } + }); + + it('keeps native image cancellation inside shell flow', async () => { + const user = userEvent.setup(); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + vi.mocked(canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(importHostImageFile).mockResolvedValue(false); + + try { + render( + {}} + onDraftChange={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + />, + ); + + const playerSlot = screen + .getByRole('heading', { name: '玩家形象' }) + .closest('article'); + expect(playerSlot).toBeTruthy(); + await user.click( + within(playerSlot as HTMLElement).getByRole('button', { + name: '上传', + }), + ); + + await waitFor(() => { + expect(importHostImageFile).toHaveBeenCalledTimes(1); + }); + + expect(uploadBarkBattleAsset).not.toHaveBeenCalled(); + expect(inputClickSpy).not.toHaveBeenCalled(); + } finally { + inputClickSpy.mockRestore(); + } + }); + + it('falls back to browser picker without native image capability', async () => { + const user = userEvent.setup(); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + try { + render( + {}} + onDraftChange={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + />, + ); + + const playerSlot = screen + .getByRole('heading', { name: '玩家形象' }) + .closest('article'); + expect(playerSlot).toBeTruthy(); + await user.click( + within(playerSlot as HTMLElement).getByRole('button', { + name: '上传', + }), + ); + + expect(inputClickSpy).toHaveBeenCalledTimes(1); + expect(importHostImageFile).not.toHaveBeenCalled(); + } finally { + inputClickSpy.mockRestore(); + } + }); + it('does not render the raw object key or asset path in the slot summary', () => { render( void; }) { const fileInputRef = useRef(null); + const [isImportingImage, setIsImportingImage] = useState(false); const [isUploading, setIsUploading] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); const assetSrc = getSlotAssetSrc(draft, slot); const assetStatus = assetSrc ? '已替换' : '未替换'; - const handleUpload = async (event: ChangeEvent) => { - const file = event.currentTarget.files?.[0] ?? null; - event.currentTarget.value = ''; - if (!file) { + const uploadFile = async (file: File) => { + if (isUploading || isRegenerating) { return; } @@ -169,6 +186,49 @@ function BarkBattleAssetSlotControl({ } }; + const handleUpload = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0] ?? null; + event.currentTarget.value = ''; + if (!file) { + return; + } + + await uploadFile(file); + }; + + const openUploadPicker = () => { + if (disabled || isImportingImage || isUploading || isRegenerating) { + return; + } + + if (canUseNativeHostCapability('file.importImage')) { + void (async () => { + setIsImportingImage(true); + try { + const importedImage = await importHostImageFile(); + if (!importedImage) { + return; + } + + await uploadFile( + base64ImageToFile( + importedImage.base64Data, + importedImage.fileName, + importedImage.mimeType, + ), + ); + } finally { + setIsImportingImage(false); + } + })().catch((error) => { + onError(error instanceof Error ? error.message : '上传素材失败。'); + }); + return; + } + + fileInputRef.current?.click(); + }; + const handleRegenerate = async () => { setIsRegenerating(true); onError(null); @@ -187,7 +247,7 @@ function BarkBattleAssetSlotControl({ } }; - const isSlotBusy = isUploading || isRegenerating; + const isSlotBusy = isImportingImage || isUploading || isRegenerating; return ( fileInputRef.current?.click()} + onClick={openUploadPicker} tone="secondary" size="xs" shape="pill" diff --git a/src/components/common/CreativeAudioInputPanel.test.tsx b/src/components/common/CreativeAudioInputPanel.test.tsx index 8835ef31b..f33c724c6 100644 --- a/src/components/common/CreativeAudioInputPanel.test.tsx +++ b/src/components/common/CreativeAudioInputPanel.test.tsx @@ -4,10 +4,20 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from '../../services/host-bridge/hostBridge'; +import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge'; import type { CreativeAudioAsset } from './creativeAudioFileAsset'; import { CreativeAudioInputPanel } from './CreativeAudioInputPanel'; type TestAudioAsset = CreativeAudioAsset; +type ExportableTestAudioAsset = TestAudioAsset & { + blob: Blob; + fileName: string; + mimeType: string; +}; const originalMediaRecorder = globalThis.MediaRecorder; const originalMediaDevices = navigator.mediaDevices; @@ -18,6 +28,10 @@ afterEach(() => { configurable: true, value: originalMediaDevices, }); + window.history.replaceState(null, '', '/'); + delete window.__TAURI__; + resetNativeAppHostBridgeForTest(); + resetHostRuntimeCacheForTest(); vi.restoreAllMocks(); }); @@ -34,6 +48,28 @@ function buildAsset(overrides: Partial = {}): TestAudioAsset { }; } +function buildExportableAsset( + overrides: Partial = {}, +): ExportableTestAudioAsset { + return { + ...buildAsset(), + blob: new Blob(['audio'], { type: 'audio/wav' }), + fileName: 'hit.wav', + mimeType: 'audio/wav', + ...overrides, + }; +} + +function trustNativeHostRuntime(capabilities: Parameters[0]['capabilities']) { + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities, + }); +} + function renderPanel( overrides: Partial< ComponentProps> @@ -159,6 +195,178 @@ test('上传音频成功后清空错误并写入资产', async () => { expect(onError).toHaveBeenCalledWith(null); }); +test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + action: 'selected', + fileName: 'hit.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 5, + }, + }; + }, + ); + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostCapabilities=file.importAudio', + ); + trustNativeHostRuntime(['file.importAudio']); + window.__TAURI__ = { + core: { + invoke: async ( + command: string, + args?: Record, + ) => (await invoke(command, args)) as Result, + }, + }; + const { readFileAsAsset, onAssetChange, onError } = renderPanel(); + + fireEvent.click(screen.getByText('上传')); + + await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1)); + const [file, source] = readFileAsAsset.mock.calls[0]!; + expect(file).toBeInstanceOf(File); + expect((file as File).name).toBe('hit.webm'); + expect((file as File).type).toBe('audio/webm'); + expect(source).toBe('uploaded'); + await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1)); + expect(onError).toHaveBeenCalledWith(null); + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'file.importAudio', + timeoutMs: 30000, + }), + }); +}); + +test('原生 App 宿主可用且当前音频为本地资产时可以导出音频', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + action: 'saved', + fileName: 'hit.wav', + bytes: 5, + }, + }; + }, + ); + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostCapabilities=file.exportAudio', + ); + trustNativeHostRuntime(['file.exportAudio']); + window.__TAURI__ = { + core: { + invoke: async ( + command: string, + args?: Record, + ) => (await invoke(command, args)) as Result, + }, + }; + const { onError } = renderPanel({ + asset: buildExportableAsset(), + }); + + fireEvent.click(screen.getByRole('button', { name: '导出' })); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'file.exportAudio', + payload: { + fileName: 'hit.wav', + base64Data: 'YXVkaW8=', + mimeType: 'audio/wav', + }, + timeoutMs: 30000, + }), + }), + ); + expect(onError).toHaveBeenCalledWith(null); +}); + +test('非本地音频资产或未声明能力时不显示导出入口', () => { + const { rerender } = renderPanel({ + asset: buildExportableAsset(), + }); + + expect(screen.queryByRole('button', { name: '导出' })).toBeNull(); + + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostCapabilities=file.exportAudio', + ); + trustNativeHostRuntime(['file.exportAudio']); + rerender( + + title="敲击音效" + defaultLabel="默认木鱼音" + asset={buildAsset({ audioSrc: '/generated/hit.wav' })} + buildRecordedFileName={() => 'recorded-hit.webm'} + onAssetChange={() => {}} + onError={() => {}} + />, + ); + + expect(screen.queryByRole('button', { name: '导出' })).toBeNull(); +}); + +test('导出音频失败时提示错误', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: false, + error: { + code: 'host_error', + message: '系统保存失败。', + }, + }; + }, + ); + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostCapabilities=file.exportAudio', + ); + trustNativeHostRuntime(['file.exportAudio']); + window.__TAURI__ = { + core: { + invoke: async ( + command: string, + args?: Record, + ) => (await invoke(command, args)) as Result, + }, + }; + const { onError } = renderPanel({ + asset: buildExportableAsset(), + }); + + fireEvent.click(screen.getByRole('button', { name: '导出' })); + + await waitFor(() => expect(onError).toHaveBeenCalledWith('系统保存失败。')); +}); + test('上传音频失败时提示错误且不写入资产', async () => { const readFileAsAsset = vi.fn(async () => { throw new Error('音频最长 1 秒。'); diff --git a/src/components/common/CreativeAudioInputPanel.tsx b/src/components/common/CreativeAudioInputPanel.tsx index 2243e701d..e2c3535ae 100644 --- a/src/components/common/CreativeAudioInputPanel.tsx +++ b/src/components/common/CreativeAudioInputPanel.tsx @@ -1,6 +1,12 @@ -import { Mic, Pause, Upload } from 'lucide-react'; +import { Download, Mic, Pause, Upload } from 'lucide-react'; import { useRef, useState } from 'react'; +import { + canUseNativeHostCapability, + exportHostAudioFile, + type HostFileImportAudioResult, + importHostAudioFile, +} from '../../services/host-bridge/hostBridge'; import { type CreativeAudioAsset, readCreativeAudioFileAsAsset, @@ -24,6 +30,67 @@ type CreativeAudioInputPanelProps = { ) => Promise; }; +type ExportableCreativeAudioAsset = CreativeAudioAsset & { + blob?: Blob; + fileName?: string; + mimeType?: string; +}; + +const HOST_EXPORT_AUDIO_MIME_TYPES = new Set([ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', +]); + +function resolveExportableAudioAsset( + asset: TAsset | null, +) { + const candidate = asset as ExportableCreativeAudioAsset | null; + if ( + !candidate?.blob || + !(candidate.blob instanceof Blob) || + !candidate.fileName?.trim() || + !candidate.mimeType?.trim() || + !HOST_EXPORT_AUDIO_MIME_TYPES.has(candidate.mimeType) + ) { + return null; + } + + return { + blob: candidate.blob, + fileName: candidate.fileName.trim(), + mimeType: candidate.mimeType as + | 'audio/mpeg' + | 'audio/mp4' + | 'audio/wav' + | 'audio/ogg' + | 'audio/webm', + }; +} + +function blobToBase64Data(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('音频导出失败。')); + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error('音频导出失败。')); + return; + } + + const base64Data = reader.result.split(',')[1] ?? ''; + if (!base64Data) { + reject(new Error('音频导出失败。')); + return; + } + resolve(base64Data); + }; + reader.readAsDataURL(blob); + }); +} + export function CreativeAudioInputPanel({ disabled = false, title, @@ -38,6 +105,49 @@ export function CreativeAudioInputPanel({ const [isRecording, setIsRecording] = useState(false); const recorderRef = useRef(null); const chunksRef = useRef([]); + const canImportHostAudio = canUseNativeHostCapability('file.importAudio'); + const canExportHostAudio = canUseNativeHostCapability('file.exportAudio'); + const exportableAsset = resolveExportableAudioAsset(asset); + + const hostAudioImportResultToFile = (result: HostFileImportAudioResult) => { + const binary = atob(result.base64Data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return new File([bytes], result.fileName, { + type: result.mimeType, + }); + }; + + const importHostAudioAsUploadedAsset = async () => { + const result = await importHostAudioFile(); + if (!result) { + return; + } + + const file = hostAudioImportResultToFile(result); + const nextAsset = await readFileAsAsset(file, 'uploaded'); + onError(null); + onAssetChange(nextAsset); + }; + + const exportHostAudioAsset = async () => { + if (!exportableAsset) { + return; + } + + const base64Data = await blobToBase64Data(exportableAsset.blob); + const exported = await exportHostAudioFile({ + fileName: exportableAsset.fileName, + base64Data, + mimeType: exportableAsset.mimeType, + }); + if (exported) { + onError(null); + } + }; const startRecording = async () => { if (disabled || isRecording) { @@ -109,17 +219,40 @@ export function CreativeAudioInputPanel({ } titleVariant="strong" actions={ - asset ? ( - onAssetChange(null)} - disabled={disabled} - tone="ghost" - size="xs" - className="min-h-0" - > - 重置 - - ) : null +
+ {canExportHostAudio && exportableAsset ? ( + { + void exportHostAudioAsset().catch((caughtError) => { + onError( + caughtError instanceof Error + ? caughtError.message + : '音频导出失败。', + ); + }); + }} + disabled={disabled} + tone="ghost" + size="xs" + className="min-h-0 gap-1" + title="导出音频" + > + + 导出 + + ) : null} + {asset ? ( + onAssetChange(null)} + disabled={disabled} + tone="ghost" + size="xs" + className="min-h-0" + > + 重置 + + ) : null} +
} bodyClassName="mt-3 flex flex-wrap items-center gap-2" > @@ -129,6 +262,23 @@ export function CreativeAudioInputPanel({ className={`min-h-10 cursor-pointer gap-2 px-3 ${ disabled ? 'pointer-events-none opacity-55' : '' }`} + onClick={(event) => { + if (disabled) { + return; + } + if (!canImportHostAudio) { + return; + } + + event.preventDefault(); + void importHostAudioAsUploadedAsset().catch((caughtError) => { + onError( + caughtError instanceof Error + ? caughtError.message + : '音频读取失败。', + ); + }); + }} > 上传 diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx index e2efcd73d..052491da1 100644 --- a/src/components/common/CreativeImageInputPanel.test.tsx +++ b/src/components/common/CreativeImageInputPanel.test.tsx @@ -1,10 +1,29 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; -import { expect, test, vi } from 'vitest'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { + canUseNativeHostCapability, + captureHostImageFile, + importHostImageFile, + subscribeHostImageDrop, +} from '../../services/host-bridge/hostBridge'; import { CreativeImageInputPanel } from './CreativeImageInputPanel'; +vi.mock('../../services/host-bridge/hostBridge', () => ({ + captureHostImageFile: vi.fn(), + canUseNativeHostCapability: vi.fn(() => false), + importHostImageFile: vi.fn(), + subscribeHostImageDrop: vi.fn(() => () => undefined), +})); + vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, @@ -17,6 +36,25 @@ vi.mock('../ResolvedAssetImage', () => ({ }) => (src ? {alt} : null), })); +const captureHostImageFileMock = vi.mocked(captureHostImageFile); +const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability); +const importHostImageFileMock = vi.mocked(importHostImageFile); +const subscribeHostImageDropMock = vi.mocked(subscribeHostImageDrop); +type HostImageDropListener = Parameters[0]; + +afterEach(() => { + vi.clearAllMocks(); + canUseNativeHostCapabilityMock.mockReturnValue(false); + subscribeHostImageDropMock.mockReturnValue(() => undefined); +}); + +function requireHostImageDropListener( + listener: HostImageDropListener | null, +) { + expect(listener).not.toBeNull(); + return listener as HostImageDropListener; +} + test('creative image input panel handles reference uploads and preview', () => { const onPromptReferenceFilesSelect = vi.fn(); const onPromptReferenceRemove = vi.fn(); @@ -711,3 +749,527 @@ test('creative image input panel can upload prompt references while showing a ma screen.getByRole('button', { name: '预览参考图 描述参考图 1' }), ).toBeTruthy(); }); + +test('creative image input panel imports the main image from native host capability', async () => { + const onMainImageFileSelect = vi.fn(); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + canUseNativeHostCapabilityMock.mockReturnValue(true); + importHostImageFileMock.mockResolvedValue({ + action: 'selected', + fileName: '参考图.png', + mimeType: 'image/png', + base64Data: 'aW1hZ2U=', + bytes: 5, + }); + + try { + render( + {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '上传拼图图片' })); + + await waitFor(() => { + expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File)); + }); + const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File; + expect(selectedFile.name).toBe('参考图.png'); + expect(selectedFile.type).toBe('image/png'); + expect(inputClickSpy).not.toHaveBeenCalled(); + } finally { + inputClickSpy.mockRestore(); + } +}); + +test('creative image input panel imports prompt reference image from native host capability', async () => { + const onPromptReferenceFilesSelect = vi.fn(); + canUseNativeHostCapabilityMock.mockReturnValue(true); + importHostImageFileMock.mockResolvedValue({ + action: 'selected', + fileName: '描述参考.webp', + mimeType: 'image/webp', + base64Data: 'cmVm', + bytes: 3, + }); + + render( + {}} + onMainImageRemove={() => {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onPromptReferenceFilesSelect={onPromptReferenceFilesSelect} + onSubmit={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '上传参考图' })); + + await waitFor(() => { + expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([ + expect.any(File), + ]); + }); + const selectedFile = onPromptReferenceFilesSelect.mock.calls[0]?.[0]?.[0] as + | File + | undefined; + expect(selectedFile?.name).toBe('描述参考.webp'); + expect(selectedFile?.type).toBe('image/webp'); +}); + +test('creative image input panel captures the main image from native host capability', async () => { + const onMainImageFileSelect = vi.fn(); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => capability === 'file.captureImage', + ); + captureHostImageFileMock.mockResolvedValue({ + action: 'captured', + fileName: '拍摄图.jpg', + mimeType: 'image/jpeg', + base64Data: 'Y2FtZXJh', + bytes: 6, + }); + + try { + render( + {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '拍摄图片' })); + + await waitFor(() => { + expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File)); + }); + const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File; + expect(selectedFile.name).toBe('拍摄图.jpg'); + expect(selectedFile.type).toBe('image/jpeg'); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(importHostImageFileMock).not.toHaveBeenCalled(); + } finally { + inputClickSpy.mockRestore(); + } +}); + +test('creative image input panel captures prompt reference image from native host capability', async () => { + const onPromptReferenceFilesSelect = vi.fn(); + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => capability === 'file.captureImage', + ); + captureHostImageFileMock.mockResolvedValue({ + action: 'captured', + fileName: '参考拍摄.png', + mimeType: 'image/png', + base64Data: 'cmVm', + bytes: 3, + }); + + render( + {}} + onMainImageRemove={() => {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onPromptReferenceFilesSelect={onPromptReferenceFilesSelect} + onSubmit={() => {}} + />, + ); + + const captureButtons = screen.getAllByRole('button', { name: '拍摄图片' }); + expect(captureButtons).toHaveLength(2); + fireEvent.click(captureButtons[1] as HTMLElement); + + await waitFor(() => { + expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([ + expect.any(File), + ]); + }); + const selectedFile = onPromptReferenceFilesSelect.mock.calls[0]?.[0]?.[0] as + | File + | undefined; + expect(selectedFile?.name).toBe('参考拍摄.png'); + expect(selectedFile?.type).toBe('image/png'); + expect(importHostImageFileMock).not.toHaveBeenCalled(); +}); + +test('creative image input panel keeps callbacks quiet when native image capture is cancelled', async () => { + const onMainImageFileSelect = vi.fn(); + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => capability === 'file.captureImage', + ); + captureHostImageFileMock.mockResolvedValue(false); + + render( + {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '拍摄图片' })); + + await waitFor(() => { + expect(captureHostImageFileMock).toHaveBeenCalledTimes(1); + }); + expect(onMainImageFileSelect).not.toHaveBeenCalled(); +}); + +test('creative image input panel keeps callbacks quiet when native image import is cancelled', async () => { + const onMainImageFileSelect = vi.fn(); + canUseNativeHostCapabilityMock.mockReturnValue(true); + importHostImageFileMock.mockResolvedValue(false); + + render( + {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '上传拼图图片' })); + + await waitFor(() => { + expect(importHostImageFileMock).toHaveBeenCalledTimes(1); + }); + expect(onMainImageFileSelect).not.toHaveBeenCalled(); +}); + +test('creative image input panel accepts desktop host image drops inside the main image card', async () => { + const onMainImageFileSelect = vi.fn(); + let dropListener: HostImageDropListener | null = null; + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => + capability === 'file.importImage' || capability === 'file.imageDropped', + ); + subscribeHostImageDropMock.mockImplementation((listener) => { + dropListener = listener; + return () => undefined; + }); + + const { container } = render( + {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + const card = container.querySelector('.creative-image-input-panel__image-card'); + expect(card).not.toBeNull(); + vi.spyOn(card as Element, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + right: 210, + bottom: 220, + width: 200, + height: 200, + x: 10, + y: 20, + toJSON: () => ({}), + }); + const elementFromPointMock = vi.fn(() => card); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + requireHostImageDropListener(dropListener)({ + action: 'dropped', + fileName: '拖入图.webp', + mimeType: 'image/webp', + base64Data: 'ZHJvcA==', + bytes: 4, + position: { + x: 32, + y: 48, + }, + }); + + await waitFor(() => { + expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File)); + }); + const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File; + expect(selectedFile.name).toBe('拖入图.webp'); + expect(selectedFile.type).toBe('image/webp'); + Reflect.deleteProperty(document, 'elementFromPoint'); +}); + +test('creative image input panel ignores desktop host image drops outside or behind another element', () => { + const onMainImageFileSelect = vi.fn(); + let dropListener: HostImageDropListener | null = null; + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => + capability === 'file.importImage' || capability === 'file.imageDropped', + ); + subscribeHostImageDropMock.mockImplementation((listener) => { + dropListener = listener; + return () => undefined; + }); + + const { container } = render( + {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + const card = container.querySelector('.creative-image-input-panel__image-card'); + expect(card).not.toBeNull(); + vi.spyOn(card as Element, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + right: 210, + bottom: 220, + width: 200, + height: 200, + x: 10, + y: 20, + toJSON: () => ({}), + }); + + const emitDrop = requireHostImageDropListener(dropListener); + + emitDrop({ + action: 'dropped', + fileName: '卡片外.png', + mimeType: 'image/png', + base64Data: 'b3V0', + bytes: 3, + position: { + x: 4, + y: 48, + }, + }); + expect(onMainImageFileSelect).not.toHaveBeenCalled(); + + const overlay = document.createElement('div'); + const elementFromPointMock = vi.fn(() => overlay); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + emitDrop({ + action: 'dropped', + fileName: '遮挡图.png', + mimeType: 'image/png', + base64Data: 'b3Zlcg==', + bytes: 4, + position: { + x: 32, + y: 48, + }, + }); + expect(onMainImageFileSelect).not.toHaveBeenCalled(); + Reflect.deleteProperty(document, 'elementFromPoint'); +}); diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index ea0b9b247..9f0b38f60 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -1,6 +1,21 @@ -import { History, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react'; +import { + Camera, + History, + ImagePlus, + Loader2, + Sparkles, + Trash2, +} from 'lucide-react'; import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { + canUseNativeHostCapability, + captureHostImageFile, + type HostFileImportImageResult, + importHostImageFile, + subscribeHostImageDrop, +} from '../../services/host-bridge/hostBridge'; +import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformActionButton } from './PlatformActionButton'; import { PlatformFieldLabel } from './PlatformFieldLabel'; @@ -86,6 +101,13 @@ export type CreativeImageInputPanelProps = { const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp'; const DEFAULT_PROMPT_REFERENCE_LIMIT = 5; +function hostImageImportResultToFile(result: HostFileImportImageResult) { + return puzzleReferenceImageDataUrlToFile( + `data:${result.mimeType};base64,${result.base64Data}`, + result.fileName, + ); +} + export function CreativeImageInputPanel({ className = '', fillHeight = true, @@ -131,7 +153,9 @@ export function CreativeImageInputPanel({ onHistoryClick, onSubmit, }: CreativeImageInputPanelProps) { + const mainImageCardRef = useRef(null); const mainImageInputRef = useRef(null); + const promptReferenceInputRef = useRef(null); const [previewReferenceImage, setPreviewReferenceImage] = useState(null); const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false); @@ -151,6 +175,11 @@ export function CreativeImageInputPanel({ mainImageClickMode === 'preview' && Boolean(uploadedImageSrc); const shouldShowMainImageUploadButton = isMainImageUploadEnabled && shouldPreviewMainImage; + const canImportHostImage = canUseNativeHostCapability('file.importImage'); + const canCaptureHostImage = canUseNativeHostCapability('file.captureImage'); + const canReceiveHostImageDrop = + canUseNativeHostCapability('file.imageDropped'); + const promptReferenceInputId = `${mainImageInputId}-prompt-reference`; useEffect(() => { if (uploadedImageSrc) { @@ -171,6 +200,50 @@ export function CreativeImageInputPanel({ } }, [previewReferenceImage, promptReferenceImages]); + useEffect(() => { + if (!canReceiveHostImageDrop || disabled || !isMainImageUploadEnabled) { + return undefined; + } + + return subscribeHostImageDrop((payload) => { + const card = mainImageCardRef.current; + const position = payload.position; + if (!card || !position) { + return; + } + + const bounds = card.getBoundingClientRect(); + const isInsideCard = + position.x >= bounds.left && + position.x <= bounds.right && + position.y >= bounds.top && + position.y <= bounds.bottom; + if (!isInsideCard) { + return; + } + + const topElement = + typeof document.elementFromPoint === 'function' + ? document.elementFromPoint(position.x, position.y) + : null; + // 中文注释:桌面拖入是窗口级事件;只让坐标命中的最上层主图槽位消费,避免多个创作面板同时接收同一张图。 + if (topElement && !card.contains(topElement)) { + return; + } + + try { + onMainImageFileSelect(hostImageImportResultToFile(payload)); + } catch { + // 中文注释:宿主已校验图片类型和体积;这里仅兜住浏览器 File 构造异常,保持当前表单状态。 + } + }); + }, [ + canReceiveHostImageDrop, + disabled, + isMainImageUploadEnabled, + onMainImageFileSelect, + ]); + const bodyClassName = fillHeight ? 'creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1' : 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1'; @@ -188,6 +261,107 @@ export function CreativeImageInputPanel({ ? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full' : 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem]'; + const importHostImageAsFile = async () => { + const result = await importHostImageFile(); + if (!result) { + return null; + } + return hostImageImportResultToFile(result); + }; + + const captureHostImageAsFile = async () => { + const result = await captureHostImageFile(); + if (!result) { + return null; + } + return hostImageImportResultToFile(result); + }; + + const handleMainImageUploadClick = () => { + if (disabled || !isMainImageUploadEnabled) { + return; + } + if (!canImportHostImage) { + mainImageInputRef.current?.click(); + return; + } + + void (async () => { + try { + const file = await importHostImageAsFile(); + if (file) { + onMainImageFileSelect(file); + } + } catch { + // 中文注释:宿主导入失败时不再弹浏览器文件框,避免权限失败后重复打扰用户。 + } + })(); + }; + + const handleMainImageCaptureClick = () => { + if (disabled || !isMainImageUploadEnabled || !canCaptureHostImage) { + return; + } + + void (async () => { + try { + const file = await captureHostImageAsFile(); + if (file) { + onMainImageFileSelect(file); + } + } catch { + // 中文注释:相机拍摄失败或用户取消时保持当前表单状态,不回落到相册或文件框。 + } + })(); + }; + + const handlePromptReferenceUploadClick = () => { + if ( + promptReferenceUploadDisabled || + !shouldShowPromptReferences || + !onPromptReferenceFilesSelect + ) { + return; + } + if (!canImportHostImage) { + promptReferenceInputRef.current?.click(); + return; + } + + void (async () => { + try { + const file = await importHostImageAsFile(); + if (file) { + onPromptReferenceFilesSelect([file]); + } + } catch { + // 中文注释:宿主导入失败时保持当前表单状态,由外层错误通道继续承接后续重试。 + } + })(); + }; + + const handlePromptReferenceCaptureClick = () => { + if ( + promptReferenceUploadDisabled || + !shouldShowPromptReferences || + !onPromptReferenceFilesSelect || + !canCaptureHostImage + ) { + return; + } + + void (async () => { + try { + const file = await captureHostImageAsFile(); + if (file) { + onPromptReferenceFilesSelect([file]); + } + } catch { + // 中文注释:相机拍摄失败不打断当前创作输入,用户可继续选择上传或重试拍摄。 + } + })(); + }; + return (
-
+
{isMainImageUploadEnabled ? ( setIsMainImagePreviewOpen(true)} /> ) : isMainImageUploadEnabled ? ( - + ) : null} {uploadedImageSrc ? ( mainImageInputRef.current?.click()} + onClick={handleMainImageUploadClick} icon={} className="absolute bottom-3 right-3 z-10 h-10 w-10" /> @@ -298,6 +479,19 @@ export function CreativeImageInputPanel({ 历史 ) : null} + {isMainImageUploadEnabled && canCaptureHostImage ? ( + } + className={`absolute top-3 z-10 h-10 w-10 ${ + shouldShowHistoryButton ? 'right-[4.75rem]' : 'right-3' + }`} + /> + ) : null} {canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? ( ) : isMainImageUploadEnabled && !uploadedImageSrc ? ( - + ) : null}
@@ -370,39 +566,65 @@ export function CreativeImageInputPanel({ {imageModelPicker} {shouldShowPromptReferences && onPromptReferenceFilesSelect ? ( - - - + { + const files = Array.from( + event.currentTarget.files ?? [], + ); + event.currentTarget.value = ''; + if (files.length > 0) { + onPromptReferenceFilesSelect(files); + } + }} + className="sr-only" + /> +
+ {canCaptureHostImage ? ( + { - const files = Array.from( - event.currentTarget.files ?? [], - ); - event.currentTarget.value = ''; - if (files.length > 0) { - onPromptReferenceFilesSelect(files); - } - }} - className="sr-only" + onClick={handlePromptReferenceCaptureClick} + icon={} + className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]" /> - - } - className={`absolute bottom-3 right-3 z-10 h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${ - promptReferenceUploadDisabled - ? 'cursor-not-allowed opacity-55' - : 'cursor-pointer' - }`} - /> + ) : null} + {canImportHostImage ? ( + } + className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]" + /> + ) : ( + } + className={`h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${ + promptReferenceUploadDisabled + ? 'cursor-not-allowed opacity-55' + : 'cursor-pointer' + }`} + /> + )} +
+ ) : null}
{shouldShowPromptReferences && diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx index 51c5c7d44..1d47fff98 100644 --- a/src/components/common/PublishShareModal.test.tsx +++ b/src/components/common/PublishShareModal.test.tsx @@ -9,8 +9,12 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; +import { HOST_BRIDGE_PUBLIC_WEB_ORIGIN } from '../../../packages/shared/src/contracts/hostBridge'; import * as clipboardService from '../../services/clipboard'; -import * as shareGridService from '../../services/wechatMiniProgramShareGrid'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from '../../services/host-bridge/hostBridge'; import { PublishShareModal } from './PublishShareModal'; import { buildMiniProgramPublishSharePath, @@ -24,10 +28,6 @@ import { vi.mock('../../services/clipboard', () => ({ copyTextToClipboard: vi.fn(), })); -vi.mock('../../services/wechatMiniProgramShareGrid', () => ({ - canUseWechatMiniProgramShareGrid: vi.fn(() => false), - openWechatMiniProgramShareGridPage: vi.fn(), -})); const payload: PublishShareModalPayload = { title: '暖灯猫街', @@ -39,12 +39,37 @@ const payload: PublishShareModalPayload = { afterEach(() => { vi.clearAllMocks(); - vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue( - false, - ); window.history.replaceState(null, '', '/'); + delete window.ReactNativeWebView; + delete window.__TAURI__; + window.wx = undefined; + resetHostRuntimeCacheForTest(); }); +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +) { + return async function tauriInvoke( + command: string, + args?: Record, + ) { + return (await invoke(command, args)) as Result; + }; +} + +function trustNativeHostRuntime( + shell: 'expo_mobile' | 'tauri_desktop', + capabilities: Parameters[0]['capabilities'], +) { + setHostRuntimeCacheForTest({ + shell, + platform: shell === 'expo_mobile' ? 'ios' : 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities, + }); +} + describe('PublishShareModal', () => { test('builds the publish share text with title, code and public url', () => { const text = buildPublishShareText(payload); @@ -103,6 +128,7 @@ describe('PublishShareModal', () => { expect(within(dialog).getByText('暖灯猫街')).toBeTruthy(); expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy(); expect(within(dialog).getByRole('button', { name: '下载卡片' })).toBeTruthy(); + expect(within(dialog).queryByRole('button', { name: '系统分享' })).toBeNull(); expect(within(dialog).queryByRole('button', { name: '九宫切图' })).toBeNull(); fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' })); @@ -141,8 +167,10 @@ describe('PublishShareModal', () => { }); test('shows the mini program grid action only inside mini program runtime', () => { - vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue( - true, + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', ); render( @@ -151,4 +179,215 @@ describe('PublishShareModal', () => { expect(screen.getByRole('button', { name: '九宫切图' })).toBeTruthy(); }); + + test('uses clipboard copy wording for Tauri native host share action', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: true, + }; + }, + ); + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open', + ); + trustNativeHostRuntime('tauri_desktop', ['share.open']); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + render( {}} />); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + expect(within(dialog).queryByRole('button', { name: '系统分享' })).toBeNull(); + fireEvent.click( + within(dialog).getByRole('button', { name: '复制分享文案' }), + ); + + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已复制' }), + ).toBeTruthy(); + }); + const rawShareUrl = new URL(buildPublishShareUrl(payload)); + const publicShareUrl = new URL( + `${rawShareUrl.pathname}${rawShareUrl.search}${rawShareUrl.hash}`, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + ).toString(); + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'share.open', + payload: { + title: '暖灯猫街', + message: '邀请你来玩《暖灯猫街》\n作品号:PZ-00000001', + url: publicShareUrl, + }, + }), + }); + }); + + test('keeps system share wording for Expo native host share action', async () => { + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=share.open', + ); + trustNativeHostRuntime('expo_mobile', ['share.open']); + const postMessage = vi.fn((rawMessage: string) => { + const request = JSON.parse(rawMessage) as { id: string }; + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: true, + }), + }), + ); + }); + window.ReactNativeWebView = { + postMessage, + }; + + render( {}} />); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + fireEvent.click(within(dialog).getByRole('button', { name: '系统分享' })); + + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已打开' }), + ).toBeTruthy(); + }); + expect(postMessage).toHaveBeenCalledWith( + expect.stringContaining('"method":"share.open"'), + ); + }); + + test('uses native host image export for share card download inside native app runtime', async () => { + class MockFileReader { + result: string | ArrayBuffer | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + readAsDataURL() { + this.result = 'data:image/png;base64,c2hhcmUtY2FyZA=='; + this.onload?.(); + } + } + vi.stubGlobal('FileReader', MockFileReader); + vi.stubGlobal( + 'Image', + class MockImage { + onload: (() => void) | null = null; + naturalWidth = 900; + naturalHeight = 900; + width = 900; + height = 900; + set src(_value: string) { + this.onload?.(); + } + }, + ); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + beginPath: vi.fn(), + clearRect: vi.fn(), + clip: vi.fn(), + closePath: vi.fn(), + createLinearGradient: vi.fn(() => ({ + addColorStop: vi.fn(), + })), + drawImage: vi.fn(), + fill: vi.fn(), + fillRect: vi.fn(), + fillText: vi.fn(), + lineTo: vi.fn(), + measureText: vi.fn((text: string) => ({ + width: Array.from(text).length * 32, + })), + moveTo: vi.fn(), + quadraticCurveTo: vi.fn(), + restore: vi.fn(), + save: vi.fn(), + stroke: vi.fn(), + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation( + (callback: BlobCallback) => { + callback(new Blob(['share-card'], { type: 'image/png' })); + }, + ); + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + action: 'saved', + fileName: '暖灯猫街-PZ-00000001.png', + bytes: 10, + }, + }; + }, + ); + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=file.exportImage', + ); + trustNativeHostRuntime('tauri_desktop', ['file.exportImage']); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + render( {}} />); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + fireEvent.click(within(dialog).getByRole('button', { name: '下载卡片' })); + + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已下载' }), + ).toBeTruthy(); + }); + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'file.exportImage', + payload: { + fileName: '暖灯猫街-PZ-00000001.png', + base64Data: 'c2hhcmUtY2FyZA==', + mimeType: 'image/png', + }, + }), + }); + }); + + test('does not show native share action when native app shell does not declare share capability', () => { + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostShell=tauri_desktop', + ); + + render( {}} />); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + expect(within(dialog).queryByRole('button', { name: '系统分享' })).toBeNull(); + expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy(); + }); }); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index e9bda83dc..16e193aa6 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -1,21 +1,24 @@ -import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react'; +import { Check, Copy, Download, Grid3X3, Link2, Share2 } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; -import { isWechatMiniProgramWebViewRuntime } from '../../services/authService'; import { copyTextToClipboard } from '../../services/clipboard'; import { - canUseWechatMiniProgramShareGrid, - openWechatMiniProgramShareGridPage, -} from '../../services/wechatMiniProgramShareGrid'; + canUseHostShareGrid, + canUseNativeHostCapability, + getHostRuntime, + openHostShare, + openHostShareGrid, +} from '../../services/host-bridge/hostBridge'; import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; import { downloadPublishShareCardImage } from './publishShareCardImage'; import { buildPublishShareCopyUrl, + buildPublishShareUrl, type PublishShareModalPayload, } from './publishShareModalModel'; -import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; type PublishShareModalProps = { open: boolean; @@ -25,6 +28,12 @@ type PublishShareModalProps = { type ActionState = 'idle' | 'success' | 'failed'; +type NativeSharePresentation = { + idleLabel: string; + successLabel: string; + failedLabel: string; +}; + function normalizePayloadTitle(payload: PublishShareModalPayload | null) { return payload?.title.trim() || '我的作品'; } @@ -41,6 +50,24 @@ function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) { return payload?.workTypeLabel?.trim() || '互动作品'; } +function resolveNativeSharePresentation( + hostShell: string | null, +): NativeSharePresentation { + if (hostShell === 'tauri_desktop') { + return { + idleLabel: '复制分享文案', + successLabel: '已复制', + failedLabel: '复制失败', + }; + } + + return { + idleLabel: '系统分享', + successLabel: '已打开', + failedLabel: '分享失败', + }; +} + /** * 发布完成后的通用分享弹窗。 * 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。 @@ -54,21 +81,32 @@ export function PublishShareModal({ const [copyState, setCopyState] = useState('idle'); const [downloadState, setDownloadState] = useState('idle'); const [gridState, setGridState] = useState('idle'); + const [nativeShareState, setNativeShareState] = + useState('idle'); const resetTimerRef = useRef(null); + const hostRuntime = getHostRuntime(); + const hostRuntimeKind = hostRuntime.kind; + const hostShell = hostRuntime.hostShell; + const nativeSharePresentation = resolveNativeSharePresentation(hostShell); const shareCopyUrl = useMemo( () => payload ? buildPublishShareCopyUrl(payload, { - miniProgramRuntime: isWechatMiniProgramWebViewRuntime(), + miniProgramRuntime: hostRuntimeKind === 'wechat_mini_program', }) : '', + [hostRuntimeKind, payload], + ); + const publicShareUrl = useMemo( + () => (payload ? buildPublishShareUrl(payload) : ''), [payload], ); const title = normalizePayloadTitle(payload); const coverImageSrc = resolvePayloadCoverImageSrc(payload); const workTypeLabel = resolvePayloadWorkTypeLabel(payload); const showMiniProgramGridButton = - canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc); + canUseHostShareGrid() && Boolean(coverImageSrc); + const showNativeShareButton = canUseNativeHostCapability('share.open'); useEffect( () => () => { @@ -83,6 +121,7 @@ export function PublishShareModal({ setCopyState('idle'); setDownloadState('idle'); setGridState('idle'); + setNativeShareState('idle'); }, [payload?.publicWorkCode]); const scheduleStateReset = () => { @@ -94,6 +133,7 @@ export function PublishShareModal({ setCopyState('idle'); setDownloadState('idle'); setGridState('idle'); + setNativeShareState('idle'); }, 1400); }; @@ -143,6 +183,27 @@ export function PublishShareModal({ }); }; + const openNativeShare = () => { + if (!payload || !publicShareUrl) { + return; + } + + setNativeShareState('idle'); + void openHostShare({ + title, + message: `邀请你来玩《${title}》\n作品号:${payload.publicWorkCode}`, + url: publicShareUrl, + }) + .then((opened) => { + setNativeShareState(opened ? 'success' : 'failed'); + scheduleStateReset(); + }) + .catch(() => { + setNativeShareState('failed'); + scheduleStateReset(); + }); + }; + const openMiniProgramGridDownload = () => { if (!payload || !coverImageSrc) { return; @@ -151,7 +212,7 @@ export function PublishShareModal({ setGridState('idle'); void resolveMiniProgramGridCover() .then((resolvedCoverImageSrc) => - openWechatMiniProgramShareGridPage({ + openHostShareGrid({ imageUrl: resolvedCoverImageSrc, title, publicWorkCode: payload.publicWorkCode, @@ -178,14 +239,39 @@ export function PublishShareModal({ footer={
+ {showNativeShareButton ? ( + + ) : null}
{activeSidebarPanel === 'assets' ? ( @@ -210,12 +251,34 @@ export function ImageCanvasSidebarView({ )}
+
+ { + if (activeSidebarPanel === 'assets') { + setAssetSearchQuery(event.target.value); + } else { + setLayerSearchQuery(event.target.value); + } + }} + /> +
+ {activeSidebarPanel === 'assets' ? ( ) : ( { activeUploadFolderId: 'characters', }), ).toBe('project'); + expect( + resolveUploadFolderId({ + assetFolders: [ + ...folders, + createFolder({ id: 'uploaded', label: '上传素材', systemDefault: false }), + ], + activeUploadFolderId: 'characters', + preferUploadFolder: true, + }), + ).toBe('uploaded'); }); it('creates uploading asset placeholders', () => { diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index c2e56661d..140e78f9b 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -9,6 +9,7 @@ import type { GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; +import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel'; import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel'; type CanvasSize = { width: number; height: number }; @@ -73,12 +74,19 @@ export function resolveUploadFolderId({ assetFolders, requestedFolderId, activeUploadFolderId, + preferUploadFolder = false, }: { assetFolders: EditorAssetFolder[]; requestedFolderId?: string; activeUploadFolderId: string; + preferUploadFolder?: boolean; }) { - const targetFolderId = requestedFolderId ?? activeUploadFolderId; + const targetFolderId = + requestedFolderId ?? + (preferUploadFolder + ? resolvePreferredAssetUploadFolder(assetFolders)?.id + : activeUploadFolderId) ?? + activeUploadFolderId; return assetFolders.some((folder) => folder.id === targetFolderId) ? targetFolderId : 'project'; diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index 3af967058..edf53dbcc 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -23,6 +23,7 @@ import type { import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; const createEditorAssetMock = vi.hoisted(() => vi.fn()); +const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorSeedanceReferenceFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); @@ -35,6 +36,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { return { ...actual, createEditorAsset: createEditorAssetMock, + createEditorAssetFolder: createEditorAssetFolderMock, createEditorProjectResource: createEditorProjectResourceMock, }; }); @@ -338,6 +340,12 @@ describe('useImageCanvasUploadWorkflow', () => { objectKey: 'object-key-uploaded', assetObjectId: 'asset-object-uploaded', })); + createEditorAssetFolderMock.mockResolvedValue({ + folderId: 'folder-uploaded', + label: '上传素材', + collapsed: false, + systemDefault: false, + }); createEditorProjectResourceMock.mockImplementation( async (projectId, input) => ({ resourceId: `resource-${input.objectKey ?? input.imageSrc.slice(0, 8)}`, @@ -421,9 +429,10 @@ describe('useImageCanvasUploadWorkflow', () => { }); await waitFor(() => { expect(screen.getByTestId('assets').textContent).toContain( - 'persisted-上传素材.png:上传素材.png:project:ready:-', + 'persisted-上传素材.png:上传素材.png:folder-uploaded:ready:-', ); }); + expect(createEditorAssetFolderMock).toHaveBeenCalledWith('上传素材', 101); }); it('opens login instead of the asset file picker when protected data is unavailable', () => { @@ -483,7 +492,7 @@ describe('useImageCanvasUploadWorkflow', () => { await waitFor(() => { expect(screen.getByTestId('assets').textContent).toContain( - 'upload-1:画布素材.png:project:uploading:上传中', + 'upload-1:画布素材.png:folder-uploaded:uploading:上传中', ); expect(screen.getByTestId('layers').textContent).toContain( 'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png', @@ -509,7 +518,7 @@ describe('useImageCanvasUploadWorkflow', () => { deferredAsset.resolve({ assetId: 'asset-persisted-canvas', - folderId: 'project', + folderId: 'folder-uploaded', label: '画布素材.png', imageSrc: 'data:image/png;base64,Y2FudmFz', width: 420, @@ -521,7 +530,7 @@ describe('useImageCanvasUploadWorkflow', () => { await waitFor(() => { expect(screen.getByTestId('assets').textContent).toContain( - 'asset-persisted-canvas:画布素材.png:project:ready:-', + 'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-', ); expect(screen.getByTestId('layers').textContent).toContain( 'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5', @@ -546,7 +555,7 @@ describe('useImageCanvasUploadWorkflow', () => { ); }); expect(screen.getByTestId('assets').textContent).toContain( - 'persisted-原始尺寸素材.png:原始尺寸素材.png:project:ready:-:1536x1024', + 'persisted-原始尺寸素材.png:原始尺寸素材.png:folder-uploaded:ready:-:1536x1024', ); expect(screen.getByTestId('layers').textContent).toContain( 'layer-upload-1:原始尺寸素材.png:persisted-原始尺寸素材.png:-718:-462:1536x1024', @@ -571,7 +580,7 @@ describe('useImageCanvasUploadWorkflow', () => { await waitFor(() => { expect(openEditorLoginModal).toHaveBeenCalledTimes(1); expect(screen.getByTestId('assets').textContent).toContain( - 'upload-1:上传素材.png:project:failed:请先登录', + 'upload-1:上传素材.png:folder-uploaded:failed:请先登录', ); }); }); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index 23f164a03..310516a34 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -11,6 +11,7 @@ import { ApiClientError } from '../../services/apiClient'; import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; import { createEditorAsset, + createEditorAssetFolder, createEditorProjectResource, } from '../../services/image-editor/editorProjectClient'; import { uploadEditorSeedanceReferenceFile } from '../../services/image-editor/editorReferenceUploadClient'; @@ -26,6 +27,11 @@ import type { QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; +import { + EDITOR_UPLOAD_ASSET_FOLDER_LABEL, + resolvePreferredAssetUploadFolder, + resolveUploadAssetFolder, +} from './ImageCanvasAssetLibraryModel'; import { SEEDANCE_REFERENCE_FILE_LIMITS, isImageFile, @@ -64,6 +70,7 @@ type UploadFilesOptions = { type UploadRequestOptions = { addToCanvas?: boolean; + folderId?: string; }; type UploadAssetFileOptions = UploadFilesOptions & { @@ -128,6 +135,7 @@ export function useImageCanvasUploadWorkflow({ const canAccessProtectedDataRef = useRef(canAccessProtectedData); const uploadTargetRef = useRef('asset'); const uploadRequestOptionsRef = useRef({}); + const uploadAssetFolderCreatePromiseRef = useRef | null>(null); const [uploadTarget, setUploadTargetState] = useState('asset'); canAccessProtectedDataRef.current = canAccessProtectedData; @@ -828,11 +836,48 @@ export function useImageCanvasUploadWorkflow({ return; } - const uploadFolderId = resolveUploadFolderId({ + let uploadFolderId = resolveUploadFolderId({ assetFolders, requestedFolderId: options.folderId, activeUploadFolderId, + preferUploadFolder: !options.folderId, }); + if (!options.folderId && !resolveUploadAssetFolder(assetFolders)) { + if (!uploadAssetFolderCreatePromiseRef.current) { + uploadAssetFolderCreatePromiseRef.current = createEditorAssetFolder( + EDITOR_UPLOAD_ASSET_FOLDER_LABEL, + assetFolders.length + 100, + ) + .then((folder) => { + setAssetFolders((currentFolders) => + currentFolders.some( + (currentFolder) => currentFolder.id === folder.folderId, + ) + ? currentFolders + : [ + ...currentFolders, + { + id: folder.folderId, + label: folder.label, + collapsed: folder.collapsed, + systemDefault: folder.systemDefault, + persisted: true, + }, + ], + ); + return folder.folderId; + }) + .catch(() => { + const fallbackFolder = + resolvePreferredAssetUploadFolder(assetFolders); + return fallbackFolder?.id ?? 'project'; + }) + .finally(() => { + uploadAssetFolderCreatePromiseRef.current = null; + }); + } + uploadFolderId = await uploadAssetFolderCreatePromiseRef.current; + } const uploadIndex = options.uploadIndex; let imageDimensions: { width: number; height: number } | null = null; if (mediaType === 'image') { @@ -1086,6 +1131,7 @@ export function useImageCanvasUploadWorkflow({ } else { const requestOptions = uploadRequestOptionsRef.current; addUploadedFiles(files, { + folderId: requestOptions.folderId, addToCanvas: requestOptions.addToCanvas ?? activeTool === 'upload', }); diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 3beee0f2e..356a640c2 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -10,6 +10,7 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks'; +import * as hostBridgeServices from '../../services/host-bridge/hostBridge'; import * as match3dWorksService from '../../services/match3d-works'; import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache'; import { Match3DResultView } from './Match3DResultView'; @@ -49,6 +50,11 @@ vi.mock('../../services/match3d-works', () => ({ updateMatch3DWork: vi.fn(), })); +vi.mock('../../services/host-bridge/hostBridge', () => ({ + canUseNativeHostCapability: vi.fn(() => false), + importHostImageFile: vi.fn(), +})); + vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => { const actual = await importOriginal< @@ -536,6 +542,93 @@ describe('Match3DResultView', () => { }); }); + test('发布封面图在原生壳内优先走 HostBridge 图片导入', async () => { + const uploadedDataUrl = 'data:image/png;base64,host-match3d-cover'; + stubMatch3DCoverUpload(uploadedDataUrl); + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({ + action: 'selected', + fileName: '抓大鹅封面.png', + base64Data: 'Y292ZXI=', + mimeType: 'image/png', + bytes: 5, + }); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + try { + render( + {}} + onStartTestRun={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '发布' })); + const publishDialog = screen.getByRole('dialog', { + name: '发布抓大鹅作品', + }); + fireEvent.click( + within(publishDialog).getByRole('button', { name: '上传封面图' }), + ); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + expect( + within(publishDialog).getByRole('switch', { name: 'AI重绘' }), + ).toBeTruthy(); + }); + expect( + within(publishDialog) + .getByRole('img', { name: '封面图预览' }) + .getAttribute('src'), + ).toBe(uploadedDataUrl); + expect(inputClickSpy).not.toHaveBeenCalled(); + } finally { + inputClickSpy.mockRestore(); + } + }); + + test('发布封面参考图取消原生导入时不触发浏览器文件输入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + try { + render( + {}} + onStartTestRun={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '发布' })); + const publishDialog = screen.getByRole('dialog', { + name: '发布抓大鹅作品', + }); + fireEvent.click( + within(publishDialog).getByRole('button', { name: '上传参考图' }), + ); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(within(publishDialog).queryByText('自定义参考图')).toBeNull(); + } finally { + inputClickSpy.mockRestore(); + } + }); + test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => { const profile = createProfile(); const onStartTestRun = vi.fn(); diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index 3dfe769d7..2a8d90403 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -17,6 +17,7 @@ import { type ReactNode, useEffect, useMemo, + useRef, useState, } from 'react'; @@ -29,6 +30,11 @@ import type { PutMatch3DWorkRequest, } from '../../../packages/shared/src/contracts/match3dWorks'; import { isGeneratedLegacyPath } from '../../services/assetReadUrlService'; +import { + canUseNativeHostCapability, + type HostFileImportImageResult, + importHostImageFile, +} from '../../services/host-bridge/hostBridge'; import { generateMatch3DCoverImage, generateMatch3DItemAssets, @@ -49,7 +55,10 @@ import { loadMatch3DSpritesheetAssetRegions, type Match3DDecodedSpritesheetRegion, } from '../../services/match3dSpritesheetParser'; -import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; +import { + puzzleReferenceImageDataUrlToFile, + readPuzzleReferenceImageAsDataUrl, +} from '../../services/puzzleReferenceImage'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; @@ -1152,6 +1161,13 @@ async function readCoverReferenceImageAsDataUrl(file: File) { return readPuzzleReferenceImageAsDataUrl(file); } +function hostMatch3DImageResultToFile(result: HostFileImportImageResult) { + return puzzleReferenceImageDataUrlToFile( + `data:${result.mimeType};base64,${result.base64Data}`, + result.fileName, + ); +} + function resolveMatch3DCoverSourceAssets( assetDrafts: Match3DItemAssetDraft[], backgroundPreviewSrc: string, @@ -1483,9 +1499,11 @@ type Match3DCoverImageEditorProps = { error: string | null; onAiRedrawChange: (enabled: boolean) => void; onFileChange: (event: ChangeEvent) => void; + onFileUploadClick: (fallback: () => void) => void; onPromptChange: (value: string) => void; onReferenceSelect: (source: string) => void; onReferenceFileChange: (event: ChangeEvent) => void; + onReferenceFileUploadClick: (fallback: () => void) => void; onReferenceRemove: (referenceId: string) => void; onUploadedImageRemove: () => void; onSubmit: () => void; @@ -1502,13 +1520,17 @@ function Match3DCoverImageEditor({ error, onAiRedrawChange, onFileChange, + onFileUploadClick, onPromptChange, onReferenceSelect, onReferenceFileChange, + onReferenceFileUploadClick, onReferenceRemove, onUploadedImageRemove, onSubmit, }: Match3DCoverImageEditorProps) { + const coverInputRef = useRef(null); + const referenceInputRef = useRef(null); const previewSrc = uploadedImageSrc || editState.coverImageSrc; const promptLabel = uploadedImageSrc ? 'AI重绘要求' : '封面描述'; const canSubmit = Boolean(uploadedImageSrc.trim() || prompt.trim()); @@ -1518,15 +1540,31 @@ function Match3DCoverImageEditor({