fix: refresh accessibility onboarding state after macOS grant (#351) (#420)

* feat: display app version in settings panel

* fix(onboarding): auto-refresh accessibility state after grant (#351)

* style(onboarding): apply formatter for issue #351 fix

* fix(onboarding): ESLint + typed mock for ScreenPermissionsStep; clear flag in handler

Consolidate tauriCommands imports and drop redundant mock cast.

Handle granted accessibility in focus/visibility callback instead of a follow-up effect.

Made-with: Cursor
This commit is contained in:
Mega Mind
2026-04-08 03:37:07 +05:30
committed by GitHub
parent 9faef5582b
commit eded18a703
4 changed files with 191 additions and 2 deletions
+4
View File
@@ -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 = () => {
<Route path="memory-debug" element={<MemoryDebugPanel />} />
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
</Routes>
<div className="border-t border-stone-100 px-4 py-3 text-center text-[11px] text-stone-400">
Beta build - v{APP_VERSION}
</div>
</div>
</div>
);
@@ -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 (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="text-center mb-5">
@@ -67,7 +101,7 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte
<div className="space-y-2 mb-3">
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
onClick={handleRequestPermissions}
disabled={isRequestingPermissions || isLoading}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
@@ -81,6 +115,12 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte
</button>
{(lastError || status?.permission_check_process_path) && (
<div className="text-xs text-stone-400 text-center px-2 space-y-1">
{shouldAutoRefreshOnReturn ? (
<p>
After granting access in System Settings, return here and OpenHuman will refresh
automatically.
</p>
) : null}
{lastError ? <p className="text-coral-400">{lastError}</p> : null}
{status?.permission_check_process_path ? (
<p className="font-mono break-all text-stone-500">
@@ -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<typeof import('../../../../utils/tauriCommands')>();
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) => (
<Provider store={store}>
<MemoryRouter>{children}</MemoryRouter>
</Provider>
);
render(<ScreenPermissionsStep onNext={onNext} />, { 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();
});
});
});
+4
View File
@@ -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;