|
1 | | -import { normalize } from 'node:path'; |
| 1 | +import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; |
| 2 | +import { readFile, unlink } from 'node:fs/promises'; |
| 3 | +import { tmpdir } from 'node:os'; |
| 4 | +import { join, normalize } from 'node:path'; |
2 | 5 | import { W3C_ELEMENT_KEY, errors } from '@appium/base-driver'; |
3 | 6 | import { Element, Rect } from '@appium/types'; |
4 | 7 | import { NovaWindowsDriver } from '../driver'; |
@@ -27,6 +30,7 @@ import { |
27 | 30 | pwsh |
28 | 31 | } from '../powershell'; |
29 | 32 | import { ClickType, Enum, Key } from '../enums'; |
| 33 | +import { getBundledFfmpegPath } from '../util'; |
30 | 34 |
|
31 | 35 | const PLATFORM_COMMAND_PREFIX = 'windows:'; |
32 | 36 |
|
@@ -58,6 +62,11 @@ const EXTENSION_COMMANDS = Object.freeze({ |
58 | 62 | setFocus: 'focusElement', |
59 | 63 | getClipboard: 'getClipboardBase64', |
60 | 64 | setClipboard: 'setClipboardFromBase64', |
| 65 | + startRecordingScreen: 'startRecordingScreen', |
| 66 | + stopRecordingScreen: 'stopRecordingScreen', |
| 67 | + deleteFile: 'deleteFile', |
| 68 | + deleteFolder: 'deleteFolder', |
| 69 | + clickAndDrag: 'executeClickAndDrag', |
61 | 70 | } as const); |
62 | 71 |
|
63 | 72 | const ContentType = Object.freeze({ |
@@ -703,3 +712,254 @@ export async function executeScroll(this: NovaWindowsDriver, scrollArgs: { |
703 | 712 | keyUp(Key.META); |
704 | 713 | } |
705 | 714 | } |
| 715 | + |
| 716 | +export async function startRecordingScreen(this: NovaWindowsDriver, args?: { |
| 717 | + outputPath?: string, |
| 718 | + timeLimit?: number, |
| 719 | + videoSize?: string, |
| 720 | + videoFps?: number, |
| 721 | + forceRestart?: boolean, |
| 722 | +}): Promise<void> { |
| 723 | + const { |
| 724 | + outputPath = join(tmpdir(), `novawindows-recording-${Date.now()}.mp4`), |
| 725 | + timeLimit = 180, |
| 726 | + videoSize, |
| 727 | + videoFps = 15, |
| 728 | + forceRestart = false, |
| 729 | + } = args ?? {}; |
| 730 | + |
| 731 | + if (this.recordingProcess && !forceRestart) { |
| 732 | + throw new errors.InvalidArgumentError('Screen recording is already in progress. Use forceRestart to start a new recording.'); |
| 733 | + } |
| 734 | + |
| 735 | + if (this.recordingProcess && forceRestart) { |
| 736 | + const oldProc = this.recordingProcess; |
| 737 | + this.recordingProcess = undefined; |
| 738 | + this.recordingOutputPath = undefined; |
| 739 | + oldProc.stdin?.write('q'); |
| 740 | + try { |
| 741 | + await new Promise<void>((resolve) => { |
| 742 | + oldProc.on('exit', () => resolve()); |
| 743 | + setTimeout(() => { |
| 744 | + oldProc.kill('SIGKILL'); |
| 745 | + resolve(); |
| 746 | + }, 3000); |
| 747 | + }); |
| 748 | + } catch { |
| 749 | + oldProc.kill('SIGKILL'); |
| 750 | + } |
| 751 | + } |
| 752 | + |
| 753 | + const ffmpegPath = getBundledFfmpegPath(); |
| 754 | + if (!ffmpegPath) { |
| 755 | + throw new errors.UnknownError( |
| 756 | + 'Screen recording is not available: the bundled ffmpeg is missing. Reinstall the driver.' |
| 757 | + ); |
| 758 | + } |
| 759 | + |
| 760 | + const ffmpegArgs = [ |
| 761 | + '-f', 'gdigrab', |
| 762 | + '-framerate', String(videoFps), |
| 763 | + '-i', 'desktop', |
| 764 | + '-t', String(timeLimit), |
| 765 | + '-c:v', 'libx264', |
| 766 | + '-preset', 'ultrafast', |
| 767 | + '-y', |
| 768 | + outputPath, |
| 769 | + ]; |
| 770 | + if (videoSize) { |
| 771 | + const sizeIdx = ffmpegArgs.indexOf('-i'); |
| 772 | + ffmpegArgs.splice(sizeIdx, 0, '-video_size', videoSize); |
| 773 | + } |
| 774 | + |
| 775 | + const proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); |
| 776 | + proc.on('error', (err) => { |
| 777 | + this.log.error( |
| 778 | + `Screen recording failed: ${err.message}. The bundled ffmpeg may be missing or invalid; try reinstalling the driver.` |
| 779 | + ); |
| 780 | + }); |
| 781 | + proc.stderr?.on('data', () => { /* suppress ffmpeg progress output */ }); |
| 782 | + |
| 783 | + this.recordingProcess = proc as ChildProcessWithoutNullStreams; |
| 784 | + this.recordingOutputPath = outputPath; |
| 785 | +} |
| 786 | + |
| 787 | +export async function stopRecordingScreen(this: NovaWindowsDriver, args?: { remotePath?: string }): Promise<string> { |
| 788 | + const { remotePath } = args ?? {}; |
| 789 | + |
| 790 | + if (!this.recordingProcess || !this.recordingOutputPath) { |
| 791 | + throw new errors.InvalidArgumentError('No screen recording in progress.'); |
| 792 | + } |
| 793 | + |
| 794 | + const proc = this.recordingProcess; |
| 795 | + const outputPath = this.recordingOutputPath; |
| 796 | + this.recordingProcess = undefined; |
| 797 | + this.recordingOutputPath = undefined; |
| 798 | + |
| 799 | + proc.stdin?.write('q'); |
| 800 | + |
| 801 | + await new Promise<void>((resolve) => { |
| 802 | + proc.on('exit', () => resolve()); |
| 803 | + setTimeout(() => resolve(), 5000); |
| 804 | + }); |
| 805 | + |
| 806 | + if (remotePath) { |
| 807 | + // TODO: upload to remotePath; for now return empty per Appium convention |
| 808 | + try { |
| 809 | + await unlink(outputPath); |
| 810 | + } catch { |
| 811 | + /* ignore */ |
| 812 | + } |
| 813 | + return ''; |
| 814 | + } |
| 815 | + |
| 816 | + try { |
| 817 | + const buffer = await readFile(outputPath); |
| 818 | + await unlink(outputPath); |
| 819 | + return buffer.toString('base64'); |
| 820 | + } catch (err) { |
| 821 | + throw new errors.UnknownError(`Failed to read recording: ${(err as Error).message}`); |
| 822 | + } |
| 823 | +} |
| 824 | + |
| 825 | +export async function deleteFile(this: NovaWindowsDriver, args: { path: string }): Promise<void> { |
| 826 | + if (!args || typeof args !== 'object' || !args.path) { |
| 827 | + throw new errors.InvalidArgumentError("'path' must be provided."); |
| 828 | + } |
| 829 | + const escapedPath = args.path.replace(/'/g, "''"); |
| 830 | + const useLiteralPath = /[\[\]?]/.test(args.path); |
| 831 | + const pathParam = useLiteralPath ? `-LiteralPath '${escapedPath}'` : `-Path '${escapedPath}'`; |
| 832 | + await this.sendPowerShellCommand(`Remove-Item ${pathParam} -Force -ErrorAction Stop`); |
| 833 | +} |
| 834 | + |
| 835 | +export async function deleteFolder(this: NovaWindowsDriver, args: { path: string, recursive?: boolean }): Promise<void> { |
| 836 | + if (!args || typeof args !== 'object' || !args.path) { |
| 837 | + throw new errors.InvalidArgumentError("'path' must be provided."); |
| 838 | + } |
| 839 | + const { path: pathArg, recursive = true } = args; |
| 840 | + const escapedPath = pathArg.replace(/'/g, "''"); |
| 841 | + const useLiteralPath = /[\[\]?]/.test(pathArg); |
| 842 | + const pathParam = useLiteralPath ? `-LiteralPath '${escapedPath}'` : `-Path '${escapedPath}'`; |
| 843 | + const recurseFlag = recursive ? ' -Recurse' : ''; |
| 844 | + await this.sendPowerShellCommand(`Remove-Item ${pathParam} -Force${recurseFlag} -ErrorAction Stop`); |
| 845 | +} |
| 846 | + |
| 847 | +export async function executeClickAndDrag(this: NovaWindowsDriver, dragArgs: { |
| 848 | + startElementId?: string, |
| 849 | + startX?: number, |
| 850 | + startY?: number, |
| 851 | + endElementId?: string, |
| 852 | + endX?: number, |
| 853 | + endY?: number, |
| 854 | + modifierKeys?: ('shift' | 'ctrl' | 'alt' | 'win') | ('shift' | 'ctrl' | 'alt' | 'win')[], |
| 855 | + durationMs?: number, |
| 856 | + button?: ClickType, |
| 857 | +}) { |
| 858 | + const { |
| 859 | + startElementId, |
| 860 | + startX, startY, |
| 861 | + endElementId, |
| 862 | + endX, endY, |
| 863 | + modifierKeys = [], |
| 864 | + durationMs = 500, |
| 865 | + button = ClickType.LEFT, |
| 866 | + } = dragArgs ?? {}; |
| 867 | + |
| 868 | + if ((startX != null) !== (startY != null)) { |
| 869 | + throw new errors.InvalidArgumentError('Both startX and startY must be provided if either is set.'); |
| 870 | + } |
| 871 | + |
| 872 | + if ((endX != null) !== (endY != null)) { |
| 873 | + throw new errors.InvalidArgumentError('Both endX and endY must be provided if either is set.'); |
| 874 | + } |
| 875 | + |
| 876 | + const processesModifierKeys = Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys]; |
| 877 | + const clickTypeToButtonMapping: { [key in ClickType]: number } = { |
| 878 | + [ClickType.LEFT]: 0, |
| 879 | + [ClickType.MIDDLE]: 1, |
| 880 | + [ClickType.RIGHT]: 2, |
| 881 | + [ClickType.BACK]: 3, |
| 882 | + [ClickType.FORWARD]: 4, |
| 883 | + }; |
| 884 | + const mouseButton = clickTypeToButtonMapping[button]; |
| 885 | + |
| 886 | + let startPos: [number, number]; |
| 887 | + if (startElementId) { |
| 888 | + if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(startElementId).toString()}`)) { |
| 889 | + const condition = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array(startElementId.split('.').map(Number))); |
| 890 | + const elId = await this.sendPowerShellCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, condition).buildCommand()); |
| 891 | + |
| 892 | + if (elId.trim() === '') { |
| 893 | + throw new errors.NoSuchElementError(); |
| 894 | + } |
| 895 | + } |
| 896 | + |
| 897 | + const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(startElementId).buildGetElementRectCommand()); |
| 898 | + const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; |
| 899 | + startPos = [ |
| 900 | + rect.x + (startX ?? rect.width / 2), |
| 901 | + rect.y + (startY ?? rect.height / 2) |
| 902 | + ]; |
| 903 | + } else { |
| 904 | + if (startX == null || startY == null) { |
| 905 | + throw new errors.InvalidArgumentError('Either startElementId or startX and startY must be provided.'); |
| 906 | + } |
| 907 | + startPos = [startX, startY]; |
| 908 | + } |
| 909 | + |
| 910 | + let endPos: [number, number]; |
| 911 | + if (endElementId) { |
| 912 | + if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(endElementId).toString()}`)) { |
| 913 | + const condition = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array(endElementId.split('.').map(Number))); |
| 914 | + const elId = await this.sendPowerShellCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, condition).buildCommand()); |
| 915 | + |
| 916 | + if (elId.trim() === '') { |
| 917 | + throw new errors.NoSuchElementError(); |
| 918 | + } |
| 919 | + } |
| 920 | + |
| 921 | + const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(endElementId).buildGetElementRectCommand()); |
| 922 | + const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; |
| 923 | + endPos = [ |
| 924 | + rect.x + (endX ?? rect.width / 2), |
| 925 | + rect.y + (endY ?? rect.height / 2) |
| 926 | + ]; |
| 927 | + } else { |
| 928 | + if (endX == null || endY == null) { |
| 929 | + throw new errors.InvalidArgumentError('Either endElementId or endX and endY must be provided.'); |
| 930 | + } |
| 931 | + endPos = [endX, endY]; |
| 932 | + } |
| 933 | + |
| 934 | + await mouseMoveAbsolute(startPos[0], startPos[1], 0); |
| 935 | + |
| 936 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { |
| 937 | + keyDown(Key.CONTROL); |
| 938 | + } |
| 939 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { |
| 940 | + keyDown(Key.ALT); |
| 941 | + } |
| 942 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { |
| 943 | + keyDown(Key.SHIFT); |
| 944 | + } |
| 945 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { |
| 946 | + keyDown(Key.META); |
| 947 | + } |
| 948 | + |
| 949 | + mouseDown(mouseButton); |
| 950 | + await mouseMoveAbsolute(endPos[0], endPos[1], durationMs, this.caps.smoothPointerMove); |
| 951 | + mouseUp(mouseButton); |
| 952 | + |
| 953 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { |
| 954 | + keyUp(Key.CONTROL); |
| 955 | + } |
| 956 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { |
| 957 | + keyUp(Key.ALT); |
| 958 | + } |
| 959 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { |
| 960 | + keyUp(Key.SHIFT); |
| 961 | + } |
| 962 | + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { |
| 963 | + keyUp(Key.META); |
| 964 | + } |
| 965 | +} |
0 commit comments