feat: add windows elevation status banner
BIN
hero-robots-restored.png
Normal file
|
After Width: | Height: | Size: 719 KiB |
|
|
@ -6,10 +6,24 @@ import {
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
} from "@mdi/js";
|
||||
import { getLocalizedHeroFeatureRail } from "~/data/heroScene";
|
||||
import {
|
||||
heroCollaborationFeature,
|
||||
getLocalizedHeroFeatureRail,
|
||||
getLocalizedHeroReviewerFeatureCard,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
const props = defineProps<{
|
||||
activeMessage?: HeroMessage | null;
|
||||
phase?: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
const { locale } = useI18n();
|
||||
const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
|
||||
const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value));
|
||||
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
|
||||
|
||||
const icons = [
|
||||
mdiRobotOutline,
|
||||
|
|
@ -18,10 +32,74 @@ const icons = [
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
] as const;
|
||||
|
||||
const reviewerIsSender = computed(() =>
|
||||
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
|
||||
);
|
||||
const reviewerIsReceiver = computed(() =>
|
||||
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
|
||||
);
|
||||
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
|
||||
const reviewerBubbleText = computed(() => {
|
||||
if (!props.activeMessage || props.reducedMotion) return null;
|
||||
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
|
||||
return props.activeMessage.text;
|
||||
}
|
||||
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
|
||||
return props.activeMessage.response;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cyber-feature-rail-shell">
|
||||
<img
|
||||
class="cyber-feature-rail__collaboration"
|
||||
:src="heroCollaborationFeature.asset"
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="cyber-feature-rail__reviewer"
|
||||
:class="{
|
||||
'cyber-feature-rail__reviewer--active': reviewerIsActive,
|
||||
'cyber-feature-rail__reviewer--sending': reviewerIsSender,
|
||||
'cyber-feature-rail__reviewer--receiving': reviewerIsReceiver,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="cyber-feature-bubble">
|
||||
<RobotSpeechBubble
|
||||
v-if="reviewerBubbleText"
|
||||
class="cyber-feature-rail__reviewer-bubble"
|
||||
tail="down"
|
||||
>
|
||||
{{ reviewerBubbleText }}
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ localizedHeroReviewerFeatureCard.label }}</div>
|
||||
<ul class="cyber-feature-rail__reviewer-tasks">
|
||||
<li v-for="task in localizedHeroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-feature-rail__reviewer-status">
|
||||
<span>{{ statusLabel }}</span>
|
||||
<strong>{{ localizedHeroReviewerFeatureCard.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
class="cyber-feature-rail__robot"
|
||||
:src="localizedHeroReviewerFeatureCard.asset"
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
<div class="cyber-feature-rail">
|
||||
<div
|
||||
v-for="(feature, index) in localizedHeroFeatureRail"
|
||||
|
|
|
|||
|
|
@ -252,6 +252,9 @@ onUnmounted(() => {
|
|||
|
||||
<CyberHeroFeatureStrip
|
||||
class="cyber-hero__feature-strip"
|
||||
:active-message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</v-container>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type Screenshot = {
|
|||
|
||||
/**
|
||||
* Screenshot definitions for the carousel.
|
||||
* `src` is relative to public/ — prepend baseURL at runtime.
|
||||
* `src` is served from the repository-level docs/screenshots directory.
|
||||
*/
|
||||
export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
|
||||
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 },
|
||||
|
|
@ -21,6 +21,4 @@ export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
|
|||
{ path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 },
|
||||
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", ruAlt: "Комментарии агента к код-ревью и процессу задачи", width: 2624, height: 1696 },
|
||||
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", ruAlt: "Разрешение или запрет действий агента с предпросмотром", width: 2624, height: 1646 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vuetify from "vite-plugin-vuetify";
|
||||
import { generateI18nRoutes, supportedLocales } from "./data/i18n";
|
||||
|
||||
|
|
@ -12,6 +14,7 @@ const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK
|
|||
const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId;
|
||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers";
|
||||
const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.";
|
||||
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`;
|
||||
|
|
@ -82,6 +85,13 @@ export default defineNuxtConfig({
|
|||
},
|
||||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
publicAssets: [
|
||||
{
|
||||
baseURL: "/screenshots",
|
||||
dir: resolve(repoRoot, "docs/screenshots"),
|
||||
maxAge: 60 * 60 * 24 * 365
|
||||
}
|
||||
],
|
||||
prerender: {
|
||||
ignore: [
|
||||
"/docs",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 684 KiB |
|
Before Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 664 KiB |
|
Before Width: | Height: | Size: 704 KiB |
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 666 KiB |
|
Before Width: | Height: | Size: 770 KiB |
|
|
@ -88,7 +88,9 @@ import {
|
|||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||
import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
|
||||
import {
|
||||
APP_GET_WINDOWS_ELEVATION_STATUS,
|
||||
APP_STARTUP_GET_STATUS,
|
||||
APP_STARTUP_PROGRESS,
|
||||
CONTEXT_CHANGED,
|
||||
|
|
@ -975,6 +977,7 @@ function registerAppStartupHandlers(): void {
|
|||
appStartupHandlersRegistered = true;
|
||||
registerRendererLogHandlers(ipcMain);
|
||||
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
|
||||
ipcMain.handle(APP_GET_WINDOWS_ELEVATION_STATUS, () => getWindowsElevationStatus());
|
||||
}
|
||||
|
||||
function cloneStartupSteps(): AppStartupStep[] {
|
||||
|
|
|
|||
145
src/main/utils/windowsElevation.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { win32 as pathWin32 } from 'path';
|
||||
|
||||
import type { WindowsElevationStatus } from '@shared/types/api';
|
||||
|
||||
const DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_WINDOWS_SYSTEM_ROOT = 'C:\\Windows';
|
||||
|
||||
export interface WindowsElevationCommandResult {
|
||||
error: unknown;
|
||||
stderr?: string | Buffer | null;
|
||||
}
|
||||
|
||||
export interface WindowsElevationCommandOptions {
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export type WindowsElevationCommandRunner = (
|
||||
command: string,
|
||||
options: WindowsElevationCommandOptions
|
||||
) => Promise<WindowsElevationCommandResult>;
|
||||
|
||||
export interface WindowsElevationStatusCheckerOptions {
|
||||
platform?: string;
|
||||
systemRoot?: string;
|
||||
timeoutMs?: number;
|
||||
runCommand?: WindowsElevationCommandRunner;
|
||||
}
|
||||
|
||||
let cachedWindowsElevationStatus: Promise<WindowsElevationStatus> | null = null;
|
||||
|
||||
function createStatus(
|
||||
platform: string,
|
||||
isAdministrator: boolean | null,
|
||||
checkFailed: boolean,
|
||||
error: string | null = null
|
||||
): WindowsElevationStatus {
|
||||
return {
|
||||
platform,
|
||||
isWindows: platform === 'win32',
|
||||
isAdministrator,
|
||||
checkFailed,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function readErrorField(error: unknown, field: string): unknown {
|
||||
if (!error || typeof error !== 'object' || !(field in error)) {
|
||||
return undefined;
|
||||
}
|
||||
return (error as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | number | null {
|
||||
const code = readErrorField(error, 'code');
|
||||
return typeof code === 'string' || typeof code === 'number' ? code : null;
|
||||
}
|
||||
|
||||
function wasKilledOrTimedOut(error: unknown): boolean {
|
||||
const killed = readErrorField(error, 'killed');
|
||||
const signal = readErrorField(error, 'signal');
|
||||
const code = getErrorCode(error);
|
||||
return killed === true || signal === 'SIGTERM' || code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
function toCappedString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return value.slice(0, 500);
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value.toString('utf8').slice(0, 500);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, stderr: unknown): string | null {
|
||||
const stderrText = toCappedString(stderr)?.trim();
|
||||
if (stderrText) {
|
||||
return stderrText;
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message.slice(0, 500);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFltmcPath(systemRoot: string): string {
|
||||
return pathWin32.join(systemRoot, 'System32', 'fltmc.exe');
|
||||
}
|
||||
|
||||
function runFltmc(command: string, options: WindowsElevationCommandOptions) {
|
||||
return new Promise<WindowsElevationCommandResult>((resolve) => {
|
||||
execFile(
|
||||
command,
|
||||
[],
|
||||
{ timeout: options.timeoutMs, windowsHide: true },
|
||||
(error, _stdout, stderr) => {
|
||||
resolve({ error, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createWindowsElevationStatusChecker(
|
||||
options: WindowsElevationStatusCheckerOptions = {}
|
||||
): () => Promise<WindowsElevationStatus> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const systemRoot = options.systemRoot ?? process.env.SystemRoot ?? DEFAULT_WINDOWS_SYSTEM_ROOT;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS;
|
||||
const runCommand = options.runCommand ?? runFltmc;
|
||||
|
||||
return async () => {
|
||||
if (platform !== 'win32') {
|
||||
return createStatus(platform, null, false);
|
||||
}
|
||||
|
||||
let result: WindowsElevationCommandResult;
|
||||
try {
|
||||
result = await runCommand(getFltmcPath(systemRoot), { timeoutMs });
|
||||
} catch (error) {
|
||||
return createStatus(platform, null, true, getErrorMessage(error, null));
|
||||
}
|
||||
|
||||
if (!result.error) {
|
||||
return createStatus(platform, true, false);
|
||||
}
|
||||
|
||||
const code = getErrorCode(result.error);
|
||||
const message = getErrorMessage(result.error, result.stderr);
|
||||
if (code === 'ENOENT' || wasKilledOrTimedOut(result.error)) {
|
||||
return createStatus(platform, null, true, message);
|
||||
}
|
||||
|
||||
return createStatus(platform, false, false, message);
|
||||
};
|
||||
}
|
||||
|
||||
export function getWindowsElevationStatus(): Promise<WindowsElevationStatus> {
|
||||
cachedWindowsElevationStatus ??= createWindowsElevationStatusChecker()();
|
||||
return cachedWindowsElevationStatus;
|
||||
}
|
||||
|
||||
export function resetWindowsElevationStatusCacheForTests(): void {
|
||||
cachedWindowsElevationStatus = null;
|
||||
}
|
||||
|
|
@ -23,6 +23,9 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
|
|||
/** Main -> renderer startup progress update */
|
||||
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
|
||||
|
||||
/** Renderer -> main Windows elevation status request */
|
||||
export const APP_GET_WINDOWS_ELEVATION_STATUS = 'app:getWindowsElevationStatus';
|
||||
|
||||
// =============================================================================
|
||||
// Telemetry Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
API_KEYS_LOOKUP,
|
||||
API_KEYS_SAVE,
|
||||
API_KEYS_STORAGE_STATUS,
|
||||
APP_GET_WINDOWS_ELEVATION_STATUS,
|
||||
APP_RELAUNCH,
|
||||
APP_STARTUP_GET_STATUS,
|
||||
APP_STARTUP_PROGRESS,
|
||||
|
|
@ -350,6 +351,7 @@ import type {
|
|||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
UpdateSchedulePatch,
|
||||
WindowsElevationStatus,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type {
|
||||
|
|
@ -514,6 +516,8 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getWindowsElevationStatus: () =>
|
||||
ipcRenderer.invoke(APP_GET_WINDOWS_ELEVATION_STATUS) as Promise<WindowsElevationStatus>,
|
||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||
getSessionsPaginated: (
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import type {
|
|||
UpdaterAPI,
|
||||
UpdateSchedulePatch,
|
||||
WaterfallData,
|
||||
WindowsElevationStatus,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api';
|
||||
|
|
@ -244,6 +245,14 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
|
||||
|
||||
getWindowsElevationStatus = async (): Promise<WindowsElevationStatus> => ({
|
||||
platform: 'browser',
|
||||
isWindows: false,
|
||||
isAdministrator: null,
|
||||
checkFailed: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> =>
|
||||
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { CliStatusBanner } from './CliStatusBanner';
|
|||
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
|
||||
import { TmuxStatusBanner } from './TmuxStatusBanner';
|
||||
import { WebPreviewBanner } from './WebPreviewBanner';
|
||||
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
|
||||
|
||||
interface CommandSearchProps {
|
||||
value: string;
|
||||
|
|
@ -114,6 +115,7 @@ export const DashboardView = (): React.JSX.Element => {
|
|||
|
||||
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
||||
<WebPreviewBanner />
|
||||
<WindowsAdministratorBanner />
|
||||
<DashboardUpdateBanner />
|
||||
<CliStatusBanner />
|
||||
<TmuxStatusBanner />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
|
||||
|
||||
import type { WindowsElevationStatus } from '@shared/types/api';
|
||||
|
||||
function createStatus(overrides: Partial<WindowsElevationStatus> = {}): WindowsElevationStatus {
|
||||
return {
|
||||
platform: 'win32',
|
||||
isWindows: true,
|
||||
isAdministrator: false,
|
||||
checkFailed: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installElevationStatus(status: WindowsElevationStatus) {
|
||||
const getWindowsElevationStatus = vi.fn().mockResolvedValue(status);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getWindowsElevationStatus,
|
||||
},
|
||||
});
|
||||
return getWindowsElevationStatus;
|
||||
}
|
||||
|
||||
async function flushReact(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('WindowsAdministratorBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
Reflect.deleteProperty(window, 'electronAPI');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows a Windows Administrator warning when the app is not elevated', async () => {
|
||||
const getWindowsElevationStatus = installElevationStatus(createStatus());
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toContain('Windows Administrator mode recommended');
|
||||
expect(host.textContent).toContain('Run as administrator');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the warning when Windows is already elevated', async () => {
|
||||
const getWindowsElevationStatus = installElevationStatus(
|
||||
createStatus({ isAdministrator: true })
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the warning outside Windows', async () => {
|
||||
const getWindowsElevationStatus = installElevationStatus(
|
||||
createStatus({ platform: 'darwin', isWindows: false, isAdministrator: null })
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the warning when the preload bridge does not expose the status check', async () => {
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(WindowsAdministratorBanner));
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import type { WindowsElevationStatus } from '@shared/types/api';
|
||||
|
||||
export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
|
||||
const [status, setStatus] = useState<WindowsElevationStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectronMode()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const getStatus = api.getWindowsElevationStatus;
|
||||
if (typeof getStatus !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void getStatus()
|
||||
.then((nextStatus) => {
|
||||
if (!cancelled) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!status?.isWindows || status.isAdministrator !== false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-start gap-3 rounded-lg border px-4 py-3"
|
||||
role="status"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.07)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-amber-200">
|
||||
Windows Administrator mode recommended
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app
|
||||
with Run as administrator before launching OpenCode teams.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -806,6 +806,14 @@ export interface TelemetryAPI {
|
|||
getSentryContext: () => Promise<SentryTelemetryContext | null>;
|
||||
}
|
||||
|
||||
export interface WindowsElevationStatus {
|
||||
platform: string;
|
||||
isWindows: boolean;
|
||||
isAdministrator: boolean | null;
|
||||
checkFailed: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Electron API
|
||||
// =============================================================================
|
||||
|
|
@ -817,6 +825,7 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
|||
startup?: AppStartupAPI;
|
||||
telemetry: TelemetryAPI;
|
||||
getAppVersion: () => Promise<string>;
|
||||
getWindowsElevationStatus: () => Promise<WindowsElevationStatus>;
|
||||
getProjects: () => Promise<Project[]>;
|
||||
getSessions: (projectId: string) => Promise<Session[]>;
|
||||
getSessionsPaginated: (
|
||||
|
|
|
|||
119
test/main/utils/windowsElevation.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// @vitest-environment node
|
||||
import {
|
||||
createWindowsElevationStatusChecker,
|
||||
resetWindowsElevationStatusCacheForTests,
|
||||
} from '@main/utils/windowsElevation';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WindowsElevationCommandRunner } from '@main/utils/windowsElevation';
|
||||
|
||||
function createError(
|
||||
message: string,
|
||||
fields: { code?: string | number; killed?: boolean; signal?: string | null } = {}
|
||||
): Error & { code?: string | number; killed?: boolean; signal?: string | null } {
|
||||
return Object.assign(new Error(message), fields);
|
||||
}
|
||||
|
||||
describe('windowsElevation', () => {
|
||||
afterEach(() => {
|
||||
resetWindowsElevationStatusCacheForTests();
|
||||
});
|
||||
|
||||
it('does not run the elevation command outside Windows', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>();
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'darwin',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(status).toEqual({
|
||||
platform: 'darwin',
|
||||
isWindows: false,
|
||||
isAdministrator: null,
|
||||
checkFailed: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports Administrator mode when fltmc succeeds', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockResolvedValue({ error: null });
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
systemRoot: 'C:\\Windows',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', {
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
expect(status.isAdministrator).toBe(true);
|
||||
expect(status.checkFailed).toBe(false);
|
||||
});
|
||||
|
||||
it('reports non-elevated Windows when fltmc exits with an error', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command failed', { code: 1 }),
|
||||
stderr: 'Access is denied.',
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isWindows).toBe(true);
|
||||
expect(status.isAdministrator).toBe(false);
|
||||
expect(status.checkFailed).toBe(false);
|
||||
expect(status.error).toBe('Access is denied.');
|
||||
});
|
||||
|
||||
it('reports an unknown status when the Windows probe command is missing', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }),
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
expect(status.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('reports an unknown status when the Windows probe times out', async () => {
|
||||
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
|
||||
error: createError('Command timed out', { code: 'ETIMEDOUT', killed: true }),
|
||||
});
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
expect(status.error).toContain('Command timed out');
|
||||
});
|
||||
|
||||
it('reports an unknown status when the Windows probe throws before returning a result', async () => {
|
||||
const runCommand = vi
|
||||
.fn<WindowsElevationCommandRunner>()
|
||||
.mockRejectedValue(new Error('spawn failed'));
|
||||
|
||||
const status = await createWindowsElevationStatusChecker({
|
||||
platform: 'win32',
|
||||
runCommand,
|
||||
})();
|
||||
|
||||
expect(status.isAdministrator).toBeNull();
|
||||
expect(status.checkFailed).toBe(true);
|
||||
expect(status.error).toBe('spawn failed');
|
||||
});
|
||||
});
|
||||