Skip to content

feat(app, app-shell) : Create the first step of password protection for file access #19104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: edge
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
36 changes: 35 additions & 1 deletion app-shell/src/config/actions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// app-shell/src/config/actions.ts
import {
ADD_CUSTOM_LABWARE,
ADD_CUSTOM_LABWARE_FAILURE,
Expand Down Expand Up @@ -42,6 +43,9 @@ import {
USB_HTTP_REQUESTS_STOP,
VALUE_UPDATED,
VIEW_PROTOCOL_SOURCE_FOLDER,
LOCK_PROTOCOL,
UNLOCK_PROTOCOL,
VERIFY_PROTOCOL_PASSWORD,
} from '../constants'

import type {
Expand Down Expand Up @@ -73,13 +77,16 @@ import type {
AnalyzeProtocolSuccessAction,
ClearAddProtocolFailureAction,
FetchProtocolsAction,
LockProtocolAction,
OpenProtocolDirectoryAction,
ProtocolListActionSource,
RemoveProtocolAction,
StoredProtocolData,
StoredProtocolDir,
UnlockProtocolAction,
UpdateProtocolListAction,
UpdateProtocolListFailureAction,
VerifyProtocolPasswordAction,
ViewProtocolSourceFolder,
} from '@opentrons/app/src/redux/protocol-storage'
import type {
Expand Down Expand Up @@ -307,6 +314,33 @@ export const viewProtocolSourceFolder = (
meta: { shell: true },
})

export const lockProtocol = (
protocolKey: string,
password: string
): LockProtocolAction => ({
type: LOCK_PROTOCOL,
payload: { protocolKey, password },
meta: { shell: true },
})

export const unlockProtocol = (
protocolKey: string,
password: string
): UnlockProtocolAction => ({
type: UNLOCK_PROTOCOL,
payload: { protocolKey, password },
meta: { shell: true },
})

export const verifyProtocolPassword = (
protocolKey: string,
password: string
): VerifyProtocolPasswordAction => ({
type: VERIFY_PROTOCOL_PASSWORD,
payload: { protocolKey, password },
meta: { shell: true },
})

export const initialized = (
usbDevices: UsbDevice[],
networkInterfaces: NetworkInterface[]
Expand Down Expand Up @@ -421,4 +455,4 @@ export const notifySubscribeAction = (
topic,
},
meta: { shell: true },
})
})
11 changes: 11 additions & 0 deletions app-shell/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ export const DISCOVERY_UPDATE_LIST: DISCOVERY_UPDATE_LIST_TYPE =

export const DISCOVERY_REMOVE: DISCOVERY_REMOVE_TYPE = 'discovery:REMOVE'


// Skunkworks constants for protocol locking
export const LOCK_PROTOCOL: 'protocolStorage:LOCK_PROTOCOL' =
'protocolStorage:LOCK_PROTOCOL'

export const UNLOCK_PROTOCOL: 'protocolStorage:UNLOCK_PROTOCOL' =
'protocolStorage:UNLOCK_PROTOCOL'

export const VERIFY_PROTOCOL_PASSWORD: 'protocolStorage:VERIFY_PROTOCOL_PASSWORD' =
'protocolStorage:VERIFY_PROTOCOL_PASSWORD'
// Back to the app-shell constants
export const CLEAR_CACHE: CLEAR_CACHE_TYPE = 'discovery:CLEAR_CACHE'
export const HEALTH_STATUS_OK: 'ok' = 'ok'
export const HEALTH_STATUS_NOT_OK: 'notOk' = 'notOk'
Expand Down
64 changes: 64 additions & 0 deletions app-shell/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import path from 'path'
import { app } from 'electron'
import Knex from 'knex'
import type { Knex as KnexType } from 'knex'

const DB_FILE_NAME = 'opentrons.db'
const DB_PATH = path.join(app.getPath('userData'), DB_FILE_NAME)

// Define a type for a single migration object
interface Migration {
name: string
up: (db: KnexType) => Promise<void>
down: (db: KnexType) => Promise<void>
}

const MIGRATIONS: Migration[] = [
{
name: '2025_08_01_add_protocol_locks_table',
up: async (db: KnexType): Promise<void> => {
await db.schema.createTable('protocolLocks', table => {
table.string('protocolKey').primary()
table.boolean('isLocked').notNullable().defaultTo(false)
table.string('passwordHash').nullable()
})
},
down: async (db: KnexType): Promise<void> => {
await db.schema.dropTableIfExists('protocolLocks')
},
},
]

class CustomMigrationSource {
getMigrations(): Promise<Migration[]> {
return Promise.resolve(MIGRATIONS)
}

getMigrationName(migration: Migration): string {
return migration.name
}

// This method now correctly returns a Promise
getMigration(migration: Migration): Promise<Migration> {
return Promise.resolve(migration)
}
}

const knexConfig: KnexType.Config = {
client: 'sqlite3',
connection: { filename: DB_PATH },
useNullAsDefault: true,
migrations: {
migrationSource: new CustomMigrationSource(),
},
}

const db = Knex(knexConfig)

export function getDb(): KnexType {
return db
}

export function initDb(): Promise<unknown> {
return db.migrate.latest()
}
139 changes: 63 additions & 76 deletions app-shell/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import electronDebug from 'electron-debug'
import * as electronDevtoolsInstaller from 'electron-devtools-installer'

import { getConfig, getOverrides, getStore, registerConfig } from './config'
import * as db from './db'
import { registerDiscovery } from './discovery'
import { registerLabware } from './labware'
import { createLogger } from './log'
Expand All @@ -23,13 +24,7 @@ import type { BrowserWindow } from 'electron'
import type { LogEntry } from 'winston'
import type { Action, Dispatch, Logger } from './types'

/**
* node 17 introduced a change to default IP resolving to prefer IPv6 which causes localhost requests to fail
* setting the default to IPv4 fixes the issue
* https://github.com/node-fetch/node-fetch/issues/1624
*/
dns.setDefaultResultOrder('ipv4first')

const config = getConfig()
const log = createLogger('main')

Expand All @@ -40,23 +35,19 @@ log.debug('App config', {
})

if (config.devtools) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
electronDebug({ isEnabled: true, showDevTools: true })
}

// hold on to references so they don't get garbage collected
let mainWindow: BrowserWindow | null | undefined
let rendererLogger: Logger

// prepended listener is important here to work around Electron issue
// https://github.com/electron/electron/issues/19468#issuecomment-623529556
app.prependOnceListener('ready', startUp)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
if (config.devtools) app.once('ready', installDevtools)
if (config.devtools) {
void installDevtools()
}

app.once('window-all-closed', () => {
log.debug('all windows closed, quitting the app')
app.quit()
closeAllNotifyConnections()
.then(() => {
app.quit()
Expand All @@ -74,93 +65,89 @@ function startUp(): void {
log.error('Uncaught Promise rejection: ', { reason })
)

mainWindow = createUi()
rendererLogger = createRendererLogger()

mainWindow.once('closed', () => (mainWindow = null))
db.initDb()
.then(() => {
log.info('Database initialized successfully')

contextMenu({
menu: actions => {
return config.devtools
? [actions.copy({}), actions.searchWithGoogle({}), actions.inspect()]
: [actions.copy({}), actions.searchWithGoogle({})]
},
})
mainWindow = createUi()
rendererLogger = createRendererLogger()

initializeMenu()
mainWindow.once('closed', () => (mainWindow = null))

// wire modules to UI dispatches
const dispatch: Dispatch = action => {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (mainWindow) {
log.silly('Sending action via IPC to renderer', { action })
mainWindow.webContents.send('dispatch', action)
}
}
contextMenu({
menu: actions => {
return config.devtools
? [actions.copy({}), actions.searchWithGoogle({}), actions.inspect()]
: [actions.copy({}), actions.searchWithGoogle({})]
},
})

const actionHandlers: Dispatch[] = [
registerConfig(dispatch),
registerDiscovery(dispatch),
registerProtocolAnalysis(dispatch, mainWindow),
registerUpdate(dispatch),
registerRobotUpdate(dispatch),
registerLabware(dispatch, mainWindow),
registerSystemInfo(dispatch),
registerProtocolStorage(dispatch),
registerUsb(dispatch),
registerNotify(dispatch, mainWindow),
registerReloadUi(mainWindow),
registerSystemLanguage(dispatch),
]
initializeMenu()

const dispatch: Dispatch = action => {
if (mainWindow) {
log.silly('Sending action via IPC to renderer', { action })
mainWindow.webContents.send('dispatch', action)
}
}

const actionHandlers: Dispatch[] = [
registerConfig(dispatch),
registerDiscovery(dispatch),
registerProtocolAnalysis(dispatch, mainWindow),
registerUpdate(dispatch),
registerRobotUpdate(dispatch),
registerLabware(dispatch, mainWindow),
registerSystemInfo(dispatch),
registerProtocolStorage(dispatch),
registerUsb(dispatch),
registerNotify(dispatch, mainWindow),
registerReloadUi(mainWindow),
registerSystemLanguage(dispatch),
]

ipcMain.on('dispatch', (_, action) => {
log.debug('Received action via IPC from renderer', { action })
actionHandlers.forEach(handler => {
handler(action as Action)
})
})

ipcMain.on('dispatch', (_, action) => {
log.debug('Received action via IPC from renderer', { action })
actionHandlers.forEach(handler => {
handler(action as Action)
log.silly('Global references', { mainWindow, rendererLogger })
})
.catch((error: Error) => {
log.error('Error initializing database', { error })
app.quit()
})
})

log.silly('Global references', { mainWindow, rendererLogger })
}

function createRendererLogger(): Logger {
log.info('Creating renderer logger')

const logger = createLogger('renderer')
ipcMain.on('log', (_, info) => logger.log(info as LogEntry))

return logger
}

function installDevtools(): Promise<Logger> {
async function installDevtools(): Promise<void> {
const extensions = [
electronDevtoolsInstaller.REACT_DEVELOPER_TOOLS,
electronDevtoolsInstaller.REDUX_DEVTOOLS,
]
// @ts-expect-error the types for electron-devtools-installer are not correct
// when importing the default export via commmon JS. the installer is actually nested in
// another default object
const install = electronDevtoolsInstaller.default?.default
const install = electronDevtoolsInstaller.default
const forceReinstall = config.reinstallDevtools

log.debug('Installing devtools')

if (typeof install === 'function') {
return install(extensions, {
try {
await install(extensions, {
loadExtensionOptions: { allowFileAccess: true },
forceDownload: forceReinstall,
})
.then(() => log.debug('Devtools extensions installed'))
.catch((error: unknown) => {
log.warn('Failed to install devtools extensions', {
forceReinstall,
error,
})
})
} else {
log.warn('could not resolve electron dev tools installer')
return Promise.reject(
new Error('could not resolve electron dev tools installer')
)
log.debug('Devtools extensions installed')
} catch (error: unknown) {
log.warn('Failed to install devtools extensions', {
forceReinstall,
error,
})
}
}
}
20 changes: 20 additions & 0 deletions app-shell/src/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Knex } from 'knex'

// This array holds all the database schema changes.
// The app's startup logic will run any new migrations from this list.
export const MIGRATIONS = [
// NOTE: You may have other migration objects here. Add this one to the end.
{
name: '2025_08_01_add_protocol_locks_table',
up: async (db: Knex): Promise<void> => {
await db.schema.createTable('protocolLocks', table => {
table.string('protocolKey').primary()
table.boolean('isLocked').notNullable().defaultTo(false)
table.string('passwordHash').nullable()
})
},
down: async (db: Knex): Promise<void> => {
await db.schema.dropTableIfExists('protocolLocks')
},
},
]
Loading
Loading