diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index e137c2761..ee4e8e0f6 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -27,6 +27,7 @@ import VoicePanel from '../components/settings/panels/VoicePanel'; import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel'; import SettingsHome from '../components/settings/SettingsHome'; import SettingsSectionPage from '../components/settings/SettingsSectionPage'; +import { APP_VERSION } from '../utils/config'; const accountSettingsItems = [ { @@ -331,6 +332,9 @@ const Settings = () => { } /> } /> +
+ Beta build - v{APP_VERSION} +
); diff --git a/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx b/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx index 5f1f6c8de..533ab2220 100644 --- a/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx +++ b/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { fetchAccessibilityStatus, @@ -17,6 +17,7 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte const dispatch = useAppDispatch(); const { status, isLoading, isRequestingPermissions, isRestartingCore, lastError } = useAppSelector(state => state.accessibility); + const [shouldAutoRefreshOnReturn, setShouldAutoRefreshOnReturn] = useState(false); useEffect(() => { void dispatch(fetchAccessibilityStatus()); @@ -25,6 +26,39 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte const accessibilityPermission = status?.permissions.accessibility ?? 'unknown'; const isGranted = accessibilityPermission === 'granted'; + useEffect(() => { + if (!shouldAutoRefreshOnReturn) { + return; + } + + const refreshAfterReturn = () => { + if (document.visibilityState !== 'visible' || isLoading || isRestartingCore) { + return; + } + + if (isGranted) { + setShouldAutoRefreshOnReturn(false); + return; + } + + setShouldAutoRefreshOnReturn(false); + void dispatch(refreshPermissionsWithRestart()); + }; + + window.addEventListener('focus', refreshAfterReturn); + document.addEventListener('visibilitychange', refreshAfterReturn); + + return () => { + window.removeEventListener('focus', refreshAfterReturn); + document.removeEventListener('visibilitychange', refreshAfterReturn); + }; + }, [dispatch, isGranted, isLoading, isRestartingCore, shouldAutoRefreshOnReturn]); + + const handleRequestPermissions = () => { + setShouldAutoRefreshOnReturn(true); + void dispatch(requestAccessibilityPermission('accessibility')); + }; + return (
@@ -67,7 +101,7 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte
{(lastError || status?.permission_check_process_path) && (
+ {shouldAutoRefreshOnReturn ? ( +

+ After granting access in System Settings, return here and OpenHuman will refresh + automatically. +

+ ) : null} {lastError ?

{lastError}

: null} {status?.permission_check_process_path ? (

diff --git a/app/src/pages/onboarding/steps/__tests__/ScreenPermissionsStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ScreenPermissionsStep.test.tsx new file mode 100644 index 000000000..a5258fb68 --- /dev/null +++ b/app/src/pages/onboarding/steps/__tests__/ScreenPermissionsStep.test.tsx @@ -0,0 +1,141 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import accessibilityReducer from '../../../../store/accessibilitySlice'; +import authReducer from '../../../../store/authSlice'; +import socketReducer from '../../../../store/socketSlice'; +import teamReducer from '../../../../store/teamSlice'; +import userReducer from '../../../../store/userSlice'; +import { + type AccessibilityStatus, + openhumanAccessibilityRequestPermission, + openhumanAccessibilityStatus, + restartCoreProcess, +} from '../../../../utils/tauriCommands'; +import ScreenPermissionsStep from '../ScreenPermissionsStep'; + +vi.mock('../../../../utils/tauriCommands', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + openhumanAccessibilityRequestPermission: vi.fn(), + openhumanAccessibilityStatus: vi.fn(), + restartCoreProcess: vi.fn(), + }; +}); + +const deniedStatus: AccessibilityStatus = { + platform_supported: true, + permissions: { + screen_recording: 'unknown', + accessibility: 'denied', + input_monitoring: 'unknown', + }, + features: { screen_monitoring: true, device_control: true, predictive_input: true }, + session: { + active: false, + started_at_ms: null, + expires_at_ms: null, + remaining_ms: null, + ttl_secs: 300, + panic_hotkey: 'Cmd+Shift+.', + stop_reason: null, + frames_in_memory: 0, + last_capture_at_ms: null, + last_context: null, + vision_enabled: true, + vision_state: 'idle', + vision_queue_depth: 0, + last_vision_at_ms: null, + last_vision_summary: null, + }, + config: { + enabled: true, + capture_policy: 'hybrid', + policy_mode: 'all_except_blacklist', + baseline_fps: 1, + vision_enabled: true, + session_ttl_secs: 300, + panic_stop_hotkey: 'Cmd+Shift+.', + autocomplete_enabled: true, + keep_screenshots: false, + allowlist: [], + denylist: [], + }, + denylist: [], + is_context_blocked: false, + permission_check_process_path: '/tmp/openhuman-core-x86_64-apple-darwin', +}; + +const grantedStatus: AccessibilityStatus = { + ...deniedStatus, + permissions: { ...deniedStatus.permissions, accessibility: 'granted' }, +}; + +const createStore = () => + configureStore({ + reducer: { + auth: authReducer, + socket: socketReducer, + user: userReducer, + team: teamReducer, + accessibility: accessibilityReducer, + }, + }); + +function renderStep() { + const store = createStore(); + const onNext = vi.fn(); + + const Wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + render(, { wrapper: Wrapper }); + + return { store, onNext }; +} + +describe('ScreenPermissionsStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(restartCoreProcess).mockResolvedValue(undefined); + vi.mocked(openhumanAccessibilityRequestPermission).mockResolvedValue({ + result: deniedStatus.permissions, + logs: [], + }); + vi.mocked(openhumanAccessibilityStatus).mockResolvedValue({ result: deniedStatus, logs: [] }); + }); + + it('auto-refreshes permissions after returning from System Settings', async () => { + vi.mocked(openhumanAccessibilityStatus) + .mockResolvedValueOnce({ result: deniedStatus, logs: [] }) + .mockResolvedValueOnce({ result: deniedStatus, logs: [] }) + .mockResolvedValueOnce({ result: grantedStatus, logs: [] }); + + renderStep(); + + await screen.findByText('Screen & Accessibility Permissions'); + + fireEvent.click(screen.getByRole('button', { name: 'Request Permissions' })); + + expect(await screen.findByText(/OpenHuman will refresh automatically/i)).toBeInTheDocument(); + + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + fireEvent(window, new Event('focus')); + + await waitFor(() => { + expect(restartCoreProcess).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByText('granted')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 497d1e69f..cbd54c6ab 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -1,3 +1,5 @@ +import packageJson from '../../package.json'; + export const CORE_RPC_URL = import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL || 'http://127.0.0.1:7788/rpc'; @@ -40,3 +42,5 @@ export const TELEGRAM_BOT_USERNAME = export const DEV_JWT_TOKEN = import.meta.env.DEV ? (import.meta.env.VITE_DEV_JWT_TOKEN as string | undefined) : undefined; + +export const APP_VERSION = packageJson.version;