Refine macOS access prompt guidance (#11822)

* Refine macOS access prompt guidance

* Capitalize MacOS in access prompt

* Use official macOS styling in prompt
This commit is contained in:
Brennan Benson
2026-07-31 15:13:41 -07:00
committed by GitHub
parent 751b6b119b
commit 4205d5f31f
12 changed files with 144 additions and 31 deletions
@@ -1,15 +1,21 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.title': {
es: '¿Ves avisos de “Orca quiere acceder…”?',
ja: '「Orca がアクセスしようとしています…」という確認が表示されますか?',
ko: '“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?',
zh: '看到“Orca 想要访问…”提示?'
},
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트 터미널 도구 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”减少此类提示。'
es: 'Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.',
ja: 'Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。',
ko: 'Orca에서 실행 중인 에이전트 터미널 도구가 보호된 파일 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.',
zh: '当 Orca 中运行的代理终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了し再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { DeveloperPermissionState } from '../../../../shared/developer-permissions-types'
import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { DeveloperPermissionsPane } from './DeveloperPermissionsPane'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
Object.assign(window, {
api: {
developerPermissions: {
getStatus: vi.fn(
async (): Promise<DeveloperPermissionState[]> => [
{ id: 'full-disk-access', status: 'denied' }
]
),
request: vi.fn()
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
Reflect.deleteProperty(window, 'api')
})
it('highlights the Full Disk Access row for a targeted Settings navigation', async () => {
await act(async () => {
root.render(
<DeveloperPermissionsPane highlightedSettingId={FULL_DISK_ACCESS_SETTINGS_TARGET_ID} />
)
})
const row = container.querySelector<HTMLElement>(
`[data-settings-section="${FULL_DISK_ACCESS_SETTINGS_TARGET_ID}"]`
)
expect(row?.dataset.highlighted).toBe('true')
expect(row?.className).toContain('data-[highlighted=true]:bg-accent')
expect(row?.className).toContain('data-[highlighted=true]:ring-ring/50')
await act(async () => root.render(<DeveloperPermissionsPane />))
expect(row?.dataset.highlighted).toBeUndefined()
})
@@ -23,6 +23,10 @@ import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search'
type DeveloperPermissionsPaneProps = {
highlightedSettingId?: string | null
}
type PermissionDefinition = {
id: DeveloperPermissionId
label: string
@@ -105,7 +109,7 @@ const PERMISSIONS: PermissionDefinition[] = [
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
"macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca and Orca Helper to reduce those prompts. Then quit Orca, end any surviving Orca Helper process, and reopen Orca."
"macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca to reduce those prompts. Then quit and reopen Orca."
)
},
actionLabel: 'Open Settings',
@@ -202,7 +206,9 @@ function statusClass(status: DeveloperPermissionStatus | undefined): string {
return 'border-border bg-muted text-muted-foreground'
}
export function DeveloperPermissionsPane(): React.JSX.Element {
export function DeveloperPermissionsPane({
highlightedSettingId = null
}: DeveloperPermissionsPaneProps): React.JSX.Element {
const [states, setStates] = useState<DeveloperPermissionState[]>([])
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState<DeveloperPermissionId | null>(null)
@@ -340,9 +346,15 @@ export function DeveloperPermissionsPane(): React.JSX.Element {
{PERMISSIONS.map((permission) => {
const status = stateById.get(permission.id)
const pending = pendingId === permission.id
const settingId = `developer-permissions-${permission.id}`
return (
<div key={permission.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div
key={permission.id}
data-settings-section={settingId}
data-highlighted={highlightedSettingId === settingId ? 'true' : undefined}
className="flex items-center justify-between gap-4 px-4 py-3 transition-[background-color,box-shadow] duration-500 data-[highlighted=true]:bg-accent data-[highlighted=true]:ring-2 data-[highlighted=true]:ring-inset data-[highlighted=true]:ring-ring/50 motion-reduce:transition-none"
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
<div className="min-w-0 space-y-1">
@@ -177,6 +177,7 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map<string, SettingsNavGroupDefinition>(
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
const SETTINGS_TARGET_HIGHLIGHT_MS = 3_000
function getSettingsSectionId(
pane: SettingsNavTarget,
@@ -372,6 +373,9 @@ function Settings(): React.JSX.Element {
getInitialMountedSectionIds
)
const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
const [highlightedSettingsTargetId, setHighlightedSettingsTargetId] = useState<string | null>(
null
)
const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0)
const [sshHostAddIntentSignal, setSshHostAddIntentSignal] = useState(0)
const [remoteServerAddIntentSignal, setRemoteServerAddIntentSignal] = useState(0)
@@ -446,6 +450,17 @@ function Settings(): React.JSX.Element {
}
}, [])
useEffect(() => {
if (!highlightedSettingsTargetId) {
return
}
const timeout = window.setTimeout(
() => setHighlightedSettingsTargetId(null),
SETTINGS_TARGET_HIGHLIGHT_MS
)
return () => window.clearTimeout(timeout)
}, [highlightedSettingsTargetId])
const requestFontSuggestions = useCallback((): void => {
if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) {
return
@@ -661,6 +676,11 @@ function Settings(): React.JSX.Element {
}
pendingNavSectionRef.current = paneSectionId
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
setHighlightedSettingsTargetId(
settingsNavigationTarget.pane === 'developer-permissions'
? (settingsNavigationTarget.sectionId ?? null)
: null
)
// Why: ensure Appearance's nested status-bar section is open before scrolling so the row is visible.
if (settingsNavigationTarget.pane === 'appearance') {
const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId)
@@ -1696,7 +1716,9 @@ function Settings(): React.JSX.Element {
searchEntries={getSectionSearchEntries('developer-permissions')}
>
{isSectionMounted('developer-permissions') ? (
<DeveloperPermissionsPane />
<DeveloperPermissionsPane
highlightedSettingId={highlightedSettingsTargetId}
/>
) : null}
</SettingsSection>
) : null}
@@ -9,6 +9,7 @@ import { UI_LANGUAGE_SPANISH } from '../../../shared/ui-language'
import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { i18n } from '@/i18n/i18n'
import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost'
import { useMacosTccPromptNotice } from './useMacosTccPromptNotice'
@@ -95,7 +96,7 @@ it('isolates plugin language-pack discovery from its parent render path', async
expect(subscribeToMacosTccPromptNotice).toHaveBeenCalledOnce()
})
it('keeps the notice open until the user closes it', async () => {
it('connects the notice to the macOS prompt and keeps it open until closed', async () => {
useAppStore.setState({
settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' }
})
@@ -111,6 +112,13 @@ it('keeps the notice open until the user closes it', async () => {
showNotice?.({ promptCount: 1 }, acknowledge)
expect(toastWarning).toHaveBeenCalledWith(
'Seeing “Orca would like to access…” prompts?',
expect.objectContaining({
description:
'Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.'
})
)
const options = toastWarning.mock.calls[0]?.[1] as
| { duration?: number; onDismiss?: () => void }
| undefined
@@ -141,4 +149,9 @@ it('acknowledges when opening Settings closes the notice', async () => {
| undefined
options?.action?.onClick()
expect(acknowledge).toHaveBeenCalledOnce()
expect(useAppStore.getState().settingsNavigationTarget).toEqual({
pane: 'developer-permissions',
repoId: null,
sectionId: FULL_DISK_ACCESS_SETTINGS_TARGET_ID
})
})
@@ -6,6 +6,7 @@ import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { translate } from '@/i18n/i18n'
import { resolveUiLocale } from '@/i18n/supported-languages'
import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import {
dismissMacosTccPromptNotice,
subscribeToMacosTccPromptNotice
@@ -41,12 +42,12 @@ export function useMacosTccPromptNotice(): void {
toast.warning(
translate(
'auto.hooks.useMacosTccPromptNotice.title',
'Reduce repeated macOS file-access prompts'
'Seeing “Orca would like to access…” prompts?'
),
{
description: translate(
'auto.hooks.useMacosTccPromptNotice.description',
'macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.'
'Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.'
),
duration: Infinity,
onDismiss: acknowledge,
@@ -55,7 +56,11 @@ export function useMacosTccPromptNotice(): void {
onClick: () => {
acknowledge()
openSettingsPage()
openSettingsTarget({ pane: 'developer-permissions', repoId: null })
openSettingsTarget({
pane: 'developer-permissions',
repoId: null,
sectionId: FULL_DISK_ACCESS_SETTINGS_TARGET_ID
})
}
},
cancel: {
+3 -3
View File
@@ -826,8 +826,8 @@
"pasteTooLarge": "Paste is too large."
},
"useMacosTccPromptNotice": {
"title": "Reduce repeated macOS file-access prompts",
"description": "macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.",
"title": "Seeing “Orca would like to access…” prompts?",
"description": "Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.",
"openSettings": "Open Settings",
"dismiss": "Don't show again"
}
@@ -5719,7 +5719,7 @@
"e7bb06007c": "LAN",
"4a73f5217a": "Apple Events for scripts that control other local apps.",
"e119f0d66b": "Automation",
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca and Orca Helper to reduce those prompts. Then quit Orca, end any surviving Orca Helper process, and reopen Orca.",
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca to reduce those prompts. Then quit and reopen Orca.",
"c566bca278": "Full Disk Access",
"9f35980756": "Keystroke injection, window control, and UI automation tools.",
"5b2f22ca2d": "Accessibility",
+3 -3
View File
@@ -802,8 +802,8 @@
"pasteTooLarge": "El contenido pegado es demasiado grande."
},
"useMacosTccPromptNotice": {
"title": "Reduce los avisos repetidos de acceso a archivos de macOS",
"description": "macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.",
"title": "¿Ves avisos de “Orca quiere acceder…”?",
"description": "Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.",
"openSettings": "Abrir ajustes",
"dismiss": "No volver a mostrar"
}
@@ -5686,7 +5686,7 @@
"e7bb06007c": "LAN",
"4a73f5217a": "Apple Events para scripts que controlan otras apps locales.",
"e119f0d66b": "Automatización",
"7ca17b62c8": "Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.",
"7ca17b62c8": "Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.",
"c566bca278": "Acceso total al disco",
"9f35980756": "Herramientas de inyección de teclas, control de ventanas y automatización de UI.",
"5b2f22ca2d": "Accesibilidad",
+3 -3
View File
@@ -802,8 +802,8 @@
"pasteTooLarge": "貼り付け内容が大きすぎます。"
},
"useMacosTccPromptNotice": {
"title": "macOSのファイルアクセス権限の確認を減らす",
"description": "エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。",
"title": "「Orca がアクセスしようとしています…」という確認が表示されますか?",
"description": "Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。",
"openSettings": "設定を開く",
"dismiss": "今後表示しない"
}
@@ -5671,7 +5671,7 @@
"e7bb06007c": "LAN",
"4a73f5217a": "他のローカル アプリを制御するスクリプトの Apple Events。",
"e119f0d66b": "オートメーション",
"7ca17b62c8": "Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。",
"7ca17b62c8": "Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了し再度開いてください。",
"c566bca278": "フルディスクアクセス",
"9f35980756": "キーストローク インジェクション、ウィンドウ コントロール、UI 自動化ツール。",
"5b2f22ca2d": "アクセシビリティ",
+3 -3
View File
@@ -802,8 +802,8 @@
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
},
"useMacosTccPromptNotice": {
"title": "반복되는 macOS 파일 접근 권한 요청 줄이기",
"description": "에이전트 터미널 도구 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.",
"title": "“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?",
"description": "Orca에서 실행 중인 에이전트 터미널 도구가 보호된 파일 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.",
"openSettings": "설정 열기",
"dismiss": "다시 표시하지 않기"
}
@@ -5671,7 +5671,7 @@
"e7bb06007c": "LAN",
"4a73f5217a": "다른 로컬 앱을 제어하는 ​​스크립트를 위한 Apple 이벤트.",
"e119f0d66b": "자동화",
"7ca17b62c8": "Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.",
"7ca17b62c8": "Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.",
"c566bca278": "전체 디스크 접근 권한",
"9f35980756": "키 입력 주입, 창 제어 및 UI 자동화 도구입니다.",
"5b2f22ca2d": "접근성",
+3 -3
View File
@@ -802,8 +802,8 @@
"pasteTooLarge": "粘贴内容过大。"
},
"useMacosTccPromptNotice": {
"title": "减少重复出现的 macOS 文件访问权限提示",
"description": "macOS 会将代理终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”减少此类提示。",
"title": "看到“Orca 想要访问…”提示",
"description": "当 Orca 中运行的代理终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。",
"openSettings": "打开设置",
"dismiss": "不再显示"
}
@@ -5671,7 +5671,7 @@
"e7bb06007c": "局域网",
"4a73f5217a": "用于控制其他本地应用程序的脚本的 Apple 事件。",
"e119f0d66b": "自动化",
"7ca17b62c8": "当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。",
"7ca17b62c8": "当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出重新打开 Orca。",
"c566bca278": "完全磁盘访问权限",
"9f35980756": "击键注入、窗口控制和 UI 自动化工具。",
"5b2f22ca2d": "无障碍",
@@ -56,6 +56,8 @@ const SETTINGS_NAV_TARGET_SET: ReadonlySet<string> = new Set(SETTINGS_NAV_TARGET
const SETTINGS_NAV_INTENT_SET: ReadonlySet<string> = new Set(SETTINGS_NAV_INTENTS)
export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number]
export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access'
export type SettingsNavigationTarget = {
pane: SettingsNavTarget
repoId: string | null