mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(mobile): stop support modules from registering as routes (#11652)
* fix(mobile): keep support modules out of Expo routes * test(mobile): parse Expo route exports * test(mobile): reject platform-specific API routes * test(mobile): reject platform API routes unconditionally
This commit is contained in:
@@ -14,7 +14,7 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { styles } from './accounts-screen-styles'
|
||||
import { styles } from '../../../src/accounts/mobile-accounts-screen-styles'
|
||||
import { useNow } from '../../../src/hooks/use-now'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
|
||||
@@ -267,8 +267,8 @@ import {
|
||||
reconcileMobileSessionCreateWarningState
|
||||
} from '../../../../src/session/mobile-session-create-warning-state'
|
||||
import { colors, spacing } from '../../../../src/theme/mobile-theme'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
|
||||
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
|
||||
import { styles } from '../../../../src/session/mobile-session-styles'
|
||||
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
|
||||
import type {
|
||||
DiffCommentActions,
|
||||
@@ -289,7 +289,7 @@ import type {
|
||||
TerminalCreateResult,
|
||||
TerminalGestureInputBucket,
|
||||
TerminalGestureInputQueue
|
||||
} from './mobile-session-route-types'
|
||||
} from '../../../../src/session/mobile-session-route-types'
|
||||
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
|
||||
import { colors, spacing, typography, radii } from '../theme/mobile-theme'
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -0,0 +1,121 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const appDirectory = fileURLToPath(new URL('../app', import.meta.url))
|
||||
const routeSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
return entry.isDirectory() ? sourceFiles(path) : [path]
|
||||
})
|
||||
}
|
||||
|
||||
function isNonScreenExpoModule(path: string): boolean {
|
||||
const fileName = basename(path)
|
||||
if (/\+api\.[jt]sx?$/.test(fileName)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
dirname(relative(appDirectory, path)) === '.' &&
|
||||
/^\+(?:html|middleware|native-intent)\.[jt]sx?$/.test(fileName)
|
||||
)
|
||||
}
|
||||
|
||||
function isPlatformSpecificApiRoute(path: string): boolean {
|
||||
return /\+api\.(?:android|ios|native|web)\.[jt]sx?$/.test(basename(path))
|
||||
}
|
||||
|
||||
function hasDefaultExport(path: string, source: string): boolean {
|
||||
const extension = extname(path)
|
||||
const sourceFile = ts.createSourceFile(
|
||||
path,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
extension === '.jsx'
|
||||
? ts.ScriptKind.JSX
|
||||
: extension === '.js'
|
||||
? ts.ScriptKind.JS
|
||||
: extension === '.tsx'
|
||||
? ts.ScriptKind.TSX
|
||||
: ts.ScriptKind.TS
|
||||
)
|
||||
|
||||
return sourceFile.statements.some((statement) => {
|
||||
if (ts.isExportAssignment(statement)) {
|
||||
return !statement.isExportEquals
|
||||
}
|
||||
if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause) {
|
||||
if (ts.isNamespaceExport(statement.exportClause)) {
|
||||
return statement.exportClause.name.text === 'default'
|
||||
}
|
||||
return statement.exportClause.elements.some(
|
||||
(element) => !element.isTypeOnly && element.name.text === 'default'
|
||||
)
|
||||
}
|
||||
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
|
||||
return (
|
||||
!ts.isInterfaceDeclaration(statement) &&
|
||||
!ts.isTypeAliasDeclaration(statement) &&
|
||||
modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) !== true &&
|
||||
modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true &&
|
||||
modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function isInvalidRouteModule(path: string, source: string): boolean {
|
||||
return (
|
||||
isPlatformSpecificApiRoute(path) ||
|
||||
(!isNonScreenExpoModule(path) && !hasDefaultExport(path, source))
|
||||
)
|
||||
}
|
||||
|
||||
describe('Expo route module boundary', () => {
|
||||
it('allows Expo modules that are not screen routes', () => {
|
||||
expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ts'))).toBe(true)
|
||||
expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ios.ts'))).toBe(false)
|
||||
expect(isNonScreenExpoModule(join(appDirectory, '+html.tsx'))).toBe(true)
|
||||
expect(isNonScreenExpoModule(join(appDirectory, '+middleware.ts'))).toBe(true)
|
||||
expect(isNonScreenExpoModule(join(appDirectory, '+native-intent.ts'))).toBe(true)
|
||||
expect(isNonScreenExpoModule(join(appDirectory, 'nested', '+middleware.ts'))).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes syntax-level default exports', () => {
|
||||
expect(hasDefaultExport('route.tsx', 'export default function Route() {}')).toBe(true)
|
||||
expect(
|
||||
hasDefaultExport('route.jsx', 'export default function Route() { return <View /> }')
|
||||
).toBe(true)
|
||||
expect(hasDefaultExport('route.ts', "export { default } from './route-screen'")).toBe(true)
|
||||
expect(hasDefaultExport('route.ts', "export { Route as default } from './route-screen'")).toBe(
|
||||
true
|
||||
)
|
||||
expect(hasDefaultExport('support.ts', '// export default')).toBe(false)
|
||||
expect(hasDefaultExport('support.ts', "const marker = 'export default'")).toBe(false)
|
||||
expect(hasDefaultExport('support.ts', 'export default interface Support {}')).toBe(false)
|
||||
expect(
|
||||
hasDefaultExport('support.ts', "export type { Support as default } from './types'")
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects platform-specific API routes even with a default export', () => {
|
||||
expect(isPlatformSpecificApiRoute(join(appDirectory, 'health+api.ts'))).toBe(false)
|
||||
for (const platform of ['android', 'ios', 'native', 'web']) {
|
||||
const path = join(appDirectory, `health+api.${platform}.ts`)
|
||||
expect(isInvalidRouteModule(path, 'export default function Route() {}')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps support modules outside the app route directory', () => {
|
||||
const invalidRoutes = sourceFiles(appDirectory)
|
||||
.filter((path) => routeSourceExtensions.has(extname(path)))
|
||||
.filter((path) => isInvalidRouteModule(path, readFileSync(path, 'utf8')))
|
||||
.map((path) => relative(appDirectory, path))
|
||||
|
||||
expect(invalidRoutes).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
|
||||
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { Pressable } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from '../../app/h/[hostId]/session/mobile-session-styles'
|
||||
import { styles } from './mobile-session-styles'
|
||||
|
||||
type HeaderIconProps = {
|
||||
size?: number
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Pressable, View } from 'react-native'
|
||||
import { SquareChevronRight } from 'lucide-react-native'
|
||||
|
||||
import { colors } from '../../../../src/theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-session-styles'
|
||||
|
||||
type Props = {
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
MarkdownDocState,
|
||||
MobileSessionTab
|
||||
} from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import {
|
||||
BULK_TAB_CLOSE_ACTIONS,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileSessionCommandInputStyles = StyleSheet.create({
|
||||
createWarningBanner: {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileSessionFrameStyles = StyleSheet.create({
|
||||
container: {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Platform, StyleSheet } from 'react-native'
|
||||
|
||||
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileSessionReaderStyles = StyleSheet.create({
|
||||
markdownTextInput: {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileSessionReviewCommentStyles = StyleSheet.create({
|
||||
diffCommentAddButton: {
|
||||
+7
-10
@@ -1,13 +1,10 @@
|
||||
import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
|
||||
import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract'
|
||||
import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
|
||||
import type {
|
||||
MobileHighlightedDiffLine,
|
||||
MobileSyntaxSegment
|
||||
} from '../../../../src/session/mobile-file-syntax'
|
||||
import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records'
|
||||
import type { DiffComment, TuiAgent } from '../../../../../src/shared/types'
|
||||
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
|
||||
import type { DiffComment, TuiAgent } from '../../../src/shared/types'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { MobileBrowserTab } from '../browser/MobileBrowserPane'
|
||||
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
|
||||
import type { MobileDiffLine } from './mobile-diff-lines'
|
||||
import type { MobileHighlightedDiffLine, MobileSyntaxSegment } from './mobile-file-syntax'
|
||||
import type { TerminalRecord } from './mobile-terminal-records'
|
||||
|
||||
export type Terminal = TerminalRecord
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import {
|
||||
getMobileSessionTabTitle,
|
||||
resolveMobileTerminalTabAgentId
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/termi
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import { isBlankBrowserUrl } from '../browser/browser-url'
|
||||
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
|
||||
// Why: tab identity + title cleaning uses the same shared glyph/label maps as
|
||||
// desktop, so the two platforms do not drift on which titles identify agents.
|
||||
|
||||
@@ -10,7 +10,7 @@ const liveInputStatusSource = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const commandInputStylesSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
|
||||
new URL('../session/mobile-session-command-input-styles.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const liveInputFocusSource = readFileSync(
|
||||
|
||||
Reference in New Issue
Block a user