mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user