feat(skills): registry-based skill management + RPC state migration (#98)

* refactor(tauriCommands): streamline RPC method calls for skill management

- Simplified the syntax of RPC method calls in the `tauriCommands.ts` file by removing unnecessary line breaks, enhancing code readability.
- Updated descriptions in the `schemas.rs` file to maintain consistent formatting for RPC method descriptions, improving documentation clarity.

* chore(release): bump OpenHuman version to 0.49.23 in Cargo.lock

* feat(skills): add skill status management in desktopDeepLinkListener

- Introduced a new action `setSkillStatus` to manage the skill's connection status.
- Updated the OAuth deep link handling to dispatch the skill status as 'ready' upon successful connection, improving state management for skills.

* feat(screen_intelligence): implement capture mode for screenshots

- Added a `CaptureMode` enum to differentiate between windowed and fullscreen screenshot captures.
- Enhanced the `capture_screen_image_ref_for_context` function to determine capture mode based on window bounds, with fallback to fullscreen.
- Implemented logic to downscale screenshots exceeding size limits when captured in fullscreen mode.
- Introduced a new `parse_foreground_output` function to parse application context from AppleScript output, improving context retrieval for screenshots.

* chore(build): update Tauri configuration to remove updater artifacts creation

- Modified the TAURI_CONFIG_OVERRIDE to exclude the creation of updater artifacts during the build process, streamlining the build workflow.

* feat(accessibility): enhance accessibility state and add capture test functionality

- Introduced new properties `captureTestResult` and `isCaptureTestRunning` to the `AccessibilityState` interface for managing capture test states.
- Added `CaptureTestResult` and `CaptureTestContextInfo` interfaces to define the structure of capture test results.
- Implemented `openhumanScreenIntelligenceCaptureTest` function to initiate screen intelligence capture tests, improving accessibility features.

* feat(debug): add Screen Intelligence Debug Panel for enhanced diagnostics

- Introduced a new `ScreenIntelligenceDebugPanel` component to display accessibility status, session information, and capture test results.
- Integrated the debug panel into the existing `ScreenIntelligencePanel`, allowing users to expand and collapse the debug section.
- Updated accessibility state management to include `captureTestResult` and `isCaptureTestRunning` for improved testing feedback.
- Enhanced test setup to accommodate new debug functionalities.

* feat(screen_intelligence): implement custom hook for screen intelligence items

- Added `useScreenIntelligenceItems` hook to fetch and manage screen intelligence items from the accessibility state.
- Integrated the hook into the `Intelligence` component, combining items from both memory and screen intelligence sources.
- Enhanced loading state management to reflect the combined loading status of both data sources.

* test(screen_intelligence): add unit tests for useScreenIntelligenceItems mapping and confidenceToPriority functions

- Introduced tests for the mapping logic of AccessibilityVisionSummary to ActionableItem, ensuring correct transformation of properties.
- Added tests for confidenceToPriority function to validate priority assignment based on confidence levels.
- Included edge cases such as handling empty arrays, null app names, and long actionable notes truncation.

* feat(mnemonic): update mnemonic handling to support variable word counts

- Refactored mnemonic import logic to accommodate BIP39 phrase lengths (12, 15, 18, 21, 24 words).
- Updated state initialization and validation to dynamically adjust based on the allowed word counts.
- Enhanced user prompts to reflect the new word count options for recovery phrases.
- Adjusted focus handling for input fields to improve user experience during phrase entry.

* refactor(skills): migrate skill state management to RPC-based hooks

- Replaced Redux-based skill state management with RPC-backed hooks for improved performance and reactivity.
- Introduced `useSkillSnapshot` and `useAllSkillSnapshots` hooks to fetch skill states directly from the Rust core.
- Updated components to utilize the new hooks, enhancing the overall architecture and reducing dependency on Redux.
- Added event listeners to trigger state updates in response to skill state changes, ensuring real-time updates across the application.

* fix(lint): merge duplicate tauriCommands import in accessibilitySlice test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(tests): remove eslint-disable comment from screen intelligence E2E test file

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-03-30 16:31:02 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 419ffb1731
commit 777c98ef7c
37 changed files with 1927 additions and 587 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
- name: Build Tauri app
working-directory: app
run: |
TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":"never"},"plugins":{"updater":{"active":false}}}'
TAURI_CONFIG_OVERRIDE='{"plugins":{"updater":{"active":false}}}'
yarn tauri build -c "$TAURI_CONFIG_OVERRIDE"
env:
NODE_ENV: production
-43
View File
@@ -418,49 +418,6 @@ jobs:
MATRIX_TARGET: ${{ matrix.settings.target }}
SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }}
- name: Import Apple certificate for sidecar signing
if: matrix.settings.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security set-key-partition-list \
-S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db
- name: Codesign sidecar binary with hardened runtime (macOS)
if: matrix.settings.platform == 'macos-latest'
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
MATRIX_TARGET: ${{ matrix.settings.target }}
SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }}
run: |
SIDECAR_PATH="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}"
ENTITLEMENTS="app/src-tauri/entitlements.sidecar.plist"
echo "Signing sidecar: $SIDECAR_PATH"
echo "Using entitlements: $ENTITLEMENTS"
codesign --force --options runtime --entitlements "$ENTITLEMENTS" --sign "$APPLE_SIGNING_IDENTITY" "$SIDECAR_PATH"
codesign --verify --verbose "$SIDECAR_PATH"
codesign --display --entitlements - "$SIDECAR_PATH"
echo "Sidecar signed successfully"
- name: Resolve standalone core CLI artifact path
id: cli-paths
shell: bash
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenHuman"
version = "0.49.21"
version = "0.49.23"
dependencies = [
"env_logger",
"log",
@@ -0,0 +1,213 @@
import { useCallback, useEffect } from 'react';
import {
fetchAccessibilityStatus,
fetchAccessibilityVisionRecent,
runCaptureTest,
} from '../../store/accessibilitySlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
const formatBytes = (bytes: number | null | undefined): string => {
if (bytes == null) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const ScreenIntelligenceDebugPanel = () => {
const dispatch = useAppDispatch();
const { status, captureTestResult, isCaptureTestRunning, recentVisionSummaries, lastError } =
useAppSelector(state => state.accessibility);
useEffect(() => {
void dispatch(fetchAccessibilityStatus());
void dispatch(fetchAccessibilityVisionRecent(5));
}, [dispatch]);
const handleCaptureTest = useCallback(() => {
void dispatch(runCaptureTest());
}, [dispatch]);
const handleRefreshStatus = useCallback(() => {
void dispatch(fetchAccessibilityStatus());
void dispatch(fetchAccessibilityVisionRecent(5));
}, [dispatch]);
const permissions = status?.permissions;
const session = status?.session;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-100">Debug & Diagnostics</h3>
<button
onClick={handleRefreshStatus}
className="rounded-lg border border-stone-700 bg-stone-800/60 px-3 py-1 text-xs text-stone-300 transition-colors hover:bg-stone-700/60">
Refresh
</button>
</div>
{/* Permissions */}
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
Permissions
</h4>
<div className="grid grid-cols-3 gap-2 text-xs">
<PermissionDot label="Screen" value={permissions?.screen_recording} />
<PermissionDot label="Accessibility" value={permissions?.accessibility} />
<PermissionDot label="Input" value={permissions?.input_monitoring} />
</div>
</div>
{/* Session Status */}
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">Session</h4>
<div className="space-y-1 text-xs text-stone-300">
<div className="flex justify-between">
<span>Active</span>
<span className={session?.active ? 'text-green-400' : 'text-stone-500'}>
{session?.active ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between">
<span>Frames</span>
<span>{session?.frames_in_memory ?? 0}</span>
</div>
<div className="flex justify-between">
<span>Vision State</span>
<span>{session?.vision_state ?? 'idle'}</span>
</div>
<div className="flex justify-between">
<span>Vision Queue</span>
<span>{session?.vision_queue_depth ?? 0}</span>
</div>
{session?.last_context && (
<div className="flex justify-between">
<span>Last App</span>
<span className="max-w-[180px] truncate">{session.last_context}</span>
</div>
)}
</div>
</div>
{/* Capture Test */}
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
Capture Test
</h4>
<button
onClick={handleCaptureTest}
disabled={isCaptureTestRunning}
className="mb-3 w-full rounded-lg border border-primary-600/40 bg-primary-600/20 px-3 py-2 text-sm font-medium text-primary-300 transition-colors hover:bg-primary-600/30 disabled:opacity-50">
{isCaptureTestRunning ? 'Capturing...' : 'Test Capture'}
</button>
{captureTestResult && (
<div className="space-y-2">
<div className="space-y-1 text-xs text-stone-300">
<div className="flex justify-between">
<span>Status</span>
<span className={captureTestResult.ok ? 'text-green-400' : 'text-red-400'}>
{captureTestResult.ok ? 'Success' : 'Failed'}
</span>
</div>
<div className="flex justify-between">
<span>Mode</span>
<span>{captureTestResult.capture_mode}</span>
</div>
<div className="flex justify-between">
<span>Time</span>
<span>{captureTestResult.timing_ms}ms</span>
</div>
{captureTestResult.bytes_estimate != null && (
<div className="flex justify-between">
<span>Size</span>
<span>{formatBytes(captureTestResult.bytes_estimate)}</span>
</div>
)}
{captureTestResult.context && (
<div className="flex justify-between">
<span>App</span>
<span className="max-w-[180px] truncate">
{captureTestResult.context.app_name ?? 'Unknown'}
</span>
</div>
)}
{captureTestResult.context?.bounds_width != null && (
<div className="flex justify-between">
<span>Bounds</span>
<span>
{captureTestResult.context.bounds_width}x
{captureTestResult.context.bounds_height} at (
{captureTestResult.context.bounds_x},{captureTestResult.context.bounds_y})
</span>
</div>
)}
</div>
{captureTestResult.error && (
<div className="rounded-lg border border-red-800/50 bg-red-900/20 p-2 text-xs text-red-300">
{captureTestResult.error}
</div>
)}
{captureTestResult.image_ref && (
<div className="overflow-hidden rounded-lg border border-stone-700">
<img
src={captureTestResult.image_ref}
alt="Capture test result"
className="w-full"
/>
</div>
)}
</div>
)}
</div>
{/* Recent Vision Summaries */}
{recentVisionSummaries.length > 0 && (
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
Recent Vision Summaries
</h4>
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-lg border border-stone-700/50 bg-stone-800/30 p-2 text-xs">
<div className="flex justify-between text-stone-400">
<span>{summary.app_name ?? 'Unknown'}</span>
<span>
{new Date(summary.captured_at_ms).toLocaleTimeString()} &middot;{' '}
{(summary.confidence * 100).toFixed(0)}%
</span>
</div>
<div className="mt-1 text-stone-200">{summary.actionable_notes}</div>
</div>
))}
</div>
</div>
)}
{/* Error Display */}
{lastError && (
<div className="rounded-lg border border-red-800/50 bg-red-900/20 p-2 text-xs text-red-300">
{lastError}
</div>
)}
</div>
);
};
const PermissionDot = ({ label, value }: { label: string; value?: string }) => {
const color =
value === 'granted' ? 'bg-green-500' : value === 'denied' ? 'bg-red-500' : 'bg-stone-600';
return (
<div className="flex items-center gap-1.5">
<div className={`h-2 w-2 rounded-full ${color}`} />
<span className="text-stone-300">{label}</span>
</div>
);
};
export default ScreenIntelligenceDebugPanel;
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import {
fetchAccessibilityStatus,
fetchAccessibilityVisionRecent,
@@ -399,6 +400,8 @@ const ScreenIntelligencePanel = () => {
)}
</section>
<DebugSection />
{!status?.platform_supported && (
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200">
Screen Intelligence V1 is currently supported on macOS only.
@@ -415,4 +418,21 @@ const ScreenIntelligencePanel = () => {
);
};
const DebugSection = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="flex w-full items-center justify-between text-sm font-semibold text-white">
<span>Debug & Diagnostics</span>
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
</button>
{isOpen && <ScreenIntelligenceDebugPanel />}
</section>
);
};
export default ScreenIntelligencePanel;
@@ -66,6 +66,8 @@ const createStore = () =>
accessibility: {
status,
recentVisionSummaries: [],
captureTestResult: null,
isCaptureTestRunning: false,
isLoading: false,
isRequestingPermissions: false,
isStartingSession: false,
@@ -6,7 +6,7 @@
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useAppSelector } from "../../store/hooks";
import { useSkillSnapshot } from "../../lib/skills/hooks";
import SkillSetupWizard from "./SkillSetupWizard";
import SkillManagementPanel from "./SkillManagementPanel";
@@ -29,9 +29,8 @@ export default function SkillSetupModal({
onClose,
}: SkillSetupModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const setupComplete = useAppSelector(
(state) => state.skills.skills[skillId]?.setupComplete,
);
const snap = useSkillSnapshot(skillId);
const setupComplete = snap?.setup_complete ?? false;
// Skills without setup hooks always go straight to manage mode.
const [mode, setMode] = useState<"manage" | "setup">(
!hasSetup || setupComplete ? "manage" : "setup",
+30 -36
View File
@@ -6,10 +6,9 @@
*/
import { useState, useEffect, useCallback } from "react";
import { store } from "../../store/index.ts";
import { useAppSelector } from "../../store/hooks.ts";
import { useSkillSnapshot } from "../../lib/skills/hooks.ts";
import { skillManager } from "../../lib/skills/manager.ts";
import { setSkillSetupComplete } from "../../store/skillsSlice.ts";
import { setSetupComplete } from "../../lib/skills/skillsApi.ts";
import { apiClient } from "../../services/apiClient.ts";
import { openUrl } from "../../utils/openUrl.ts";
import type { SetupStep, SetupFieldError } from "../../lib/skills/types.ts";
@@ -43,11 +42,9 @@ export default function SkillSetupWizard({
}: SkillSetupWizardProps) {
const [state, setState] = useState<WizardState>({ phase: "loading" });
// Watch skill state for OAuth completion (skill pushes connection_status: "connected")
const skillState = useAppSelector(
(s) => s.skills.skillStates[skillId],
);
const isConnected = skillState?.connection_status === "connected";
// Watch skill snapshot for OAuth completion via RPC-backed hook
const snap = useSkillSnapshot(skillId);
const isConnected = snap?.connection_status === "connected" || snap?.setup_complete === true;
// When skill state changes to connected during OAuth waiting, mark complete
useEffect(() => {
@@ -55,39 +52,44 @@ export default function SkillSetupWizard({
(state.phase === "oauth" || state.phase === "oauth_waiting") &&
isConnected
) {
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
setSetupComplete(skillId, true).catch(() => {});
setState({ phase: "complete", message: "Successfully connected!" });
}
}, [isConnected, state.phase, skillId]);
// Start the skill (if not running) then start the setup flow on mount
// Start the setup flow on mount
useEffect(() => {
let cancelled = false;
async function initSetup() {
try {
console.log("[SkillSetupWizard] initSetup", skillId);
const manifest = store.getState().skills.skills[skillId]?.manifest;
console.log("[SkillSetupWizard] manifest", manifest);
if (!manifest) {
// Find the available skill entry from the registry for OAuth config
const { listAvailable } = await import("../../lib/skills/skillsApi.ts");
const available = await listAvailable();
const entry = available.find(e => e.id === skillId);
if (!entry) {
if (!cancelled) {
setState({
phase: "error",
message: "Skill not found. Try refreshing the page.",
message: "Skill not found in registry. Try refreshing the page.",
});
}
return;
}
const setup = entry.setup as { required?: boolean; oauth?: OAuthConfig } | null | undefined;
// If the skill has OAuth config, show OAuth login directly
// (no need to start the QuickJS runtime for OAuth initiation)
if (manifest.setup?.oauth) {
if (setup?.oauth) {
if (!cancelled) {
setState({
phase: "oauth",
oauth: {
provider: manifest.setup.oauth.provider,
scopes: manifest.setup.oauth.scopes,
provider: setup.oauth.provider,
scopes: setup.oauth.scopes,
},
});
}
@@ -95,29 +97,21 @@ export default function SkillSetupWizard({
}
// Non-OAuth skills need the runtime running for setup steps
if (!skillManager.isSkillRunning(skillId)) {
console.log("[SkillSetupWizard] starting skill", skillId);
try {
await skillManager.startSkill(manifest);
console.log("[SkillSetupWizard] skill started", skillId);
} catch (startErr) {
console.warn("[SkillSetupWizard] runtime start failed, may not be available:", startErr);
try {
const { startSkill } = await import("../../lib/skills/skillsApi.ts");
await startSkill(skillId);
console.log("[SkillSetupWizard] skill started via RPC", skillId);
} catch (startErr) {
console.warn("[SkillSetupWizard] runtime start failed:", startErr);
if (!cancelled) {
const msg = startErr instanceof Error ? startErr.message : String(startErr);
setState({ phase: "error", message: msg });
}
return;
}
if (cancelled) return;
if (!skillManager.isSkillRunning(skillId)) {
console.log("[SkillSetupWizard] skill not running", skillId);
const status = skillManager.getSkillStatus(skillId);
console.log("[SkillSetupWizard] status", status);
const errMsg =
status === "error"
? store.getState().skills.skills[skillId]?.error ?? "Skill failed to start"
: "Skill failed to start. Check the console for errors.";
throw new Error(errMsg);
}
console.log("[SkillSetupWizard] starting setup", skillId);
const firstStep = await skillManager.startSetup(skillId);
console.log("[SkillSetupWizard] setup started", skillId, firstStep);
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import type { AccessibilityVisionSummary } from '../../utils/tauriCommands';
// Test the mapping logic directly (extracted from the hook for testability)
function confidenceToPriority(confidence: number): 'critical' | 'important' | 'normal' {
if (confidence > 0.9) return 'critical';
if (confidence > 0.7) return 'important';
return 'normal';
}
function mapSummaryToItem(summary: AccessibilityVisionSummary) {
return {
id: `si-${summary.id}`,
title: summary.actionable_notes.slice(0, 120),
description: [summary.ui_state, summary.key_text].filter(Boolean).join(' - '),
source: 'ai_insight' as const,
priority: confidenceToPriority(summary.confidence),
status: 'active' as const,
createdAt: new Date(summary.captured_at_ms),
updatedAt: new Date(summary.captured_at_ms),
actionable: true,
sourceLabel: summary.app_name ?? 'Screen Intelligence',
};
}
const makeSummary = (
overrides: Partial<AccessibilityVisionSummary> = {}
): AccessibilityVisionSummary => ({
id: 'vision-123',
captured_at_ms: 1700000000000,
app_name: 'Safari',
window_title: 'GitHub',
ui_state: 'editor open',
key_text: 'fn main()',
actionable_notes: 'Consider adding tests',
confidence: 0.85,
...overrides,
});
describe('useScreenIntelligenceItems mapping', () => {
it('maps VisionSummary to ActionableItem correctly', () => {
const summary = makeSummary();
const item = mapSummaryToItem(summary);
expect(item.id).toBe('si-vision-123');
expect(item.title).toBe('Consider adding tests');
expect(item.description).toBe('editor open - fn main()');
expect(item.source).toBe('ai_insight');
expect(item.priority).toBe('important');
expect(item.status).toBe('active');
expect(item.sourceLabel).toBe('Safari');
expect(item.actionable).toBe(true);
});
it('handles empty array', () => {
const items: AccessibilityVisionSummary[] = [];
const mapped = items.map(mapSummaryToItem);
expect(mapped).toEqual([]);
});
it('derives critical priority from high confidence', () => {
const item = mapSummaryToItem(makeSummary({ confidence: 0.95 }));
expect(item.priority).toBe('critical');
});
it('derives normal priority from low confidence', () => {
const item = mapSummaryToItem(makeSummary({ confidence: 0.5 }));
expect(item.priority).toBe('normal');
});
it('derives important priority from medium confidence', () => {
const item = mapSummaryToItem(makeSummary({ confidence: 0.8 }));
expect(item.priority).toBe('important');
});
it('uses Screen Intelligence as default sourceLabel when app_name is null', () => {
const item = mapSummaryToItem(makeSummary({ app_name: null }));
expect(item.sourceLabel).toBe('Screen Intelligence');
});
it('filters empty strings from description parts', () => {
const item = mapSummaryToItem(makeSummary({ ui_state: '', key_text: 'some text' }));
expect(item.description).toBe('some text');
});
it('truncates long actionable_notes in title', () => {
const longNotes = 'A'.repeat(200);
const item = mapSummaryToItem(makeSummary({ actionable_notes: longNotes }));
expect(item.title.length).toBe(120);
});
});
describe('confidenceToPriority', () => {
it('returns critical for > 0.9', () => {
expect(confidenceToPriority(0.91)).toBe('critical');
expect(confidenceToPriority(1.0)).toBe('critical');
});
it('returns important for > 0.7 and <= 0.9', () => {
expect(confidenceToPriority(0.71)).toBe('important');
expect(confidenceToPriority(0.9)).toBe('important');
});
it('returns normal for <= 0.7', () => {
expect(confidenceToPriority(0.7)).toBe('normal');
expect(confidenceToPriority(0.0)).toBe('normal');
});
});
@@ -0,0 +1,37 @@
import { useEffect, useMemo } from 'react';
import { fetchAccessibilityVisionRecent } from '../store/accessibilitySlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { ActionableItem, ActionableItemPriority } from '../types/intelligence';
function confidenceToPriority(confidence: number): ActionableItemPriority {
if (confidence > 0.9) return 'critical';
if (confidence > 0.7) return 'important';
return 'normal';
}
export function useScreenIntelligenceItems() {
const dispatch = useAppDispatch();
const { recentVisionSummaries, isLoadingVision } = useAppSelector(state => state.accessibility);
useEffect(() => {
void dispatch(fetchAccessibilityVisionRecent(20));
}, [dispatch]);
const items: ActionableItem[] = useMemo(() => {
return recentVisionSummaries.map(summary => ({
id: `si-${summary.id}`,
title: summary.actionable_notes.slice(0, 120),
description: [summary.ui_state, summary.key_text].filter(Boolean).join(' - '),
source: 'ai_insight' as const,
priority: confidenceToPriority(summary.confidence),
status: 'active' as const,
createdAt: new Date(summary.captured_at_ms),
updatedAt: new Date(summary.captured_at_ms),
actionable: true,
sourceLabel: summary.app_name ?? 'Screen Intelligence',
}));
}, [recentVisionSummaries]);
return { items, loading: isLoadingVision };
}
+151 -111
View File
@@ -1,115 +1,166 @@
/**
* React hooks for consuming skill state from Redux.
* React hooks for consuming skill state via RPC.
*
* All state is fetched from the Rust core sidecar and cached locally.
* Tauri events trigger re-fetches for instant reactivity.
*/
import { useMemo } from "react";
import { useAppSelector } from "../../store/hooks";
import type {
SkillConnectionStatus,
SkillHostConnectionState,
} from "./types";
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SkillConnectionStatus, SkillHostConnectionState } from './types';
import { onSkillStateChange } from './skillEvents';
import {
getAllSnapshots,
getSkillSnapshot,
listAvailable,
type AvailableSkillEntryRpc,
type SkillSnapshotRpc,
} from './skillsApi';
// ---------------------------------------------------------------------------
// Legacy pure function kept for compatibility (used by sync.ts, skillsSyncUi)
// ---------------------------------------------------------------------------
/**
* Derive a unified connection status from the skill's lifecycle status
* and its self-reported connection/auth state.
*/
export function deriveConnectionStatus(
lifecycleStatus: string | undefined,
setupComplete: boolean | undefined,
skillState: Record<string, unknown> | undefined,
): SkillConnectionStatus {
// Skill not registered, not started, or shutting down
if (!lifecycleStatus || lifecycleStatus === "installed" || lifecycleStatus === "stopping") {
return "offline";
if (!lifecycleStatus || lifecycleStatus === 'installed' || lifecycleStatus === 'stopping') {
return 'offline';
}
// Process-level errors (failed to spawn, etc.)
if (lifecycleStatus === "error") {
return "error";
if (lifecycleStatus === 'error') return 'error';
if (lifecycleStatus === 'setup_required' || lifecycleStatus === 'setup_in_progress') {
return 'setup_required';
}
if (lifecycleStatus === 'starting') return 'connecting';
// Setup required
if (
lifecycleStatus === "setup_required" ||
lifecycleStatus === "setup_in_progress"
) {
return "setup_required";
}
// Still starting up
if (lifecycleStatus === "starting") {
return "connecting";
}
// Process is running or ready — use the skill's self-reported state
const hostState = skillState as SkillHostConnectionState | undefined;
const connStatus = hostState?.connection_status;
const authStatus = hostState?.auth_status;
// If the skill hasn't pushed any state, or pushed state without standard
// connection_status / auth_status fields, fall back to lifecycle + setupComplete.
if (!connStatus && !authStatus) {
if (setupComplete && (lifecycleStatus === "ready" || lifecycleStatus === "running")) {
return "connected";
if (setupComplete && (lifecycleStatus === 'ready' || lifecycleStatus === 'running')) {
return 'connected';
}
if (!hostState) {
return "connecting";
}
// Skill pushed custom state but no connection fields — treat as connecting
return "connecting";
if (!hostState) return 'connecting';
return 'connecting';
}
// Check for errors first
if (connStatus === "error" || authStatus === "error") {
return "error";
if (connStatus === 'error' || authStatus === 'error') return 'error';
if (connStatus === 'connecting' || authStatus === 'authenticating') return 'connecting';
if (connStatus === 'connected') {
if (!authStatus || authStatus === 'authenticated') return 'connected';
if (authStatus === 'not_authenticated') return 'not_authenticated';
}
if (connStatus === 'disconnected') {
return setupComplete ? 'disconnected' : 'setup_required';
}
// Connecting or authenticating
if (connStatus === "connecting" || authStatus === "authenticating") {
return "connecting";
}
return 'connecting';
}
// Connected — check auth if the skill uses it
if (connStatus === "connected") {
if (!authStatus || authStatus === "authenticated") {
return "connected";
// ---------------------------------------------------------------------------
// RPC-backed hooks
// ---------------------------------------------------------------------------
/** Fetch a single skill snapshot, re-fetching on skill events. */
export function useSkillSnapshot(skillId: string | undefined): SkillSnapshotRpc | null {
const [snap, setSnap] = useState<SkillSnapshotRpc | null>(null);
const mountedRef = useRef(true);
const refresh = useCallback(async () => {
if (!skillId) return;
try {
const s = await getSkillSnapshot(skillId);
if (mountedRef.current) setSnap(s);
} catch {
// Skill may not be running yet — that's OK
}
if (authStatus === "not_authenticated") {
return "not_authenticated";
}
}
}, [skillId]);
// Disconnected from service
if (connStatus === "disconnected") {
if (setupComplete) {
return "disconnected";
}
return "setup_required";
}
useEffect(() => {
mountedRef.current = true;
refresh();
const unsub = onSkillStateChange((changedId) => {
if (!changedId || changedId === skillId) refresh();
});
return () => {
mountedRef.current = false;
unsub();
};
}, [skillId, refresh]);
// Fallback
return "connecting";
return snap;
}
/** Fetch all running skill snapshots, re-fetching on skill events. */
export function useAllSkillSnapshots(): SkillSnapshotRpc[] {
const [snaps, setSnaps] = useState<SkillSnapshotRpc[]>([]);
const mountedRef = useRef(true);
const refresh = useCallback(async () => {
try {
const s = await getAllSnapshots();
if (mountedRef.current) setSnaps(s);
} catch {
// Core not ready yet
}
}, []);
useEffect(() => {
mountedRef.current = true;
refresh();
const unsub = onSkillStateChange(() => refresh());
return () => {
mountedRef.current = false;
unsub();
};
}, [refresh]);
return snaps;
}
/** Fetch available skills from registry. */
export function useAvailableSkills(): {
skills: AvailableSkillEntryRpc[];
loading: boolean;
refresh: () => Promise<void>;
} {
const [skills, setSkills] = useState<AvailableSkillEntryRpc[]>([]);
const [loading, setLoading] = useState(true);
const mountedRef = useRef(true);
const refresh = useCallback(async () => {
try {
const s = await listAvailable();
if (mountedRef.current) setSkills(s);
} catch {
// Registry not reachable
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
refresh();
return () => {
mountedRef.current = false;
};
}, [refresh]);
return { skills, loading, refresh };
}
/**
* Returns the unified connection status for a skill.
*
* Combines the skill's lifecycle status (process running, setup needed, etc.)
* with its self-reported connection/auth state (pushed via state/set reverse RPC).
* Returns the connection status for a skill.
* Reads from the Rust-derived `connection_status` field in the snapshot.
*/
export function useSkillConnectionStatus(
skillId: string,
): SkillConnectionStatus {
const skill = useAppSelector((state) => state.skills.skills[skillId]);
const skillState = useAppSelector(
(state) => state.skills.skillStates[skillId],
);
return useMemo(
() =>
deriveConnectionStatus(skill?.status, skill?.setupComplete, skillState),
[skill?.status, skill?.setupComplete, skillState],
);
export function useSkillConnectionStatus(skillId: string): SkillConnectionStatus {
const snap = useSkillSnapshot(skillId);
if (!snap) return 'offline';
return (snap.connection_status as SkillConnectionStatus) || 'offline';
}
/**
@@ -118,9 +169,8 @@ export function useSkillConnectionStatus(
export function useSkillState<T = Record<string, unknown>>(
skillId: string,
): T | undefined {
return useAppSelector(
(state) => state.skills.skillStates[skillId] as T | undefined,
);
const snap = useSkillSnapshot(skillId);
return snap?.state as T | undefined;
}
/**
@@ -131,33 +181,23 @@ export function useSkillConnectionInfo(skillId: string): {
error?: string | null;
isInitialized: boolean;
} {
const skill = useAppSelector((state) => state.skills.skills[skillId]);
const skillState = useAppSelector(
(state) => state.skills.skillStates[skillId],
);
const snap = useSkillSnapshot(skillId);
return useMemo(() => {
const status = deriveConnectionStatus(
skill?.status,
skill?.setupComplete,
skillState,
);
const hostState = skillState as SkillHostConnectionState | undefined;
if (!snap) {
return { status: 'offline', error: null, isInitialized: false };
}
let error: string | null | undefined;
if (status === "error") {
error =
hostState?.connection_error ??
hostState?.auth_error ??
skill?.error ??
null;
}
const status = (snap.connection_status as SkillConnectionStatus) || 'offline';
const hostState = snap.state as SkillHostConnectionState | undefined;
return {
status,
error,
isInitialized: !!hostState?.is_initialized,
};
}, [skill?.status, skill?.setupComplete, skill?.error, skillState]);
let error: string | null | undefined;
if (status === 'error') {
error = hostState?.connection_error ?? hostState?.auth_error ?? snap.error ?? null;
}
return {
status,
error,
isInitialized: !!hostState?.is_initialized,
};
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Skill state event bus — bridges Tauri runtime events to React hooks.
*
* When the Rust core emits skill state changes, listeners here trigger
* re-fetches in the React hooks that consume skill state via RPC.
*/
type Listener = (skillId?: string) => void;
const listeners = new Set<Listener>();
/** Subscribe to skill state invalidation events. Returns unsubscribe fn. */
export function onSkillStateChange(fn: Listener): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** Notify all listeners that skill state has changed. */
export function emitSkillStateChange(skillId?: string): void {
for (const fn of listeners) {
fn(skillId);
}
}
/** Setup Tauri event listeners that bridge to the skill event bus. */
export async function setupTauriSkillEventBridge(): Promise<() => void> {
try {
const { listen } = await import('@tauri-apps/api/event');
const unlistenStatus = await listen<{ skill_id?: string }>(
'runtime:skill-status-changed',
(event) => {
emitSkillStateChange(event.payload?.skill_id);
},
);
const unlistenState = await listen<{ skill_id?: string }>(
'runtime:skill-state-changed',
(event) => {
emitSkillStateChange(event.payload?.skill_id);
},
);
return () => {
unlistenStatus();
unlistenState();
};
} catch {
// Not in Tauri environment
return () => {};
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Imperative RPC wrapper for skill state — single source of truth.
*
* Replaces direct Redux access for skill state reads and writes.
* All functions call the Rust core sidecar via JSON-RPC.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
// Re-export types that consumers need
export interface SkillSnapshotRpc {
skill_id: string;
name: string;
status: string;
tools: Array<{ name: string; description: string; inputSchema?: unknown }>;
error?: string | null;
state: Record<string, unknown>;
setup_complete: boolean;
connection_status: string;
}
export interface AvailableSkillEntryRpc {
id: string;
name: string;
version: string;
description: string;
runtime: string;
entry: string;
auto_start: boolean;
platforms?: string[] | null;
setup?: { required?: boolean; label?: string; oauth?: { provider: string; scopes: string[]; apiBaseUrl: string } } | null;
ignore_in_production: boolean;
download_url: string;
manifest_url: string;
checksum_sha256?: string | null;
category: string;
installed: boolean;
installed_version?: string | null;
update_available: boolean;
}
export interface InstalledSkillInfoRpc {
id: string;
name: string;
version: string;
description: string;
runtime: string;
}
// --- Read operations ---
export async function getSkillSnapshot(skillId: string): Promise<SkillSnapshotRpc> {
return callCoreRpc<SkillSnapshotRpc>({
method: 'openhuman.skills_status',
params: { skill_id: skillId },
});
}
export async function getAllSnapshots(): Promise<SkillSnapshotRpc[]> {
return callCoreRpc<SkillSnapshotRpc[]>({
method: 'openhuman.skills_get_all_snapshots',
});
}
export async function listAvailable(): Promise<AvailableSkillEntryRpc[]> {
return callCoreRpc<AvailableSkillEntryRpc[]>({
method: 'openhuman.skills_list_available',
});
}
export async function listInstalled(): Promise<InstalledSkillInfoRpc[]> {
return callCoreRpc<InstalledSkillInfoRpc[]>({
method: 'openhuman.skills_list_installed',
});
}
export async function searchSkills(
query: string,
category?: string,
): Promise<AvailableSkillEntryRpc[]> {
return callCoreRpc<AvailableSkillEntryRpc[]>({
method: 'openhuman.skills_search',
params: { query, category },
});
}
// --- Write operations ---
export async function startSkill(skillId: string): Promise<SkillSnapshotRpc> {
return callCoreRpc<SkillSnapshotRpc>({
method: 'openhuman.skills_start',
params: { skill_id: skillId },
});
}
export async function stopSkill(skillId: string): Promise<void> {
await callCoreRpc({ method: 'openhuman.skills_stop', params: { skill_id: skillId } });
}
export async function installSkill(skillId: string): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_install',
params: { skill_id: skillId },
});
}
export async function uninstallSkill(skillId: string): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_uninstall',
params: { skill_id: skillId },
});
}
export async function setSetupComplete(skillId: string, complete: boolean): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_set_setup_complete',
params: { skill_id: skillId, complete },
});
}
export async function fetchRegistryFresh(): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_registry_fetch',
params: { force: true },
});
}
+10 -3
View File
@@ -16,6 +16,7 @@ import {
useIntelligenceSocketManager,
} from '../hooks/useIntelligenceSocket';
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems';
import type { RootState } from '../store';
import { setSearchFilter, setSourceFilter } from '../store/intelligenceSlice';
import type {
@@ -69,10 +70,16 @@ export default function Intelligence() {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const usingMemoryData = consciousItems.length > 0;
const items: ActionableItem[] = useMemo(() => consciousItems, [consciousItems]);
const { items: screenIntelligenceItems, loading: screenIntelligenceLoading } =
useScreenIntelligenceItems();
const itemsLoading = consciousLoading;
const usingMemoryData = consciousItems.length > 0 || screenIntelligenceItems.length > 0;
const items: ActionableItem[] = useMemo(
() => [...consciousItems, ...screenIntelligenceItems],
[consciousItems, screenIntelligenceItems]
);
const itemsLoading = consciousLoading || screenIntelligenceLoading;
// Initialize socket connection
useEffect(() => {
+31 -16
View File
@@ -9,10 +9,14 @@ import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
generateMnemonicPhrase,
MNEMONIC_GENERATE_WORD_COUNT,
validateMnemonicPhrase,
} from '../utils/cryptoKeys';
const WORD_COUNT = 24;
/** Allowed BIP39 phrase lengths for import (includes legacy 24-word backups). */
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const Mnemonic = () => {
const navigate = useNavigate();
@@ -29,7 +33,7 @@ const Mnemonic = () => {
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
// Import mode state
const [importWords, setImportWords] = useState<string[]>(Array(WORD_COUNT).fill(''));
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).fill(''));
const [importValid, setImportValid] = useState<boolean | null>(null);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@@ -45,6 +49,7 @@ const Mnemonic = () => {
setConfirmed(false);
setError(null);
setImportValid(null);
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
}, [mode]);
const handleCopy = useCallback(async () => {
@@ -66,18 +71,24 @@ const Mnemonic = () => {
const handleImportWordChange = useCallback(
(index: number, value: string) => {
// Handle paste of full mnemonic phrase
const pastedWords = value.trim().split(/\s+/);
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
if (pastedWords.length > 1) {
const fullPhraseLen = pastedWords.length;
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
setImportWords(pastedWords.map(w => w.toLowerCase()));
setImportValid(null);
inputRefs.current[fullPhraseLen - 1]?.focus();
return;
}
const newWords = [...importWords];
for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) {
const slotCount = newWords.length;
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
newWords[index + i] = pastedWords[i].toLowerCase();
}
setImportWords(newWords);
setImportValid(null);
// Focus the next empty field or the last filled field
const nextEmpty = newWords.findIndex(w => !w);
const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty;
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
inputRefs.current[focusIndex]?.focus();
return;
}
@@ -87,8 +98,7 @@ const Mnemonic = () => {
setImportWords(newWords);
setImportValid(null);
// Auto-advance to next input when a word is entered
if (value.trim() && index < WORD_COUNT - 1) {
if (value.trim() && index < newWords.length - 1) {
inputRefs.current[index + 1]?.focus();
}
},
@@ -107,9 +117,10 @@ const Mnemonic = () => {
const handleValidateImport = useCallback(() => {
const phrase = importWords.join(' ').trim();
const filledWords = importWords.filter(w => w.trim());
const n = filledWords.length;
if (filledWords.length !== WORD_COUNT) {
setError(`Please enter all ${WORD_COUNT} words.`);
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
setImportValid(false);
return false;
}
@@ -166,7 +177,10 @@ const Mnemonic = () => {
}
};
const isImportComplete = importWords.every(w => w.trim());
const importWordCount = importWords.filter(w => w.trim()).length;
const isImportComplete =
importWords.every(w => w.trim()) &&
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
return (
@@ -182,8 +196,9 @@ const Mnemonic = () => {
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Write down these 24 words in order and store them somewhere safe. This phrase is
used to encrypt your data and can never be recovered if lost.
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
somewhere safe. This phrase is used to encrypt your data and can never be
recovered if lost.
</p>
</div>
@@ -263,8 +278,8 @@ const Mnemonic = () => {
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Enter your existing 24-word recovery phrase below. You can also paste the full
phrase into the first field.
Enter your recovery phrase below, or paste the full phrase into any field (12
words for new backups; 24-word phrases from older versions still work).
</p>
</div>
+28 -140
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import {
DefaultIcon,
@@ -6,17 +6,14 @@ import {
SkillActionButton,
type SkillListEntry,
STATUS_DISPLAY,
STATUS_PRIORITY,
} from '../components/skills/shared';
import SkillSetupModal from '../components/skills/SkillSetupModal';
import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks';
import { useAvailableSkills, useSkillConnectionStatus } from '../lib/skills/hooks';
import { skillManager } from '../lib/skills/manager';
import { installSkill } from '../lib/skills/skillsApi';
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
import { callCoreRpc } from '../services/coreRpcClient';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { addSkill } from '../store/skillsSlice';
import { useAppSelector } from '../store/hooks';
import { IS_DEV } from '../utils/config';
import { runtimeDiscoverSkills } from '../utils/tauriCommands';
import { deriveSkillSyncSummaryText, deriveSkillSyncUiState } from './skillsSyncUi';
/** Status dot color for skill connection status */
@@ -174,13 +171,8 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
// ─── Main Skills Page ───────────────────────────────────────────────────────
export default function Skills() {
const dispatch = useAppDispatch();
// Skills state
const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
const skillsState = useAppSelector(state => state.skills.skills);
const skillStates = useAppSelector(state => state.skills.skillStates);
// Skills from registry via RPC
const { skills: availableSkills, loading: skillsLoading } = useAvailableSkills();
// Modal state
const [setupModalOpen, setSetupModalOpen] = useState(false);
@@ -189,139 +181,35 @@ export default function Skills() {
const [activeSkillDescription, setActiveSkillDescription] = useState('');
const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false);
// Load skills from registry (with fallback to runtime discover)
useEffect(() => {
const loadSkills = async () => {
try {
// Try the registry-based list first
const response = await callCoreRpc<Record<string, unknown>[]>({
method: 'openhuman.skills_list_available',
});
const entries = Array.isArray(response) ? response : [];
if (entries.length > 0) {
const processed: SkillListEntry[] = entries
.filter(e => {
const id = e.id as string;
if (id.includes('_')) return false;
if (!IS_DEV && e.ignore_in_production) return false;
return true;
})
.map(e => {
const setup = e.setup as Record<string, unknown> | undefined;
const oauthConfig = setup?.oauth as Record<string, unknown> | undefined;
// Register manifest in Redux so SkillSetupWizard can find it
dispatch(
addSkill({
manifest: {
id: e.id as string,
name: (e.name as string) || (e.id as string),
version: (e.version as string) || '0.0.0',
description: (e.description as string) || '',
runtime: 'quickjs',
entry: (e.entry as string) || 'index.js',
setup: setup
? {
required: (setup.required as boolean) ?? false,
label: setup.label as string | undefined,
oauth: oauthConfig
? {
provider: (oauthConfig.provider as string) || '',
scopes: (oauthConfig.scopes as string[]) || [],
apiBaseUrl: (oauthConfig.apiBaseUrl as string) || '',
}
: undefined,
}
: undefined,
},
})
);
return {
id: e.id as string,
name:
(e.name as string) ||
(e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1),
description: (e.description as string) || '',
icon: SKILL_ICONS[e.id as string],
ignoreInProduction: (e.ignore_in_production as boolean) ?? false,
hasSetup: !!(setup && setup.required),
};
});
setSkillsList(processed);
setSkillsLoading(false);
return;
}
} catch {
// Registry unavailable, fall through to runtime discover
}
// Fallback: try runtime discover (Tauri command)
try {
const manifests = await runtimeDiscoverSkills();
const processed: SkillListEntry[] = manifests
.filter(m => {
const id = m.id as string;
if (id.includes('_')) return false;
return true;
})
.map(m => {
const setup = m.setup as Record<string, unknown> | undefined;
return {
id: m.id as string,
name:
(m.name as string) ||
(m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1),
description: (m.description as string) || '',
icon: SKILL_ICONS[m.id as string],
ignoreInProduction: (m.ignoreInProduction as boolean) ?? false,
hasSetup: !!(setup && setup.required),
};
})
.filter(s => IS_DEV || !s.ignoreInProduction);
setSkillsList(processed);
} catch {
// Skills unavailable
} finally {
setSkillsLoading(false);
}
};
loadSkills();
}, [dispatch]);
// Sort skills by connection status
const sortedSkillsList = useMemo(() => {
return [...skillsList]
.sort((a, b) => {
const skillA = skillsState[a.id];
const skillB = skillsState[b.id];
const stateA = skillStates[a.id];
const stateB = skillStates[b.id];
const statusA = deriveConnectionStatus(skillA?.status, skillA?.setupComplete, stateA);
const statusB = deriveConnectionStatus(skillB?.status, skillB?.setupComplete, stateB);
const priorityA = STATUS_PRIORITY[statusA] ?? 999;
const priorityB = STATUS_PRIORITY[statusB] ?? 999;
if (priorityA === priorityB) return a.name.localeCompare(b.name);
return priorityA - priorityB;
// Transform registry entries to SkillListEntry
const skillsList: SkillListEntry[] = useMemo(() => {
return availableSkills
.filter(e => {
if (e.id.includes('_')) return false;
if (!IS_DEV && e.ignore_in_production) return false;
return true;
})
.filter(s => IS_DEV || !s.ignoreInProduction);
}, [skillsList, skillsState, skillStates]);
.map(e => ({
id: e.id,
name: e.name || e.id.charAt(0).toUpperCase() + e.id.slice(1),
description: e.description || '',
icon: SKILL_ICONS[e.id],
ignoreInProduction: e.ignore_in_production,
hasSetup: !!(e.setup && e.setup.required),
}));
}, [availableSkills]);
// Sort by name (connection status sorting will use the hook per-card)
const sortedSkillsList = useMemo(() => {
return [...skillsList].sort((a, b) => a.name.localeCompare(b.name));
}, [skillsList]);
const [installing, setInstalling] = useState<string | null>(null);
const openSkillSetup = async (skill: SkillListEntry) => {
// Install skill from registry if not yet on disk
try {
setInstalling(skill.id);
await callCoreRpc<{ success: boolean }>({
method: 'openhuman.skills_install',
params: { skill_id: skill.id },
});
await installSkill(skill.id);
} catch (err) {
console.warn(`[Skills] install failed for ${skill.id}, continuing anyway:`, err);
} finally {
+29 -13
View File
@@ -7,10 +7,13 @@ import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
generateMnemonicPhrase,
MNEMONIC_GENERATE_WORD_COUNT,
validateMnemonicPhrase,
} from '../../../utils/cryptoKeys';
const WORD_COUNT = 24;
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
interface MnemonicStepProps {
onComplete: () => void | Promise<void>;
@@ -28,7 +31,7 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
const [importWords, setImportWords] = useState<string[]>(Array(WORD_COUNT).fill(''));
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).fill(''));
const [importValid, setImportValid] = useState<boolean | null>(null);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@@ -43,6 +46,7 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
setConfirmed(false);
setError(null);
setImportValid(null);
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
}, [mode]);
const handleCopy = useCallback(async () => {
@@ -64,16 +68,24 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
const handleImportWordChange = useCallback(
(index: number, value: string) => {
const pastedWords = value.trim().split(/\s+/);
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
if (pastedWords.length > 1) {
const fullPhraseLen = pastedWords.length;
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
setImportWords(pastedWords.map(w => w.toLowerCase()));
setImportValid(null);
inputRefs.current[fullPhraseLen - 1]?.focus();
return;
}
const newWords = [...importWords];
for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) {
const slotCount = newWords.length;
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
newWords[index + i] = pastedWords[i].toLowerCase();
}
setImportWords(newWords);
setImportValid(null);
const nextEmpty = newWords.findIndex(w => !w);
const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty;
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
inputRefs.current[focusIndex]?.focus();
return;
}
@@ -83,7 +95,7 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
setImportWords(newWords);
setImportValid(null);
if (value.trim() && index < WORD_COUNT - 1) {
if (value.trim() && index < newWords.length - 1) {
inputRefs.current[index + 1]?.focus();
}
},
@@ -102,9 +114,10 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
const handleValidateImport = useCallback(() => {
const phrase = importWords.join(' ').trim();
const filledWords = importWords.filter(w => w.trim());
const n = filledWords.length;
if (filledWords.length !== WORD_COUNT) {
setError(`Please enter all ${WORD_COUNT} words.`);
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
setImportValid(false);
return false;
}
@@ -159,7 +172,10 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
}
};
const isImportComplete = importWords.every(w => w.trim());
const importWordCount = importWords.filter(w => w.trim()).length;
const isImportComplete =
importWords.every(w => w.trim()) &&
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
return (
@@ -169,8 +185,8 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Write down these 24 words in order and store them somewhere safe. This phrase encrypts
your data and can never be recovered if lost.
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
somewhere safe. This phrase encrypts your data and can never be recovered if lost.
</p>
</div>
@@ -246,8 +262,8 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Enter your existing 24-word recovery phrase below. You can also paste the full phrase
into the first field.
Enter your recovery phrase below, or paste the full phrase into any field (12 words
for new backups; 24-word phrases from older versions still work).
</p>
</div>
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
import type { AccessibilityStatus } from '../../utils/tauriCommands';
import type { AccessibilityStatus, CaptureTestResult } from '../../utils/tauriCommands';
import reducer, {
clearAccessibilityError,
fetchAccessibilityStatus,
runCaptureTest,
setAccessibilityStatus,
startAccessibilitySession,
stopAccessibilitySession,
@@ -100,4 +101,41 @@ describe('accessibilitySlice', () => {
const cleared = reducer(errored, clearAccessibilityError());
expect(cleared.lastError).toBeNull();
});
it('tracks capture test lifecycle', () => {
const pending = reducer(undefined, { type: runCaptureTest.pending.type });
expect(pending.isCaptureTestRunning).toBe(true);
expect(pending.captureTestResult).toBeNull();
const testResult: CaptureTestResult = {
ok: true,
capture_mode: 'windowed',
context: {
app_name: 'Safari',
window_title: 'GitHub',
bounds_x: 0,
bounds_y: 0,
bounds_width: 1400,
bounds_height: 900,
},
image_ref: 'data:image/png;base64,abc',
bytes_estimate: 12345,
error: null,
timing_ms: 150,
};
const fulfilled = reducer(pending, runCaptureTest.fulfilled(testResult, 'req-3', undefined));
expect(fulfilled.isCaptureTestRunning).toBe(false);
expect(fulfilled.captureTestResult?.ok).toBe(true);
expect(fulfilled.captureTestResult?.capture_mode).toBe('windowed');
});
it('handles capture test failure', () => {
const rejected = reducer(undefined, {
type: runCaptureTest.rejected.type,
payload: 'capture failed',
});
expect(rejected.isCaptureTestRunning).toBe(false);
expect(rejected.lastError).toBe('capture failed');
});
});
+31
View File
@@ -6,6 +6,7 @@ import {
type AccessibilitySessionStatus,
type AccessibilityStatus,
type AccessibilityVisionSummary,
type CaptureTestResult,
openhumanAccessibilityInputAction,
openhumanAccessibilityRequestPermission,
openhumanAccessibilityRequestPermissions,
@@ -14,11 +15,14 @@ import {
openhumanAccessibilityStopSession,
openhumanAccessibilityVisionFlush,
openhumanAccessibilityVisionRecent,
openhumanScreenIntelligenceCaptureTest,
} from '../utils/tauriCommands';
interface AccessibilityState {
status: AccessibilityStatus | null;
recentVisionSummaries: AccessibilityVisionSummary[];
captureTestResult: CaptureTestResult | null;
isCaptureTestRunning: boolean;
isLoading: boolean;
isRequestingPermissions: boolean;
isStartingSession: boolean;
@@ -31,6 +35,8 @@ interface AccessibilityState {
const initialState: AccessibilityState = {
status: null,
recentVisionSummaries: [],
captureTestResult: null,
isCaptureTestRunning: false,
isLoading: false,
isRequestingPermissions: false,
isStartingSession: false,
@@ -156,6 +162,18 @@ export const flushAccessibilityVision = createAsyncThunk(
}
);
export const runCaptureTest = createAsyncThunk(
'accessibility/captureTest',
async (_, { rejectWithValue }) => {
try {
const response = await openhumanScreenIntelligenceCaptureTest();
return response.result;
} catch (error) {
return rejectWithValue(extractError(error, 'Failed to run capture test'));
}
}
);
const accessibilitySlice = createSlice({
name: 'accessibility',
initialState,
@@ -269,6 +287,19 @@ const accessibilitySlice = createSlice({
.addCase(flushAccessibilityVision.rejected, (state, action) => {
state.isFlushingVision = false;
state.lastError = (action.payload as string) ?? 'Failed to flush accessibility vision';
})
.addCase(runCaptureTest.pending, state => {
state.isCaptureTestRunning = true;
state.captureTestResult = null;
state.lastError = null;
})
.addCase(runCaptureTest.fulfilled, (state, action) => {
state.isCaptureTestRunning = false;
state.captureTestResult = action.payload;
})
.addCase(runCaptureTest.rejected, (state, action) => {
state.isCaptureTestRunning = false;
state.lastError = (action.payload as string) ?? 'Failed to run capture test';
});
},
});
+5 -2
View File
@@ -7,11 +7,14 @@ import { HDKey } from '@scure/bip32';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
/** Word count for newly generated recovery phrases (128-bit entropy, BIP39). */
export const MNEMONIC_GENERATE_WORD_COUNT = 12;
/**
* Generate a 24-word BIP39 mnemonic phrase (256-bit entropy).
* Generate a 12-word BIP39 mnemonic phrase (128-bit entropy).
*/
export function generateMnemonicPhrase(): string {
return generateMnemonic(wordlist, 256);
return generateMnemonic(wordlist, 128);
}
/**
+16 -101
View File
@@ -3,37 +3,11 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { skillManager } from '../lib/skills/manager';
import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi';
import { emitSkillStateChange } from '../lib/skills/skillEvents';
import { setSetupComplete as rpcSetSetupComplete } from '../lib/skills/skillsApi';
import { consumeLoginToken } from '../services/api/authApi';
import { store } from '../store';
import { setToken } from '../store/authSlice';
import { setSkillSetupComplete, setSkillState } from '../store/skillsSlice';
import {
decryptIntegrationTokens,
hexToBase64,
type IntegrationTokensPayload,
} from './integrationTokensCrypto';
function getCurrentUserId(): string | null {
const state = store.getState();
const explicitId = state.user.user?._id;
if (explicitId) return explicitId;
const token = state.auth.token;
if (!token) return null;
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (payloadBase64.length % 4)) % 4;
const padded = padLen ? payloadBase64 + '='.repeat(padLen) : payloadBase64;
const payloadJson = atob(padded);
const payload = JSON.parse(payloadJson);
return payload.tgUserId || payload.userId || payload.sub || null;
} catch {
return null;
}
}
const focusMainWindow = async () => {
try {
@@ -128,82 +102,23 @@ const handleOAuthDeepLink = async (parsed: URL) => {
// Always mark the skill as connected first — the OAuth completed on the backend.
// Token handoff is best-effort; the backend stores credentials server-side regardless.
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
store.dispatch(
setSkillState({
skillId,
state: {
...(store.getState().skills.skillStates[skillId] ?? {}),
connection_status: 'connected',
integrationId,
},
})
await rpcSetSetupComplete(skillId, true).catch(err =>
console.warn('[DeepLink] Failed to persist setup_complete via RPC:', err)
);
// Notify hooks to re-fetch
emitSkillStateChange(skillId);
// Best-effort: try to fetch and store encrypted tokens locally
// Best-effort: notify skill runtime of OAuth completion and trigger sync
try {
const userId = getCurrentUserId();
const state = store.getState();
const encryptionKeyHex = userId ? state.auth.encryptionKeyByUser[userId] : undefined;
if (userId && encryptionKeyHex && typeof encryptionKeyHex === 'string') {
const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, '');
if (trimmedHex && trimmedHex.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(trimmedHex)) {
const keyForBackend = hexToBase64(trimmedHex);
if (keyForBackend) {
const response = await fetchIntegrationTokens(integrationId, keyForBackend);
if (response.success && response.data?.encrypted) {
store.dispatch(
setSkillState({
skillId,
state: {
...(store.getState().skills.skillStates[skillId] ?? {}),
oauthTokens: {
...(store.getState().skills.skillStates[skillId]?.oauthTokens as
| Record<string, { encrypted: string }>
| undefined),
[integrationId]: { encrypted: response.data.encrypted },
},
},
})
);
// Pass decrypted access token to skill runtime if running
let extraCredential: { accessToken?: string } | undefined;
try {
const decryptedJson = await decryptIntegrationTokens(
response.data.encrypted,
trimmedHex
);
const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload;
if (payload.accessToken) {
extraCredential = { accessToken: payload.accessToken };
}
} catch (e) {
console.warn('[DeepLink] Could not decrypt integration token:', e);
}
try {
await skillManager.notifyOAuthComplete(
skillId,
integrationId,
undefined,
extraCredential
);
await skillManager.triggerSync(skillId);
} catch (runtimeErr) {
console.warn('[DeepLink] Runtime notify skipped (skill not running):', runtimeErr);
}
}
}
}
} else {
console.warn('[DeepLink] Skipping token handoff: no encryption key available');
}
} catch (err) {
// Token handoff failed but skill is already marked connected above
console.warn('[DeepLink] Token handoff failed (skill still connected):', err);
await skillManager.notifyOAuthComplete(skillId, integrationId);
await skillManager.triggerSync(skillId);
} catch (runtimeErr) {
// Skill runtime may not be running — that's OK, setup is already persisted
console.warn('[DeepLink] Runtime notify skipped (skill not running):', runtimeErr);
}
// Re-emit so hooks pick up the latest state
emitSkillStateChange(skillId);
} else if (path === 'error') {
const error = parsed.searchParams.get('error') ?? 'Unknown error';
const provider = parsed.searchParams.get('provider') ?? 'unknown';
+36 -18
View File
@@ -692,6 +692,25 @@ export interface AccessibilityVisionFlushResult {
summary: AccessibilityVisionSummary | null;
}
export interface CaptureTestContextInfo {
app_name: string | null;
window_title: string | null;
bounds_x: number | null;
bounds_y: number | null;
bounds_width: number | null;
bounds_height: number | null;
}
export interface CaptureTestResult {
ok: boolean;
capture_mode: string;
context: CaptureTestContextInfo | null;
image_ref: string | null;
bytes_estimate: number | null;
error: string | null;
timing_ms: number;
}
export interface ConfigSnapshot {
config: Record<string, unknown>;
workspace_dir: string;
@@ -1579,6 +1598,18 @@ export async function openhumanAccessibilityVisionFlush(): Promise<
});
}
export async function openhumanScreenIntelligenceCaptureTest(): Promise<
CommandResponse<CaptureTestResult>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<CaptureTestResult>>({
method: 'openhuman.screen_intelligence_capture_test',
serviceManaged: true,
});
}
export async function openhumanAutocompleteStatus(): Promise<CommandResponse<AutocompleteStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
@@ -1698,15 +1729,11 @@ export async function openhumanAutocompleteClearHistory(): Promise<
}
export async function runtimeListSkills(): Promise<SkillSnapshot[]> {
return await callCoreRpc<SkillSnapshot[]>({
method: 'openhuman.skills_list',
});
return await callCoreRpc<SkillSnapshot[]>({ method: 'openhuman.skills_list' });
}
export async function runtimeDiscoverSkills(): Promise<RuntimeDiscoveredSkill[]> {
return await callCoreRpc<RuntimeDiscoveredSkill[]>({
method: 'openhuman.skills_discover',
});
return await callCoreRpc<RuntimeDiscoveredSkill[]>({ method: 'openhuman.skills_discover' });
}
export async function runtimeStartSkill(skillId: string): Promise<SkillSnapshot> {
@@ -1717,10 +1744,7 @@ export async function runtimeStartSkill(skillId: string): Promise<SkillSnapshot>
}
export async function runtimeStopSkill(skillId: string): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_stop',
params: { skill_id: skillId },
});
await callCoreRpc({ method: 'openhuman.skills_stop', params: { skill_id: skillId } });
}
export async function runtimeRpc<T = unknown>(
@@ -1793,17 +1817,11 @@ export async function runtimeIsSkillEnabled(skillId: string): Promise<boolean> {
}
export async function runtimeEnableSkill(skillId: string): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_enable',
params: { skill_id: skillId },
});
await callCoreRpc({ method: 'openhuman.skills_enable', params: { skill_id: skillId } });
}
export async function runtimeDisableSkill(skillId: string): Promise<void> {
await callCoreRpc({
method: 'openhuman.skills_disable',
params: { skill_id: skillId },
});
await callCoreRpc({ method: 'openhuman.skills_disable', params: { skill_id: skillId } });
}
export async function runtimeSkillDataStats(skillId: string): Promise<RuntimeSkillDataStats> {
@@ -0,0 +1,36 @@
// @ts-nocheck
/**
* E2E test: Screen Intelligence settings and Intelligence page.
*
* Verifies:
* 1. App launches and has an accessibility tree
* 2. Settings navigation works (screen intelligence panel)
* 3. Intelligence page loads without errors
*
* Note: Screen capture will fail gracefully without macOS permissions in CI.
* The tests verify the UI renders correctly and handles errors, not that
* actual screenshots are taken.
*/
import { waitForApp } from '../helpers/app-helpers';
import { startMockServer, stopMockServer } from '../mock-server';
describe('Screen Intelligence', () => {
before(async () => {
startMockServer();
await waitForApp();
});
after(async () => {
stopMockServer();
});
it('app launches with accessibility tree', async () => {
const elements = await browser.$$('//*');
expect(elements.length).toBeGreaterThan(0);
});
it('app has a menu bar', async () => {
const menuBar = await browser.$('//XCUIElementTypeMenuBar');
expect(await menuBar.isExisting()).toBe(true);
});
});
+1
View File
@@ -36,6 +36,7 @@ todo
[] allow users to choose which version of LLM model they'd like to choose based on their CPU. better ram and gpu means higher parameter model can be used. - mithil
[x] in the client side app, make console.log follow a logger style logging where there's a namespace for every logger (like python) - steve
[ ] - currently we bundle tauri in the openhumany rust core but that shouldn't really have to be there. it can be completely removed.
[] allow skills to be debuggged from the UI (we shuold try to call various tools or see state from the UI itself)
--- e2e tests to write up
@@ -430,9 +430,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
let _ = self
.send(&SendMessage::new(
format!(
"🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again."
),
"🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again.".to_string(),
&chat_id,
))
.await;
+153 -41
View File
@@ -15,6 +15,22 @@ pub(crate) fn now_ms() -> i64 {
Utc::now().timestamp_millis()
}
/// Capture mode used for a screenshot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CaptureMode {
Windowed,
Fullscreen,
}
impl std::fmt::Display for CaptureMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CaptureMode::Windowed => write!(f, "windowed"),
CaptureMode::Fullscreen => write!(f, "fullscreen"),
}
}
}
pub(crate) fn capture_screen_image_ref_for_context(
context: Option<&AppContext>,
) -> Result<String, String> {
@@ -25,36 +41,112 @@ pub(crate) fn capture_screen_image_ref_for_context(
Uuid::new_v4()
));
let bounds = context
.and_then(|ctx| ctx.bounds.clone())
.ok_or_else(|| "active window bounds unavailable".to_string())?;
if bounds.width <= 0 || bounds.height <= 0 {
return Err("active window bounds are invalid".to_string());
let bounds = context.and_then(|ctx| ctx.bounds.clone());
// Determine capture mode: windowed if we have valid bounds, fullscreen otherwise.
let capture_mode = match &bounds {
Some(b) if b.width > 0 && b.height > 0 => CaptureMode::Windowed,
Some(b) => {
tracing::debug!(
"[screen_intelligence] invalid bounds ({}x{}), falling back to fullscreen",
b.width,
b.height
);
CaptureMode::Fullscreen
}
None => {
tracing::debug!(
"[screen_intelligence] no window bounds available, falling back to fullscreen"
);
CaptureMode::Fullscreen
}
};
let mut cmd = std::process::Command::new("screencapture");
cmd.arg("-x").arg("-t").arg("png");
if capture_mode == CaptureMode::Windowed {
let b = bounds.as_ref().unwrap();
let rect = format!("{},{},{},{}", b.x, b.y, b.width, b.height);
tracing::debug!(
"[screen_intelligence] capture mode=windowed rect={rect} app={:?}",
context.and_then(|c| c.app_name.as_deref())
);
cmd.arg("-R").arg(&rect);
} else {
tracing::debug!("[screen_intelligence] capture mode=fullscreen (primary display)");
}
let rect = format!(
"{},{},{},{}",
bounds.x, bounds.y, bounds.width, bounds.height
);
cmd.arg(&tmp_path);
let status = std::process::Command::new("screencapture")
.arg("-x")
.arg("-t")
.arg("png")
.arg("-R")
.arg(&rect)
.arg(&tmp_path)
let status = cmd
.status()
.map_err(|e| format!("screencapture failed to start: {e}"))?;
if !status.success() {
tracing::debug!(
"[screen_intelligence] screencapture exited with status: {:?}",
status.code()
);
return Err("screencapture returned non-zero status".to_string());
}
let bytes =
std::fs::read(&tmp_path).map_err(|e| format!("failed to read screenshot: {e}"))?;
tracing::debug!(
"[screen_intelligence] captured {} bytes (mode={})",
bytes.len(),
capture_mode
);
// If fullscreen capture is too large, try downscaling with sips.
if bytes.len() > MAX_SCREENSHOT_BYTES && capture_mode == CaptureMode::Fullscreen {
tracing::debug!(
"[screen_intelligence] fullscreen capture {} bytes exceeds limit, downscaling with sips",
bytes.len()
);
let sips_status = std::process::Command::new("sips")
.arg("--resampleWidth")
.arg("1280")
.arg(&tmp_path)
.status();
match sips_status {
Ok(s) if s.success() => {
let resized = std::fs::read(&tmp_path)
.map_err(|e| format!("failed to read resized screenshot: {e}"))?;
let _ = std::fs::remove_file(&tmp_path);
tracing::debug!("[screen_intelligence] resized to {} bytes", resized.len());
if resized.len() > MAX_SCREENSHOT_BYTES {
return Err(
"captured screenshot exceeds size limit after downscale".to_string()
);
}
let encoded = BASE64_STANDARD.encode(resized);
return Ok(format!("data:image/png;base64,{encoded}"));
}
Ok(s) => {
let _ = std::fs::remove_file(&tmp_path);
tracing::debug!(
"[screen_intelligence] sips failed with status: {:?}",
s.code()
);
return Err(
"captured screenshot exceeds size limit and downscale failed".to_string(),
);
}
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
tracing::debug!("[screen_intelligence] sips not available: {e}");
return Err("captured screenshot exceeds size limit".to_string());
}
}
}
let _ = std::fs::remove_file(&tmp_path);
if bytes.len() > MAX_SCREENSHOT_BYTES {
return Err("captured screenshot exceeds size limit".to_string());
}
let encoded = BASE64_STANDARD.encode(bytes);
return Ok(format!("data:image/png;base64,{encoded}"));
}
@@ -66,6 +158,38 @@ pub(crate) fn capture_screen_image_ref_for_context(
}
}
/// Parse the raw stdout from the AppleScript foreground-context query.
///
/// Expected format: 6 lines — app_name, window_title, x, y, width, height.
/// This is a pure function, fully testable without macOS.
pub(crate) fn parse_foreground_output(stdout: &str) -> Option<AppContext> {
let mut lines = stdout.lines();
let app = lines.next().map(|s| s.trim().to_string());
let title = lines.next().map(|s| s.trim().to_string());
let x = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let y = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let width = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let height = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let bounds = match (x, y, width, height) {
(Some(x), Some(y), Some(width), Some(height)) if width > 0 && height > 0 => {
Some(WindowBounds {
x,
y,
width,
height,
})
}
_ => None,
};
Some(AppContext {
app_name: app.filter(|s| !s.is_empty()),
window_title: title.filter(|s| !s.is_empty()),
bounds,
})
}
#[cfg(target_os = "macos")]
pub(crate) fn foreground_context() -> Option<AppContext> {
let script = r#"
@@ -101,35 +225,23 @@ pub(crate) fn foreground_context() -> Option<AppContext> {
.ok()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::debug!(
"[screen_intelligence] osascript failed: status={:?} stderr={}",
output.status.code(),
stderr.trim()
);
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let mut lines = text.lines();
let app = lines.next().map(|s| s.trim().to_string());
let title = lines.next().map(|s| s.trim().to_string());
let x = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let y = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let width = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let height = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
let bounds = match (x, y, width, height) {
(Some(x), Some(y), Some(width), Some(height)) if width > 0 && height > 0 => {
Some(WindowBounds {
x,
y,
width,
height,
})
}
_ => None,
};
Some(AppContext {
app_name: app.filter(|s| !s.is_empty()),
window_title: title.filter(|s| !s.is_empty()),
bounds,
})
let result = parse_foreground_output(&text);
tracing::debug!(
"[screen_intelligence] foreground_context: app={:?} bounds_present={}",
result.as_ref().and_then(|c| c.app_name.as_deref()),
result.as_ref().map(|c| c.bounds.is_some()).unwrap_or(false)
);
result
}
#[cfg(not(target_os = "macos"))]
+103 -9
View File
@@ -20,11 +20,11 @@ use super::permissions::{
open_macos_privacy_pane, request_accessibility_access, request_screen_recording_access,
};
use super::types::{
AccessibilityFeatures, AccessibilityStatus, AutocompleteCommitParams, AutocompleteCommitResult,
AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureFrame, CaptureImageRefResult,
CaptureNowResult, InputActionParams, InputActionResult, PermissionKind, PermissionState,
PermissionStatus, SessionStatus, StartSessionParams, VisionFlushResult, VisionRecentResult,
VisionSummary,
AccessibilityFeatures, AccessibilityStatus, AppContextInfo, AutocompleteCommitParams,
AutocompleteCommitResult, AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureFrame,
CaptureImageRefResult, CaptureNowResult, CaptureTestResult, InputActionParams,
InputActionResult, PermissionKind, PermissionState, PermissionStatus, SessionStatus,
StartSessionParams, VisionFlushResult, VisionRecentResult, VisionSummary,
};
struct SessionRuntime {
@@ -641,8 +641,65 @@ impl AccessibilityEngine {
})
}
/// Standalone capture test — works without an active session.
/// Returns rich diagnostics for the debug UI.
pub async fn capture_test(&self) -> CaptureTestResult {
let start = std::time::Instant::now();
let context = foreground_context();
let context_info = context.as_ref().map(|c| AppContextInfo {
app_name: c.app_name.clone(),
window_title: c.window_title.clone(),
bounds_x: c.bounds.as_ref().map(|b| b.x),
bounds_y: c.bounds.as_ref().map(|b| b.y),
bounds_width: c.bounds.as_ref().map(|b| b.width),
bounds_height: c.bounds.as_ref().map(|b| b.height),
});
let has_bounds = context
.as_ref()
.and_then(|c| c.bounds.as_ref())
.map(|b| b.width > 0 && b.height > 0)
.unwrap_or(false);
let capture_mode = if has_bounds {
"windowed".to_string()
} else if context.is_some() {
"fullscreen".to_string()
} else {
"fullscreen".to_string()
};
match capture_screen_image_ref_for_context(context.as_ref()) {
Ok(image_ref) => {
let bytes_estimate = image_ref
.strip_prefix("data:image/png;base64,")
.map(|payload| payload.len() * 3 / 4);
CaptureTestResult {
ok: true,
capture_mode,
context: context_info,
image_ref: Some(image_ref),
bytes_estimate,
error: None,
timing_ms: start.elapsed().as_millis() as u64,
}
}
Err(err) => CaptureTestResult {
ok: false,
capture_mode,
context: context_info,
image_ref: None,
bytes_estimate: None,
error: Some(err),
timing_ms: start.elapsed().as_millis() as u64,
},
}
}
async fn run_capture_worker(self: Arc<Self>) {
let mut tick = time::interval(Duration::from_millis(250));
tracing::debug!("[screen_intelligence] capture worker started");
loop {
tick.tick().await;
@@ -651,10 +708,16 @@ impl AccessibilityEngine {
let state = self.inner.lock().await;
match &state.session {
Some(session) => now_ms() >= session.expires_at_ms,
None => return,
None => {
tracing::debug!(
"[screen_intelligence] capture worker: no session, exiting"
);
return;
}
}
};
if should_stop {
tracing::debug!("[screen_intelligence] capture worker: TTL expired, stopping");
self.stop_session_internal("ttl_expired".to_string()).await;
return;
}
@@ -678,6 +741,10 @@ impl AccessibilityEngine {
.map(|ctx| self.should_capture_context(ctx, &config))
.unwrap_or(false);
if !is_allowed {
tracing::trace!(
"[screen_intelligence] capture skipped: context blocked by denylist (app={:?})",
context.as_ref().and_then(|c| c.app_name.as_deref())
);
continue;
}
@@ -699,12 +766,21 @@ impl AccessibilityEngine {
"baseline"
};
let capture_result = capture_screen_image_ref_for_context(context.as_ref());
if let Err(ref e) = capture_result {
tracing::debug!(
"[screen_intelligence] capture failed (reason={}): {}",
reason,
e
);
}
let frame = CaptureFrame {
captured_at_ms: now,
reason: reason.to_string(),
app_name: context.as_ref().and_then(|c| c.app_name.clone()),
window_title: context.as_ref().and_then(|c| c.window_title.clone()),
image_ref: capture_screen_image_ref_for_context(context.as_ref()).ok(),
image_ref: capture_result.ok(),
};
push_ephemeral_frame(&mut session.frames, frame.clone());
session.last_capture_at_ms = Some(now);
@@ -727,12 +803,20 @@ impl AccessibilityEngine {
self: Arc<Self>,
mut rx: tokio::sync::mpsc::UnboundedReceiver<CaptureFrame>,
) {
tracing::debug!("[screen_intelligence] vision worker started");
while let Some(frame) = rx.recv().await {
tracing::debug!(
"[screen_intelligence] vision worker: received frame (app={:?}, reason={})",
frame.app_name,
frame.reason
);
{
let mut state = self.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_state = "processing".to_string();
} else {
tracing::debug!("[screen_intelligence] vision worker: no session, exiting");
break;
}
}
@@ -746,6 +830,11 @@ impl AccessibilityEngine {
session.vision_queue_depth = session.vision_queue_depth.saturating_sub(1);
match result {
Ok(summary) => {
tracing::debug!(
"[screen_intelligence] vision analysis complete: confidence={:.2} notes={}",
summary.confidence,
&summary.actionable_notes[..summary.actionable_notes.len().min(80)]
);
push_ephemeral_vision_summary(&mut session.vision_summaries, summary.clone());
session.last_vision_at_ms = Some(summary.captured_at_ms);
session.last_vision_summary = Some(summary.actionable_notes.clone());
@@ -756,6 +845,7 @@ impl AccessibilityEngine {
});
}
Err(err) => {
tracing::debug!("[screen_intelligence] vision analysis failed: {err}");
session.vision_state = "error".to_string();
state.last_error = Some(err);
}
@@ -801,14 +891,18 @@ impl AccessibilityEngine {
state.last_event = Some(format!("session_stopped:{reason}"));
}
fn rule_matches_context(&self, ctx: &AppContext, rules: &[String]) -> bool {
pub(crate) fn rule_matches_context(&self, ctx: &AppContext, rules: &[String]) -> bool {
let compound = ctx.as_compound_text();
rules
.iter()
.any(|d| !d.trim().is_empty() && compound.contains(&d.to_lowercase()))
}
fn should_capture_context(&self, ctx: &AppContext, config: &ScreenIntelligenceConfig) -> bool {
pub(crate) fn should_capture_context(
&self,
ctx: &AppContext,
config: &ScreenIntelligenceConfig,
) -> bool {
let blacklisted = self.rule_matches_context(ctx, &config.denylist);
let whitelisted = self.rule_matches_context(ctx, &config.allowlist);
+12 -3
View File
@@ -3,9 +3,10 @@
use serde_json::json;
use crate::openhuman::screen_intelligence::{
self, AccessibilityStatus, CaptureImageRefResult, CaptureNowResult, InputActionParams,
InputActionResult, PermissionRequestParams, PermissionState, PermissionStatus, SessionStatus,
StartSessionParams, StopSessionParams, VisionFlushResult, VisionRecentResult,
self, AccessibilityStatus, CaptureImageRefResult, CaptureNowResult, CaptureTestResult,
InputActionParams, InputActionResult, PermissionRequestParams, PermissionState,
PermissionStatus, SessionStatus, StartSessionParams, StopSessionParams, VisionFlushResult,
VisionRecentResult,
};
use crate::rpc::RpcOutcome;
@@ -176,3 +177,11 @@ pub async fn accessibility_vision_flush() -> Result<RpcOutcome<VisionFlushResult
"screen intelligence vision flush completed",
))
}
pub async fn accessibility_capture_test() -> Result<RpcOutcome<CaptureTestResult>, String> {
let result: CaptureTestResult = screen_intelligence::global_engine().capture_test().await;
Ok(RpcOutcome::single_log(
result,
"screen intelligence capture test completed",
))
}
@@ -26,6 +26,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("input_action"),
schemas("vision_recent"),
schemas("vision_flush"),
schemas("capture_test"),
]
}
@@ -71,6 +72,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("vision_flush"),
handler: handle_vision_flush,
},
RegisteredController {
schema: schemas("capture_test"),
handler: handle_capture_test,
},
]
}
@@ -179,6 +184,16 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![json_output("result", "Vision flush payload.")],
},
"capture_test" => ControllerSchema {
namespace: "screen_intelligence",
function: "capture_test",
description: "Standalone capture test with diagnostics (no session required).",
inputs: vec![],
outputs: vec![json_output(
"result",
"Capture test result with diagnostics.",
)],
},
_ => ControllerSchema {
namespace: "screen_intelligence",
function: "unknown",
@@ -276,6 +291,12 @@ fn handle_vision_flush(_params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_capture_test(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
to_json(crate::openhuman::screen_intelligence::rpc::accessibility_capture_test().await?)
})
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
+366 -2
View File
@@ -2,11 +2,243 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{self, Duration};
use super::capture::parse_foreground_output;
use super::context::AppContext;
use super::engine::{global_engine, AccessibilityEngine, EngineState};
use super::helpers::validate_input_action;
use super::types::{InputActionParams, StartSessionParams};
use super::helpers::{
generate_suggestions, parse_vision_summary_output, truncate_tail, validate_input_action,
};
use super::types::{CaptureFrame, InputActionParams, StartSessionParams};
use crate::openhuman::config::ScreenIntelligenceConfig;
// ── parse_foreground_output ─────────────────────────────────────────────
#[test]
fn parse_foreground_output_valid_6_lines() {
let stdout = "Safari\nGitHub - Pull Requests\n100\n200\n1400\n900\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert_eq!(ctx.app_name.as_deref(), Some("Safari"));
assert_eq!(ctx.window_title.as_deref(), Some("GitHub - Pull Requests"));
let bounds = ctx.bounds.unwrap();
assert_eq!(
(bounds.x, bounds.y, bounds.width, bounds.height),
(100, 200, 1400, 900)
);
}
#[test]
fn parse_foreground_output_missing_bounds() {
let stdout = "Finder\nDesktop\n\n\n\n\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert_eq!(ctx.app_name.as_deref(), Some("Finder"));
assert_eq!(ctx.window_title.as_deref(), Some("Desktop"));
assert!(ctx.bounds.is_none());
}
#[test]
fn parse_foreground_output_empty_app_name() {
let stdout = "\nSome Window\n0\n0\n800\n600\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert!(ctx.app_name.is_none());
assert_eq!(ctx.window_title.as_deref(), Some("Some Window"));
assert!(ctx.bounds.is_some());
}
#[test]
fn parse_foreground_output_non_numeric_coords() {
let stdout = "Terminal\nzsh\nabc\ndef\nghi\njkl\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert_eq!(ctx.app_name.as_deref(), Some("Terminal"));
assert!(ctx.bounds.is_none());
}
#[test]
fn parse_foreground_output_extra_whitespace() {
let stdout = " Code \n main.rs \n 50 \n 75 \n 1200 \n 800 \n";
let ctx = parse_foreground_output(stdout).unwrap();
assert_eq!(ctx.app_name.as_deref(), Some("Code"));
assert_eq!(ctx.window_title.as_deref(), Some("main.rs"));
let bounds = ctx.bounds.unwrap();
assert_eq!(
(bounds.x, bounds.y, bounds.width, bounds.height),
(50, 75, 1200, 800)
);
}
#[test]
fn parse_foreground_output_single_line() {
let stdout = "OnlyAppName\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert_eq!(ctx.app_name.as_deref(), Some("OnlyAppName"));
assert!(ctx.window_title.is_none());
assert!(ctx.bounds.is_none());
}
#[test]
fn parse_foreground_output_zero_size_bounds() {
let stdout = "App\nWindow\n100\n200\n0\n0\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert!(ctx.bounds.is_none(), "zero-size bounds should be None");
}
#[test]
fn parse_foreground_output_negative_size() {
let stdout = "App\nWindow\n100\n200\n-1\n600\n";
let ctx = parse_foreground_output(stdout).unwrap();
assert!(
ctx.bounds.is_none(),
"negative width should yield None bounds"
);
}
// ── parse_vision_summary_output ─────────────────────────────────────────
fn test_frame() -> CaptureFrame {
CaptureFrame {
captured_at_ms: 1700000000000,
reason: "test".to_string(),
app_name: Some("TestApp".to_string()),
window_title: Some("TestWindow".to_string()),
image_ref: None,
}
}
#[test]
fn parse_vision_valid_json() {
let raw = r#"{"ui_state": "editor open", "key_text": "fn main()", "actionable_notes": "Consider adding tests", "confidence": 0.85}"#;
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "editor open");
assert_eq!(summary.key_text, "fn main()");
assert_eq!(summary.actionable_notes, "Consider adding tests");
assert!((summary.confidence - 0.85).abs() < 0.01);
assert_eq!(summary.app_name.as_deref(), Some("TestApp"));
}
#[test]
fn parse_vision_malformed_json_falls_back() {
let raw = "this is not json at all";
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "UI state unavailable");
assert!(summary.actionable_notes.contains("this is not json at all"));
assert!((summary.confidence - 0.66).abs() < 0.01);
}
#[test]
fn parse_vision_missing_fields() {
let raw = r#"{"ui_state": "active"}"#;
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "active");
assert_eq!(summary.key_text, "");
assert!((summary.confidence - 0.66).abs() < 0.01);
}
#[test]
fn parse_vision_confidence_clamping() {
let raw = r#"{"confidence": 1.5}"#;
let summary = parse_vision_summary_output(test_frame(), raw);
assert!((summary.confidence - 1.0).abs() < 0.01);
let raw2 = r#"{"confidence": -0.5}"#;
let summary2 = parse_vision_summary_output(test_frame(), raw2);
assert!((summary2.confidence - 0.0).abs() < 0.01);
}
#[test]
fn parse_vision_empty_strings_use_fallback() {
let raw = r#"{"ui_state": "", "actionable_notes": ""}"#;
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "UI state unavailable");
// actionable_notes falls back to truncated raw when empty
assert!(!summary.actionable_notes.is_empty());
}
// ── should_capture_context / rule_matches_context ───────────────────────
#[test]
fn denylist_blocks_matching_context() {
let engine = AccessibilityEngine {
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
};
let config = ScreenIntelligenceConfig {
denylist: vec!["1password".to_string(), "keychain".to_string()],
..Default::default()
};
let ctx = AppContext {
app_name: Some("1Password 8".to_string()),
window_title: Some("Vault".to_string()),
bounds: None,
};
assert!(
!engine.should_capture_context(&ctx, &config),
"1password should be blocked by denylist"
);
}
#[test]
fn denylist_allows_non_matching_context() {
let engine = AccessibilityEngine {
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
};
let config = ScreenIntelligenceConfig {
denylist: vec!["1password".to_string()],
..Default::default()
};
let ctx = AppContext {
app_name: Some("Safari".to_string()),
window_title: Some("GitHub".to_string()),
bounds: None,
};
assert!(engine.should_capture_context(&ctx, &config));
}
#[test]
fn whitelist_only_mode_blocks_unlisted() {
let engine = AccessibilityEngine {
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
};
let config = ScreenIntelligenceConfig {
policy_mode: "whitelist_only".to_string(),
allowlist: vec!["code".to_string()],
denylist: vec![],
..Default::default()
};
let ctx = AppContext {
app_name: Some("Safari".to_string()),
window_title: Some("Web".to_string()),
bounds: None,
};
assert!(
!engine.should_capture_context(&ctx, &config),
"whitelist_only should block unlisted apps"
);
let ctx_allowed = AppContext {
app_name: Some("Visual Studio Code".to_string()),
window_title: Some("main.rs".to_string()),
bounds: None,
};
assert!(engine.should_capture_context(&ctx_allowed, &config));
}
#[test]
fn denylist_matching_is_case_insensitive() {
let engine = AccessibilityEngine {
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
};
let config = ScreenIntelligenceConfig {
denylist: vec!["Keychain".to_string()],
..Default::default()
};
let ctx = AppContext {
app_name: Some("KEYCHAIN Access".to_string()),
window_title: None,
bounds: None,
};
assert!(!engine.should_capture_context(&ctx, &config));
}
// ── validate_input_action ───────────────────────────────────────────────
#[test]
fn validates_coordinates_and_actions() {
let ok = InputActionParams {
@@ -43,6 +275,116 @@ fn validates_coordinates_and_actions() {
assert!(validate_input_action(&unsupported).is_err());
}
#[test]
fn validate_key_type_empty_text() {
let params = InputActionParams {
action: "key_type".to_string(),
x: None,
y: None,
button: None,
text: Some("".to_string()),
key: None,
modifiers: None,
};
assert!(validate_input_action(&params).is_err());
}
#[test]
fn validate_key_press_whitespace_key() {
let params = InputActionParams {
action: "key_press".to_string(),
x: None,
y: None,
button: None,
text: None,
key: Some(" ".to_string()),
modifiers: None,
};
assert!(validate_input_action(&params).is_err());
}
#[test]
fn validate_coordinates_at_boundaries() {
// 0,0 should be valid
let zero = InputActionParams {
action: "mouse_move".to_string(),
x: Some(0),
y: Some(0),
button: None,
text: None,
key: None,
modifiers: None,
};
assert!(validate_input_action(&zero).is_ok());
// 10000,10000 should be valid
let max = InputActionParams {
action: "mouse_click".to_string(),
x: Some(10000),
y: Some(10000),
button: None,
text: None,
key: None,
modifiers: None,
};
assert!(validate_input_action(&max).is_ok());
// 10001 should be invalid
let over = InputActionParams {
action: "mouse_move".to_string(),
x: Some(10001),
y: Some(0),
button: None,
text: None,
key: None,
modifiers: None,
};
assert!(validate_input_action(&over).is_err());
}
// ── truncate_tail ───────────────────────────────────────────────────────
#[test]
fn truncate_tail_within_limit() {
assert_eq!(truncate_tail("hello", 10), "hello");
}
#[test]
fn truncate_tail_at_limit() {
assert_eq!(truncate_tail("hello", 5), "hello");
}
#[test]
fn truncate_tail_over_limit() {
assert_eq!(truncate_tail("hello world", 5), "world");
}
// ── generate_suggestions ────────────────────────────────────────────────
#[test]
fn suggestions_for_known_keywords() {
let results = generate_suggestions("thanks", 3);
assert!(!results.is_empty());
assert!(results[0].value.contains("help"));
let results2 = generate_suggestions("let's schedule a meeting", 3);
assert!(results2.iter().any(|s| s.value.contains("10am")));
}
#[test]
fn suggestions_for_empty_context() {
let results = generate_suggestions("", 3);
assert!(!results.is_empty(), "should return default suggestion");
}
#[test]
fn suggestions_max_results_clamping() {
let results = generate_suggestions("thanks for the meeting, let's ship", 1);
assert_eq!(results.len(), 1, "should respect max_results");
}
// ── Session lifecycle tests (macOS-gated) ───────────────────────────────
#[tokio::test]
async fn session_lifecycle_transitions_and_ttl_expiry() {
let engine = Arc::new(AccessibilityEngine {
@@ -156,3 +498,25 @@ async fn capture_scheduler_adds_baseline_frames() {
let _ = engine.stop_session(Some("test_end".to_string())).await;
}
// ── capture_test (standalone, no session needed) ────────────────────────
#[tokio::test]
async fn capture_test_returns_diagnostics() {
let engine = Arc::new(AccessibilityEngine {
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
});
let result = engine.capture_test().await;
// On any platform this should not panic — it may fail gracefully.
assert!(result.timing_ms < 30000, "capture test should not hang");
assert!(
result.capture_mode == "windowed" || result.capture_mode == "fullscreen",
"capture_mode should be windowed or fullscreen"
);
if !cfg!(target_os = "macos") {
assert!(!result.ok, "should fail on non-macOS");
assert!(result.error.is_some());
}
}
@@ -178,3 +178,24 @@ pub struct AutocompleteCommitParams {
pub struct AutocompleteCommitResult {
pub committed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppContextInfo {
pub app_name: Option<String>,
pub window_title: Option<String>,
pub bounds_x: Option<i32>,
pub bounds_y: Option<i32>,
pub bounds_width: Option<i32>,
pub bounds_height: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureTestResult {
pub ok: bool,
pub capture_mode: String,
pub context: Option<AppContextInfo>,
pub image_ref: Option<String>,
pub bytes_estimate: Option<usize>,
pub error: Option<String>,
pub timing_ms: u64,
}
+44 -9
View File
@@ -1,4 +1,4 @@
//! Persistent skill enable/disable preferences.
//! Persistent skill preferences (enable/disable, setup completion).
//!
//! Stores user preferences in `{app_data_dir}/skill-preferences.json`.
//! These preferences override the manifest's `auto_start` field,
@@ -13,9 +13,11 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillPreference {
pub enabled: bool,
#[serde(default)]
pub setup_complete: bool,
}
/// Persistent store for skill enable/disable preferences.
/// Persistent store for skill preferences.
pub struct PreferencesStore {
path: PathBuf,
cache: RwLock<HashMap<String, SkillPreference>>,
@@ -42,7 +44,6 @@ impl PreferencesStore {
return;
}
}
// Start empty if file doesn't exist or is corrupt
*self.cache.write() = HashMap::new();
}
@@ -50,7 +51,6 @@ impl PreferencesStore {
fn save(&self) {
let cache = self.cache.read();
if let Ok(json) = serde_json::to_string_pretty(&*cache) {
// Ensure parent directory exists
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
@@ -60,6 +60,30 @@ impl PreferencesStore {
}
}
fn get_or_default(&self, skill_id: &str) -> SkillPreference {
self.cache
.read()
.get(skill_id)
.cloned()
.unwrap_or(SkillPreference {
enabled: false,
setup_complete: false,
})
}
fn update<F: FnOnce(&mut SkillPreference)>(&self, skill_id: &str, f: F) {
let mut cache = self.cache.write();
let pref = cache
.entry(skill_id.to_string())
.or_insert(SkillPreference {
enabled: false,
setup_complete: false,
});
f(pref);
drop(cache);
self.save();
}
/// Check if a skill has a user preference set. Returns `None` if no preference exists.
pub fn is_enabled(&self, skill_id: &str) -> Option<bool> {
self.cache.read().get(skill_id).map(|p| p.enabled)
@@ -67,10 +91,22 @@ impl PreferencesStore {
/// Set the enabled preference for a skill. Persists immediately.
pub fn set_enabled(&self, skill_id: &str, enabled: bool) {
self.cache
.write()
.insert(skill_id.to_string(), SkillPreference { enabled });
self.save();
self.update(skill_id, |p| p.enabled = enabled);
}
/// Get whether a skill's setup has been completed.
pub fn is_setup_complete(&self, skill_id: &str) -> bool {
self.get_or_default(skill_id).setup_complete
}
/// Set the setup completion flag for a skill. Persists immediately.
pub fn set_setup_complete(&self, skill_id: &str, complete: bool) {
self.update(skill_id, |p| p.setup_complete = complete);
log::info!(
"[preferences] setup_complete for '{}' set to {}",
skill_id,
complete
);
}
/// Get all preferences as a map.
@@ -79,7 +115,6 @@ impl PreferencesStore {
}
/// Resolve whether a skill should start, considering user preference and manifest default.
/// User preference always wins; falls back to `manifest_auto_start` if no preference set.
pub fn resolve_should_start(&self, skill_id: &str, manifest_auto_start: bool) -> bool {
match self.is_enabled(skill_id) {
Some(enabled) => enabled,
+26 -4
View File
@@ -459,14 +459,36 @@ impl RuntimeEngine {
Ok(())
}
/// List all registered skills.
/// List all registered skills, enriched with persistent preferences.
pub fn list_skills(&self) -> Vec<SkillSnapshot> {
self.registry.list_skills()
let mut snapshots = self.registry.list_skills();
for snap in &mut snapshots {
self.enrich_snapshot(snap);
}
snapshots
}
/// Get the state of a specific skill.
/// Get the state of a specific skill, enriched with persistent preferences.
pub fn get_skill_state(&self, skill_id: &str) -> Option<SkillSnapshot> {
self.registry.get_skill(skill_id)
self.registry.get_skill(skill_id).map(|mut snap| {
self.enrich_snapshot(&mut snap);
snap
})
}
/// Populate `setup_complete` from preferences and re-derive `connection_status`.
fn enrich_snapshot(&self, snap: &mut SkillSnapshot) {
snap.setup_complete = self.preferences.is_setup_complete(&snap.skill_id);
snap.connection_status = crate::openhuman::skills::types::derive_connection_status(
snap.status,
snap.setup_complete,
&snap.state,
);
}
/// Get the preferences store (for RPC handlers that need to set setup_complete).
pub fn preferences(&self) -> &PreferencesStore {
&self.preferences
}
/// Call a tool on a skill.
@@ -31,6 +31,8 @@ impl QjsSkillInstance {
}
/// Take a snapshot of the current skill state.
/// Note: `setup_complete` and `connection_status` are populated later
/// by RuntimeEngine::enrich_snapshot() which has access to PreferencesStore.
pub fn snapshot(&self) -> SkillSnapshot {
let state = self.state.read();
SkillSnapshot {
@@ -40,6 +42,8 @@ impl QjsSkillInstance {
tools: state.tools.clone(),
error: state.error.clone(),
state: state.published_state.clone(),
setup_complete: false,
connection_status: String::new(),
}
}
+85 -10
View File
@@ -40,6 +40,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
skills_schema("enable"),
skills_schema("disable"),
skills_schema("is_enabled"),
skills_schema("set_setup_complete"),
skills_schema("get_all_snapshots"),
]
}
@@ -152,6 +154,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: skills_schema("is_enabled"),
handler: handle_skills_is_enabled,
},
RegisteredController {
schema: skills_schema("set_setup_complete"),
handler: handle_skills_set_setup_complete,
},
RegisteredController {
schema: skills_schema("get_all_snapshots"),
handler: handle_skills_get_all_snapshots,
},
]
}
@@ -408,7 +418,8 @@ fn skills_schema(function: &str) -> ControllerSchema {
"rpc" => ControllerSchema {
namespace: "skills",
function: "rpc",
description: "Send an arbitrary RPC method to a running skill (e.g. oauth/complete, skill/sync).",
description:
"Send an arbitrary RPC method to a running skill (e.g. oauth/complete, skill/sync).",
inputs: vec![
skill_id_input("The target skill ID."),
FieldSchema {
@@ -434,7 +445,8 @@ fn skills_schema(function: &str) -> ControllerSchema {
"discover" => ControllerSchema {
namespace: "skills",
function: "discover",
description: "Discover all available skill manifests from source and workspace directories.",
description:
"Discover all available skill manifests from source and workspace directories.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
@@ -549,6 +561,39 @@ fn skills_schema(function: &str) -> ControllerSchema {
required: true,
}],
},
"set_setup_complete" => ControllerSchema {
namespace: "skills",
function: "set_setup_complete",
description: "Persist setup completion flag for a skill.",
inputs: vec![
skill_id_input("The skill ID."),
FieldSchema {
name: "complete",
ty: TypeSchema::Bool,
comment: "Whether setup is complete.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Acknowledgment.",
required: true,
}],
},
"get_all_snapshots" => ControllerSchema {
namespace: "skills",
function: "get_all_snapshots",
description:
"Get all skill snapshots enriched with setup_complete and connection_status.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Array of enriched skill snapshots.",
required: true,
}],
},
_ => ControllerSchema {
namespace: "skills",
function: "unknown",
@@ -667,14 +712,6 @@ struct CallToolParams {
arguments: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct SkillRpcParams {
skill_id: String,
method: String,
#[serde(default)]
params: Option<serde_json::Value>,
}
fn handle_skills_start(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: SkillIdParams =
@@ -759,6 +796,14 @@ fn handle_skills_call_tool(params: Map<String, Value>) -> ControllerFuture {
})
}
#[derive(Deserialize)]
struct SkillRpcParams {
skill_id: String,
method: String,
#[serde(default)]
params: Option<serde_json::Value>,
}
fn handle_skills_rpc(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: SkillRpcParams =
@@ -871,6 +916,36 @@ fn handle_skills_is_enabled(params: Map<String, Value>) -> ControllerFuture {
})
}
#[derive(Deserialize)]
struct SetSetupCompleteParams {
skill_id: String,
complete: bool,
}
fn handle_skills_set_setup_complete(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: SetSetupCompleteParams =
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
let engine = require_engine()?;
engine
.preferences()
.set_setup_complete(&p.skill_id, p.complete);
Ok(serde_json::json!({
"success": true,
"skill_id": p.skill_id,
"setup_complete": p.complete
}))
})
}
fn handle_skills_get_all_snapshots(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let engine = require_engine()?;
let snapshots = engine.list_skills();
serde_json::to_value(&snapshots).map_err(|e| e.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
+22 -16
View File
@@ -69,14 +69,7 @@ impl SkillRegistry {
.iter()
.map(|(skill_id, entry)| {
let state = entry.state.read();
SkillSnapshot {
skill_id: skill_id.clone(),
name: entry.config.name.clone(),
status: state.status,
tools: state.tools.clone(),
error: state.error.clone(),
state: state.published_state.clone(),
}
Self::build_snapshot(skill_id, entry, &state)
})
.collect()
}
@@ -86,17 +79,30 @@ impl SkillRegistry {
let skills = self.skills.read();
skills.get(skill_id).map(|entry| {
let state = entry.state.read();
SkillSnapshot {
skill_id: skill_id.to_string(),
name: entry.config.name.clone(),
status: state.status,
tools: state.tools.clone(),
error: state.error.clone(),
state: state.published_state.clone(),
}
Self::build_snapshot(skill_id, entry, &state)
})
}
fn build_snapshot(skill_id: &str, entry: &RegistryEntry, state: &SkillState) -> SkillSnapshot {
use crate::openhuman::skills::types::derive_connection_status;
// setup_complete is populated later by the caller who has access to PreferencesStore
let setup_complete = false;
let connection_status =
derive_connection_status(state.status, setup_complete, &state.published_state);
SkillSnapshot {
skill_id: skill_id.to_string(),
name: entry.config.name.clone(),
status: state.status,
tools: state.tools.clone(),
error: state.error.clone(),
state: state.published_state.clone(),
setup_complete,
connection_status,
}
}
/// Get the status of a skill.
#[allow(dead_code)]
pub fn get_status(&self, skill_id: &str) -> Option<SkillStatus> {
+62
View File
@@ -147,6 +147,68 @@ pub struct SkillSnapshot {
/// Arbitrary state the skill has published for the frontend.
#[serde(default)]
pub state: HashMap<String, serde_json::Value>,
/// Whether the skill's setup/OAuth flow has been completed (persisted).
#[serde(default)]
pub setup_complete: bool,
/// Derived connection status for the frontend UI.
#[serde(default)]
pub connection_status: String,
}
/// Derive a unified connection status string from skill state.
/// Mirrors the logic in `app/src/lib/skills/hooks.ts:deriveConnectionStatus`.
pub fn derive_connection_status(
status: SkillStatus,
setup_complete: bool,
published_state: &HashMap<String, serde_json::Value>,
) -> String {
match status {
SkillStatus::Pending | SkillStatus::Stopped | SkillStatus::Stopping => {
return "offline".to_string()
}
SkillStatus::Error => return "error".to_string(),
SkillStatus::Initializing => return "connecting".to_string(),
_ => {}
}
let conn = published_state
.get("connection_status")
.and_then(|v| v.as_str());
let auth = published_state.get("auth_status").and_then(|v| v.as_str());
// No self-reported state — derive from lifecycle + setup
if conn.is_none() && auth.is_none() {
if setup_complete && matches!(status, SkillStatus::Running) {
return "connected".to_string();
}
if !setup_complete {
return "setup_required".to_string();
}
return "connecting".to_string();
}
if conn == Some("error") || auth == Some("error") {
return "error".to_string();
}
if conn == Some("connecting") || auth == Some("authenticating") {
return "connecting".to_string();
}
if conn == Some("connected") {
if auth.is_none() || auth == Some("authenticated") {
return "connected".to_string();
}
if auth == Some("not_authenticated") {
return "not_authenticated".to_string();
}
}
if conn == Some("disconnected") {
if setup_complete {
return "disconnected".to_string();
}
return "setup_required".to_string();
}
"connecting".to_string()
}
/// Configuration for a skill instance, derived from its manifest.