diff --git a/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx b/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx
index d7a53184cd..025fe4bbb8 100644
--- a/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx
+++ b/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx
@@ -56,52 +56,6 @@ test('render no usb found screen when there is not a valid mounted usb drive', (
}
});
-test('has development shortcut to export file without USB drive', async () => {
- const mockShowSaveDialog = jest
- .fn()
- .mockResolvedValue({ filePath: '/user/batch-export.csv' });
- kiosk.showSaveDialog = mockShowSaveDialog;
-
- const originalEnv: NodeJS.ProcessEnv = { ...process.env };
- process.env = {
- ...originalEnv,
- NODE_ENV: 'development',
- };
-
- const saveFile = jest.fn().mockResolvedValue(ok());
-
- renderInAppContext(
- ,
- {
- usbDriveStatus: mockUsbDriveStatus('no_drive'),
- apiMock,
- }
- );
-
- // TODO: remove when USB status comes from backend. currently, allows
- // component to set the usb drive path in useEffect
- await advancePromises();
-
- userEvent.click(screen.getButton('Save As…'));
- expect(mockShowSaveDialog).toHaveBeenCalledWith({
- defaultPath: 'batch-export.csv',
- });
- await waitFor(() => {
- expect(saveFile).toHaveBeenCalledWith({ path: '/user/batch-export.csv' });
- });
-
- process.env = originalEnv;
-});
-
test('happy usb path - save to default location', async () => {
const saveFile = jest.fn().mockResolvedValue(ok());
@@ -134,47 +88,6 @@ test('happy usb path - save to default location', async () => {
});
});
-test('happy usb path - save as', async () => {
- const mockShowSaveDialog = jest.fn().mockResolvedValue({
- filePath: 'test-mount-point/batch-export.csv',
- });
- kiosk.showSaveDialog = mockShowSaveDialog;
-
- const saveFile = jest.fn().mockResolvedValue(ok());
-
- renderInAppContext(
- ,
- {
- usbDriveStatus: mockUsbDriveStatus('mounted'),
- apiMock,
- }
- );
- await screen.findByText('Save Batch Export');
-
- // TODO: remove when USB status comes from backend. currently, allows
- // component to set the usb drive path in useEffect
- await advancePromises();
-
- userEvent.click(screen.getButton('Save As…'));
- expect(mockShowSaveDialog).toHaveBeenCalledWith({
- defaultPath: 'test-mount-point/batch-export.csv',
- });
- await waitFor(() => {
- expect(saveFile).toHaveBeenCalledWith({
- path: 'test-mount-point/batch-export.csv',
- });
- });
-});
-
test('renders saving modal when mutation is loading', () => {
renderInAppContext(
{
screen.getByText('Batch Export Not Saved');
screen.getByText('Failed to save batch export. Permission denied.');
});
-
-test('can cancel save dialog', async () => {
- const mockShowSaveDialog = jest.fn().mockResolvedValue({ canceled: true });
- kiosk.showSaveDialog = mockShowSaveDialog;
-
- const saveFile = jest.fn().mockResolvedValue(ok());
-
- renderInAppContext(
- ,
- {
- usbDriveStatus: mockUsbDriveStatus('mounted'),
- apiMock,
- }
- );
- await screen.findByText('Save Batch Export');
- // TODO: remove when USB status comes from backend. currently, allows
- // component to set the usb drive path in useEffect
- await advancePromises();
- userEvent.click(screen.getButton('Save As…'));
- expect(mockShowSaveDialog).toHaveBeenCalledWith({
- defaultPath: 'test-mount-point/batch-export.csv',
- });
-
- // because the save dialog is not part of the UI, we cannot wait for its disappearance,
- // but we need to allow the save as button to settle
- await advancePromises();
-
- expect(saveFile).not.toHaveBeenCalled();
- screen.getByText('Save Batch Export');
-});
diff --git a/apps/admin/frontend/src/components/save_backend_file_modal.tsx b/apps/admin/frontend/src/components/save_backend_file_modal.tsx
index 2ed0ba045e..e84767ca67 100644
--- a/apps/admin/frontend/src/components/save_backend_file_modal.tsx
+++ b/apps/admin/frontend/src/components/save_backend_file_modal.tsx
@@ -13,31 +13,6 @@ import { MutationStatus } from '@tanstack/react-query';
import { AppContext } from '../contexts/app_context';
import { Loading } from './loading';
-interface SaveAsButtonProps {
- onSave: (location: string) => void;
- disabled?: boolean;
- options?: KioskBrowser.SaveDialogOptions;
-}
-
-function SaveAsButton({
- onSave,
- disabled,
- options,
-}: SaveAsButtonProps): JSX.Element {
- async function useSaveDialog() {
- assert(window.kiosk);
- const { filePath } = await window.kiosk.showSaveDialog(options);
- if (filePath) {
- onSave(filePath);
- }
- }
- return (
-
- );
-}
-
export interface SaveBackendFileModalProps {
saveFileStatus: MutationStatus;
saveFile: (input: { path: string }) => void;
@@ -90,21 +65,7 @@ export function SaveBackendFileModal({
}
onOverlayClick={onClose}
- actions={
-
- {window.kiosk && process.env.NODE_ENV === 'development' && (
- saveFile({ path })}
- options={{
- // Provide a file name, but allow the system dialog to use its
- // default starting directory.
- defaultPath: basename(defaultRelativePath),
- }}
- />
- )}
-
-
- }
+ actions={}
/>
);
case 'mounted': {
@@ -135,17 +96,6 @@ export function SaveBackendFileModal({
Save
- saveFile({ path })}
- options={{
- // Provide a file name and default to the USB drive's root directory.
- defaultPath: join(
- usbDriveStatus.mountPoint,
- basename(defaultRelativePath)
- ),
- }}
- disabled={!window.kiosk}
- />
}
/>
diff --git a/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx b/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx
index 1dbf53b7ee..3cce9d94f4 100644
--- a/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx
+++ b/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx
@@ -1,5 +1,4 @@
import userEvent from '@testing-library/user-event';
-import { mockKiosk } from '@votingworks/test-utils';
import { err } from '@votingworks/basics';
import { mockUsbDriveStatus } from '@votingworks/ui';
import { renderInAppContext } from '../../test/render_in_app_context';
@@ -40,7 +39,6 @@ test('handles no election packages on USB drive', async () => {
await screen.findByRole('heading', { name: 'Election' });
screen.getByText('No election packages found on the inserted USB drive.');
- screen.getButton('Select Other File...');
});
test('configures from election packages on USB drive', async () => {
@@ -88,27 +86,6 @@ test('configures from election packages on USB drive', async () => {
await waitFor(() => apiMock.assertComplete());
});
-test('configures from selected file', async () => {
- const kiosk = mockKiosk();
- window.kiosk = kiosk;
- kiosk.showOpenDialog.mockResolvedValueOnce({
- canceled: false,
- filePaths: ['/path/to/election-package.zip'],
- });
-
- apiMock.expectListPotentialElectionPackagesOnUsbDrive([]);
- renderInAppContext(, {
- apiMock,
- usbDriveStatus: mockUsbDriveStatus('mounted'),
- });
-
- await screen.findByRole('heading', { name: 'Election' });
-
- apiMock.expectConfigure('/path/to/election-package.zip');
- userEvent.click(screen.getButton('Select Other File...'));
- await waitFor(() => apiMock.assertComplete());
-});
-
test('shows configuration error', async () => {
apiMock.expectListPotentialElectionPackagesOnUsbDrive([
{
diff --git a/apps/admin/frontend/src/screens/unconfigured_screen.tsx b/apps/admin/frontend/src/screens/unconfigured_screen.tsx
index 0758ae6b8b..0cde994ce9 100644
--- a/apps/admin/frontend/src/screens/unconfigured_screen.tsx
+++ b/apps/admin/frontend/src/screens/unconfigured_screen.tsx
@@ -2,7 +2,6 @@ import React, { useContext } from 'react';
import styled from 'styled-components';
import { DateTime } from 'luxon';
import {
- Button,
Card,
FullScreenMessage,
H2,
@@ -11,7 +10,7 @@ import {
UsbDriveImage,
} from '@votingworks/ui';
import type { FileSystemEntry } from '@votingworks/fs';
-import { assertDefined, throwIllegalValue } from '@votingworks/basics';
+import { throwIllegalValue } from '@votingworks/basics';
import { Loading } from '../components/loading';
import { NavigationScreen } from '../components/navigation_screen';
import { configure, listPotentialElectionPackagesOnUsbDrive } from '../api';
@@ -37,18 +36,6 @@ function SelectElectionPackage({
}): JSX.Element {
const configureMutation = configure.useMutation();
- async function onSelectOtherFile() {
- const dialogResult = await assertDefined(window.kiosk).showOpenDialog({
- properties: ['openFile'],
- filters: [{ name: '', extensions: ['zip', 'json'] }],
- });
- if (dialogResult.canceled) return;
- const selectedPath = dialogResult.filePaths[0];
- if (selectedPath) {
- configureMutation.mutate({ electionFilePath: selectedPath });
- }
- }
-
const configureError = configureMutation.data?.err();
return (
@@ -111,14 +98,6 @@ function SelectElectionPackage({
)}
-
-
-
);
diff --git a/libs/@types/kiosk-browser/kiosk-browser.d.ts b/libs/@types/kiosk-browser/kiosk-browser.d.ts
index eb6f12ad50..8e25e19877 100644
--- a/libs/@types/kiosk-browser/kiosk-browser.d.ts
+++ b/libs/@types/kiosk-browser/kiosk-browser.d.ts
@@ -1,87 +1,7 @@
declare namespace KioskBrowser {
- export interface SaveAsOptions {
- title?: string;
- defaultPath?: string;
- buttonLabel?: string;
- filters?: FileFilter[];
- }
-
- // Copied from Electron.OpenDialogOptions
- export interface OpenDialogOptions {
- title?: string;
- defaultPath?: string;
- /**
- * Custom label for the confirmation button, when left empty the default label will
- * be used.
- */
- buttonLabel?: string;
- filters?: FileFilter[];
- /**
- * Contains which features the dialog should use. The following values are
- * supported:
- */
- properties?: Array<
- 'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles'
- >;
- }
-
- // Copied from Electron.SaveDialogOptions, with Linux relevant options only
- export interface SaveDialogOptions {
- /**
- * The dialog title. Cannot be displayed on some `Linux` desktop environments.
- */
- title?: string;
- /**
- * Absolute directory path, absolute file path, or file name to use by default.
- */
- defaultPath?: string;
- /**
- * Custom label for the confirmation button, when left empty the default label will
- * be used.
- */
- buttonLabel?: string;
- filters?: FileFilter[];
- properties?: Array<'showHiddenFiles' | 'showOverwriteConfirmation'>;
- }
-
- export interface FileWriter {
- /**
- * Writes a chunk to the file. May be called multiple times. Data will be
- * written in the order of calls to `write`.
- */
- write(data: Uint8Array | string): Promise;
-
- /**
- * Finishes writing to the file and closes it. Subsequent calls to `write`
- * will fail. Resolves when the file is successfully closed.
- */
- end(): Promise;
-
- filename: string;
- }
-
export interface Kiosk {
log(message: string): Promise;
-
quit(): void;
-
- /**
- * Opens a Save Dialog to allow the user to choose a destination for a file.
- * Once chosen, resolves with a handle to the file to write data to it.
- */
- saveAs(options?: SaveAsOptions): Promise;
-
- showOpenDialog(options?: OpenDialogOptions): Promise<{
- canceled: boolean;
- filePaths: string[];
- }>;
-
- showSaveDialog(options?: SaveDialogOptions): Promise<{
- canceled: boolean;
- filePath?: string;
- }>;
-
- captureScreenshot(): Promise;
}
}
diff --git a/libs/dev-dock/backend/src/dev_dock_api.ts b/libs/dev-dock/backend/src/dev_dock_api.ts
index 8a7a083833..bc1ebfa1fb 100644
--- a/libs/dev-dock/backend/src/dev_dock_api.ts
+++ b/libs/dev-dock/backend/src/dev_dock_api.ts
@@ -1,6 +1,8 @@
import type Express from 'express';
import * as grout from '@votingworks/grout';
import * as fs from 'node:fs';
+import { homedir } from 'node:os';
+import { isAbsolute, join } from 'node:path';
import { Optional, assert } from '@votingworks/basics';
import {
PrinterConfig,
@@ -8,7 +10,6 @@ import {
safeParseElectionDefinition,
UserRole,
} from '@votingworks/types';
-import { isAbsolute, join } from 'node:path';
import {
CardStatus,
readFromMockFile as readFromCardMockFile,
@@ -168,6 +169,14 @@ function buildApi(devDockFilePath: string, machineType: MachineType) {
usbHandler.clearData();
},
+ async captureScreenshot({ appName }: { appName: string }): Promise {
+ assert(/^[a-z0-9]+$/i.test(appName));
+ const downloadsPath = join(homedir(), 'Downloads');
+ const fileName = `Screenshot-${appName}-${new Date().toISOString()}.png`;
+ await execFile('gnome-screenshot', ['-f', join(downloadsPath, fileName)]);
+ return fileName;
+ },
+
getPrinterStatus(): PrinterStatus {
return printerHandler.getPrinterStatus();
},
diff --git a/libs/dev-dock/frontend/package.json b/libs/dev-dock/frontend/package.json
index 9d300c8378..7580c5f5e3 100644
--- a/libs/dev-dock/frontend/package.json
+++ b/libs/dev-dock/frontend/package.json
@@ -36,7 +36,6 @@
"@testing-library/user-event": "^13.5.0",
"@types/express": "4.17.14",
"@types/jest": "^29.5.3",
- "@types/kiosk-browser": "workspace:*",
"@types/node": "20.16.0",
"@types/react": "18.3.3",
"@types/styled-components": "^5.1.26",
diff --git a/libs/dev-dock/frontend/src/dev_dock.test.tsx b/libs/dev-dock/frontend/src/dev_dock.test.tsx
index 305dcd12b1..3fe2441b7e 100644
--- a/libs/dev-dock/frontend/src/dev_dock.test.tsx
+++ b/libs/dev-dock/frontend/src/dev_dock.test.tsx
@@ -1,5 +1,4 @@
import { render, screen, waitFor, within } from '@testing-library/react';
-import { Buffer } from 'node:buffer';
import userEvent from '@testing-library/user-event';
import { createMockClient, MockClient } from '@votingworks/grout-test-utils';
import type { Api } from '@votingworks/dev-dock-backend';
@@ -11,8 +10,6 @@ import {
mockSystemAdministratorUser,
mockElectionManagerUser,
mockPollWorkerUser,
- mockKiosk,
- mockFileWriter,
} from '@votingworks/test-utils';
import { CardStatus } from '@votingworks/auth';
import { DevDock } from './dev_dock';
@@ -48,7 +45,6 @@ jest.mock('@votingworks/utils', () => ({
}));
let mockApiClient: MockClient;
-let kiosk!: jest.Mocked;
beforeEach(() => {
mockApiClient = createMockClient();
@@ -82,8 +78,6 @@ beforeEach(() => {
featureFlagMock.enableFeatureFlag(
BooleanEnvironmentVariableName.USE_MOCK_USB_DRIVE
);
- kiosk = mockKiosk();
- window.kiosk = kiosk;
});
afterEach(() => {
@@ -293,16 +287,18 @@ test('screenshot button', async () => {
name: 'Capture Screenshot',
});
- const screenshotBuffer = Buffer.of();
- const fileWriter = mockFileWriter();
- kiosk.captureScreenshot.mockResolvedValueOnce(screenshotBuffer);
- kiosk.saveAs.mockResolvedValueOnce(fileWriter);
+ jest.spyOn(window, 'alert').mockImplementation(() => {});
+ document.title = 'VotingWorks VxAdmin';
+ mockApiClient.captureScreenshot
+ .expectCallWith({ appName: 'VxAdmin' })
+ .resolves('Screenshot-VxAdmin-2024-11-25-00:00:00.000Z.png');
userEvent.click(screenshotButton);
await waitFor(() => {
- expect(kiosk.captureScreenshot).toHaveBeenCalled();
- expect(kiosk.saveAs).toHaveBeenCalled();
- expect(fileWriter.write).toHaveBeenCalledWith(screenshotBuffer);
+ mockApiClient.assertComplete();
+ expect(window.alert).toHaveBeenCalledWith(
+ 'Screenshot saved as Screenshot-VxAdmin-2024-11-25-00:00:00.000Z.png in the Downloads folder.'
+ );
});
});
diff --git a/libs/dev-dock/frontend/src/dev_dock.tsx b/libs/dev-dock/frontend/src/dev_dock.tsx
index 4b87c293c4..e46f261b35 100644
--- a/libs/dev-dock/frontend/src/dev_dock.tsx
+++ b/libs/dev-dock/frontend/src/dev_dock.tsx
@@ -11,7 +11,6 @@ import styled from 'styled-components';
import * as grout from '@votingworks/grout';
import {
assert,
- assertDefined,
sleep,
throwIllegalValue,
uniqueBy,
@@ -83,22 +82,11 @@ function ElectionControl(): JSX.Element | null {
const selectedElection = getElectionQuery.data;
- async function onSelectElection(
+ function onSelectElection(
event: React.ChangeEvent
) {
const path = event.target.value;
- if (path === 'Pick from file...') {
- const dialogResult = await assertDefined(window.kiosk).showOpenDialog({
- properties: ['openFile'],
- });
- if (dialogResult.canceled) return;
- const selectedPath = dialogResult.filePaths[0];
- if (selectedPath) {
- setElectionMutation.mutate({ path: selectedPath });
- }
- } else {
- setElectionMutation.mutate({ path });
- }
+ setElectionMutation.mutate({ path });
}
const elections = uniqueBy(
@@ -116,7 +104,6 @@ function ElectionControl(): JSX.Element | null {
{election.title} - {election.path}
))}
- {window.kiosk && }
);
}
@@ -406,6 +393,9 @@ function ScreenshotControls({
}: {
containerRef: RefObject;
}) {
+ const apiClient = useApiClient();
+ const captureScreenshotMutation = useMutation(apiClient.captureScreenshot);
+
async function captureScreenshot() {
// Use a ref to the dock container to momentarily hide it during the
// screenshot.
@@ -414,25 +404,22 @@ function ScreenshotControls({
containerRef.current.style.visibility = 'hidden';
await sleep(500);
- assert(window.kiosk);
- const screenshotData = await window.kiosk.captureScreenshot();
+ // "VotingWorks VxAdmin" -> "VxAdmin"
+ const appName = document.title.replace('VotingWorks', '').trim();
+ const fileName = await captureScreenshotMutation.mutateAsync({ appName });
// eslint-disable-next-line no-param-reassign
containerRef.current.style.visibility = 'visible';
- // "VotingWorks VxAdmin" -> "VxAdmin"
- const appName = document.title.replace('VotingWorks', '').trim();
- const fileName = `Screenshot-${appName}-${new Date().toISOString()}.png`;
- const saveFile = await window.kiosk.saveAs({
- defaultPath: fileName,
- });
- await saveFile?.write(screenshotData);
+ if (fileName) {
+ // eslint-disable-next-line no-alert
+ alert(`Screenshot saved as ${fileName} in the Downloads folder.`);
+ }
}
return (
diff --git a/libs/test-utils/src/index.ts b/libs/test-utils/src/index.ts
index 7a71a18d83..11b6a1bee0 100644
--- a/libs/test-utils/src/index.ts
+++ b/libs/test-utils/src/index.ts
@@ -5,7 +5,6 @@ export * from './backend_wait_for';
export * from './child_process';
export * from './compressed_tallies';
export * from './console';
-export * from './mock_file_writer';
export * from './mock_kiosk';
export * from './mock_use_audio_controls';
export * from './has_text_across_elements';
diff --git a/libs/test-utils/src/mock_file_writer.ts b/libs/test-utils/src/mock_file_writer.ts
deleted file mode 100644
index b1512db101..0000000000
--- a/libs/test-utils/src/mock_file_writer.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export type Chunk = Parameters[0];
-
-export interface MockFileWriter extends KioskBrowser.FileWriter {
- chunks: readonly Chunk[];
-}
-
-export function mockFileWriter(): jest.Mocked {
- const chunks: Chunk[] = [];
-
- return {
- filename: '/mock/file',
- write: jest.fn().mockImplementation((chunk) => {
- chunks.push(chunk);
- }),
- end: jest.fn().mockResolvedValue(undefined),
- chunks,
- };
-}
diff --git a/libs/test-utils/src/mock_kiosk.ts b/libs/test-utils/src/mock_kiosk.ts
index 2fb3f37868..1f58c23819 100644
--- a/libs/test-utils/src/mock_kiosk.ts
+++ b/libs/test-utils/src/mock_kiosk.ts
@@ -1,5 +1,3 @@
-import { Buffer } from 'node:buffer';
-
export type MockKiosk = jest.Mocked;
/**
@@ -8,10 +6,6 @@ export type MockKiosk = jest.Mocked;
export function mockKiosk(): MockKiosk {
return {
quit: jest.fn(),
- saveAs: jest.fn().mockResolvedValue(undefined),
log: jest.fn(),
- captureScreenshot: jest.fn().mockResolvedValue(Buffer.of()),
- showOpenDialog: jest.fn(),
- showSaveDialog: jest.fn(),
};
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3735161a10..53671a5feb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3844,9 +3844,6 @@ importers:
'@types/jest':
specifier: ^29.5.3
version: 29.5.3
- '@types/kiosk-browser':
- specifier: workspace:*
- version: link:../../@types/kiosk-browser
'@types/node':
specifier: 20.16.0
version: 20.16.0