Skip to content

Commit ff92511

Browse files
committed
feat(share): add screen recording video sharing
1 parent cf8766d commit ff92511

File tree

7 files changed

+382
-77
lines changed

7 files changed

+382
-77
lines changed

src/main/main.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { electronApp, optimizer } from '@electron-toolkit/utils';
6-
import { app, globalShortcut, ipcMain } from 'electron';
6+
import {
7+
app,
8+
desktopCapturer,
9+
globalShortcut,
10+
ipcMain,
11+
screen,
12+
session,
13+
} from 'electron';
714
import squirrelStartup from 'electron-squirrel-startup';
815
import ElectronStore from 'electron-store';
916
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
@@ -123,6 +130,20 @@ const initializeApp = async () => {
123130
// eslint-disable-next-line
124131
new AppUpdater();
125132

133+
session.defaultSession.setDisplayMediaRequestHandler(
134+
(_request, callback) => {
135+
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
136+
// Grant access to the first screen found.
137+
callback({ video: sources[0], audio: 'loopback' });
138+
});
139+
// If true, use the system picker if available.
140+
// Note: this is currently experimental. If the system picker
141+
// is available, it will be used and the media request handler
142+
// will not be invoked.
143+
},
144+
{ useSystemPicker: true },
145+
);
146+
126147
logger.info('mainZustandBridge');
127148

128149
const { unsubscribe } = mainZustandBridge(
@@ -152,6 +173,14 @@ const registerIPCHandlers = () => {
152173
ipcMain.handle('utio:shareReport', async (_, params) => {
153174
await UTIOService.getInstance().shareReport(params);
154175
});
176+
177+
ipcMain.handle('get-screen-size', () => {
178+
const primaryDisplay = screen.getPrimaryDisplay();
179+
return {
180+
screenWidth: primaryDisplay.size.width,
181+
screenHeight: primaryDisplay.size.height,
182+
};
183+
});
155184
};
156185

157186
/**

src/main/window/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export async function hideWindowBlock<T>(
145145
let originalBounds: Electron.Rectangle | undefined;
146146

147147
try {
148-
mainWindow?.setContentProtection(true);
148+
mainWindow?.setContentProtection(false);
149149
mainWindow?.setAlwaysOnTop(true);
150150
try {
151151
if (mainWindow) {

src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type Channels = 'ipc-example';
1313

1414
const electronHandler = {
1515
ipcRenderer: {
16+
invoke: (channel: string, ...args: unknown[]) =>
17+
ipcRenderer.invoke(channel, ...args),
1618
sendMessage(channel: Channels, ...args: unknown[]) {
1719
ipcRenderer.send(channel, ...args);
1820
},

src/renderer/src/components/ChatInput/index.tsx

Lines changed: 152 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,26 @@
22
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import { Box, Button, Flex, HStack, Spinner, VStack } from '@chakra-ui/react';
5+
import {
6+
Box,
7+
Button,
8+
Flex,
9+
HStack,
10+
Menu,
11+
MenuButton,
12+
MenuItem,
13+
MenuList,
14+
Spinner,
15+
useDisclosure,
16+
VStack,
17+
} from '@chakra-ui/react';
618
import { useToast } from '@chakra-ui/react';
19+
import { RiRecordCircleLine } from 'react-icons/ri';
20+
import { TbReport } from 'react-icons/tb';
721
import React, { forwardRef, useEffect, useMemo, useRef } from 'react';
822
import { FaPaperPlane, FaStop, FaTrash } from 'react-icons/fa';
9-
import { LuScreenShare } from 'react-icons/lu';
23+
import { HiChevronDown } from 'react-icons/hi';
24+
import { FaRegShareFromSquare } from 'react-icons/fa6';
1025
import { IoPlay } from 'react-icons/io5';
1126
import { useDispatch } from 'zutron';
1227

@@ -20,6 +35,7 @@ import { uploadReport } from '@renderer/utils/share';
2035

2136
import reportHTMLUrl from '@resources/report.html?url';
2237
import { isCallUserMessage } from '@renderer/utils/message';
38+
import { useScreenRecord } from '@renderer/hooks/useScreenRecord';
2339

2440
const ChatInput = forwardRef((_props, _ref) => {
2541
const {
@@ -36,6 +52,18 @@ const ChatInput = forwardRef((_props, _ref) => {
3652

3753
const toast = useToast();
3854
const { run } = useRunAgent();
55+
const {
56+
isOpen: isShareOpen,
57+
onOpen: onShareOpen,
58+
onClose: onShareClose,
59+
} = useDisclosure();
60+
const {
61+
canSaveRecording,
62+
startRecording,
63+
stopRecording,
64+
saveRecording,
65+
recordRefs,
66+
} = useScreenRecord();
3967

4068
const textareaRef = useRef<HTMLTextAreaElement>(null);
4169
const running = status === 'running';
@@ -44,6 +72,9 @@ const ChatInput = forwardRef((_props, _ref) => {
4472

4573
const startRun = () => {
4674
run(localInstructions, () => {
75+
startRecording().catch((e) => {
76+
console.error('start recording failed:', e);
77+
});
4778
setLocalInstructions('');
4879
});
4980
};
@@ -74,6 +105,12 @@ const ChatInput = forwardRef((_props, _ref) => {
74105
}
75106
}, []);
76107

108+
useEffect(() => {
109+
if (status === StatusEnum.INIT) {
110+
return;
111+
}
112+
}, [status]);
113+
77114
const isCallUser = useMemo(() => isCallUserMessage(messages), [messages]);
78115

79116
/**
@@ -83,6 +120,14 @@ const ChatInput = forwardRef((_props, _ref) => {
83120
if (status === StatusEnum.END && isCallUser && savedInstructions) {
84121
setLocalInstructions(savedInstructions);
85122
}
123+
// record screen when running
124+
if (status !== StatusEnum.INIT) {
125+
stopRecording();
126+
}
127+
128+
return () => {
129+
stopRecording();
130+
};
86131
}, [isCallUser, status]);
87132

88133
const lastHumanMessage =
@@ -96,7 +141,7 @@ const ChatInput = forwardRef((_props, _ref) => {
96141
const shareTimeoutRef = React.useRef<NodeJS.Timeout>();
97142
const SHARE_TIMEOUT = 100000;
98143

99-
const handleShare = async () => {
144+
const handleShare = async (type: 'report' | 'video') => {
100145
if (isSharePending.current) {
101146
return;
102147
}
@@ -118,73 +163,77 @@ const ChatInput = forwardRef((_props, _ref) => {
118163
});
119164
}, SHARE_TIMEOUT);
120165

121-
const response = await fetch(reportHTMLUrl);
122-
const html = await response.text();
123-
124-
const userData = {
125-
...restUserData,
126-
status,
127-
conversations: messages,
128-
} as ComputerUseUserData;
129-
130-
const htmlContent = reportHTMLContent(html, [userData]);
131-
132-
let reportUrl: string | undefined;
133-
134-
if (settings?.reportStorageBaseUrl) {
135-
try {
136-
const { url } = await uploadReport(
137-
htmlContent,
138-
settings.reportStorageBaseUrl,
139-
);
140-
reportUrl = url;
141-
await navigator.clipboard.writeText(url);
142-
toast({
143-
title: 'Report link copied to clipboard!',
144-
status: 'success',
145-
position: 'top',
146-
duration: 2000,
147-
isClosable: true,
148-
variant: 'ui-tars-success',
149-
});
150-
} catch (error) {
151-
console.error('Share failed:', error);
152-
toast({
153-
title: 'Failed to upload report',
154-
description:
155-
error instanceof Error ? error.message : JSON.stringify(error),
156-
status: 'error',
157-
position: 'top',
158-
duration: 3000,
159-
isClosable: true,
166+
if (type === 'video') {
167+
saveRecording();
168+
} else if (type === 'report') {
169+
const response = await fetch(reportHTMLUrl);
170+
const html = await response.text();
171+
172+
const userData = {
173+
...restUserData,
174+
status,
175+
conversations: messages,
176+
} as ComputerUseUserData;
177+
178+
const htmlContent = reportHTMLContent(html, [userData]);
179+
180+
let reportUrl: string | undefined;
181+
182+
if (settings?.reportStorageBaseUrl) {
183+
try {
184+
const { url } = await uploadReport(
185+
htmlContent,
186+
settings.reportStorageBaseUrl,
187+
);
188+
reportUrl = url;
189+
await navigator.clipboard.writeText(url);
190+
toast({
191+
title: 'Report link copied to clipboard!',
192+
status: 'success',
193+
position: 'top',
194+
duration: 2000,
195+
isClosable: true,
196+
variant: 'ui-tars-success',
197+
});
198+
} catch (error) {
199+
console.error('Share failed:', error);
200+
toast({
201+
title: 'Failed to upload report',
202+
description:
203+
error instanceof Error ? error.message : JSON.stringify(error),
204+
status: 'error',
205+
position: 'top',
206+
duration: 3000,
207+
isClosable: true,
208+
});
209+
}
210+
}
211+
212+
// Send UTIO data through IPC
213+
if (settings?.utioBaseUrl) {
214+
const lastScreenshot = messages
215+
.filter((m) => m.screenshotBase64)
216+
.pop()?.screenshotBase64;
217+
218+
await window.electron.utio.shareReport({
219+
type: 'shareReport',
220+
instruction: lastHumanMessage,
221+
lastScreenshot,
222+
report: reportUrl,
160223
});
161224
}
162-
}
163225

164-
// Send UTIO data through IPC
165-
if (settings?.utioBaseUrl) {
166-
const lastScreenshot = messages
167-
.filter((m) => m.screenshotBase64)
168-
.pop()?.screenshotBase64;
169-
170-
await window.electron.utio.shareReport({
171-
type: 'shareReport',
172-
instruction: lastHumanMessage,
173-
lastScreenshot,
174-
report: reportUrl,
175-
});
226+
// If shareEndpoint is not configured or the upload fails, fall back to downloading the file
227+
const blob = new Blob([htmlContent], { type: 'text/html' });
228+
const url = window.URL.createObjectURL(blob);
229+
const a = document.createElement('a');
230+
a.href = url;
231+
a.download = `report-${Date.now()}.html`;
232+
document.body.appendChild(a);
233+
a.click();
234+
document.body.removeChild(a);
235+
window.URL.revokeObjectURL(url);
176236
}
177-
178-
// If shareEndpoint is not configured or the upload fails, fall back to downloading the file
179-
const blob = new Blob([htmlContent], { type: 'text/html' });
180-
const url = window.URL.createObjectURL(blob);
181-
const a = document.createElement('a');
182-
a.href = url;
183-
a.download = `report-${Date.now()}.html`;
184-
document.body.appendChild(a);
185-
a.click();
186-
document.body.removeChild(a);
187-
window.URL.revokeObjectURL(url);
188237
} catch (error) {
189238
console.error('Share failed:', error);
190239
toast({
@@ -270,17 +319,46 @@ const ChatInput = forwardRef((_props, _ref) => {
270319
<HStack justify="space-between" align="center" w="100%">
271320
<Box>
272321
{status !== StatusEnum.RUNNING && messages?.length > 1 && (
273-
<Button
274-
variant="tars-ghost"
275-
aria-label="Share"
276-
onClick={handleShare}
277-
isDisabled={isSharing}
278-
>
279-
{isSharing ? <Spinner size="sm" /> : <LuScreenShare />}
280-
</Button>
322+
<HStack spacing={2}>
323+
<Menu isLazy isOpen={isShareOpen} onClose={onShareClose}>
324+
<MenuButton
325+
as={Button}
326+
onMouseOver={onShareOpen}
327+
variant="tars-ghost"
328+
aria-label="Share options"
329+
rightIcon={<HiChevronDown />}
330+
>
331+
{isSharing ? (
332+
<Spinner size="sm" />
333+
) : (
334+
<FaRegShareFromSquare />
335+
)}
336+
</MenuButton>
337+
<MenuList>
338+
{canSaveRecording && (
339+
<MenuItem onClick={() => handleShare('video')}>
340+
<HStack spacing={1}>
341+
<RiRecordCircleLine />
342+
<span>Recording Video</span>
343+
</HStack>
344+
</MenuItem>
345+
)}
346+
<MenuItem onClick={() => handleShare('report')}>
347+
<HStack spacing={1}>
348+
<TbReport />
349+
<span>Report HTML</span>
350+
</HStack>
351+
</MenuItem>
352+
</MenuList>
353+
</Menu>
354+
</HStack>
281355
)}
282356
<div />
283357
</Box>
358+
<div style={{ display: 'none' }}>
359+
<video ref={recordRefs.videoRef} />
360+
<canvas ref={recordRefs.canvasRef} />
361+
</div>
284362
{/* <HStack spacing={2}>
285363
<Switch
286364
isChecked={fullyAuto}

src/renderer/src/components/Header/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ export default function Header({ className }: { className?: string }) {
1414
return (
1515
<Box position="relative" textAlign="center" className={className}>
1616
<Flex alignItems="center" justifyContent="center">
17-
<HStack>
17+
<HStack userSelect="none">
1818
<Image
19+
userSelect="none"
1920
alt="UI-TARS Logo"
2021
src={logoVector}
2122
h="40px"

0 commit comments

Comments
 (0)