Skip to content

Commit e379ef5

Browse files
committed
feat(lock): implement application lock functionality with IPC integration
1 parent d614c2c commit e379ef5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1380
-20
lines changed

.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,8 @@ insert_final_newline = true
1212
indent_style = space
1313
indent_size = 2
1414

15+
[*.json]
16+
insert_final_newline = false
17+
1518
[*.md]
1619
trim_trailing_whitespace = false

rollup.config.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,40 @@ export default [
108108
},
109109
],
110110
},
111+
{
112+
// Lock screen renderer bundle
113+
external: [
114+
...builtinModules,
115+
...Object.keys(appManifest.dependencies),
116+
...Object.keys(appManifest.devDependencies),
117+
].filter((moduleName) => moduleName !== '@bugsnag/js'),
118+
input: 'src/lockScreen/lock-screen.tsx',
119+
preserveEntrySignatures: 'strict',
120+
plugins: [
121+
json(),
122+
replace({
123+
'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
124+
'preventAssignment': true,
125+
}),
126+
babel({
127+
babelHelpers: 'bundled',
128+
extensions,
129+
}),
130+
nodeResolve({
131+
browser: true,
132+
extensions,
133+
}),
134+
commonjs(),
135+
],
136+
output: [
137+
{
138+
dir: 'app',
139+
format: 'cjs',
140+
sourcemap: 'inline',
141+
interop: 'auto',
142+
},
143+
],
144+
},
111145
{
112146
external: [
113147
...builtinModules,

src/app/PersistableValues.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,15 @@ type PersistableValues_4_9_0 = PersistableValues_4_7_2 & {
9191
isVideoCallScreenCaptureFallbackEnabled: boolean;
9292
};
9393

94+
// Add screen lock persisted settings: timeout (seconds) and passwordHash (sha256 hex)
95+
type PersistableValues_5_0_0 = PersistableValues_4_9_0 & {
96+
screenLockTimeoutSeconds: number; // 0 = disabled
97+
screenLockPasswordHash: string | null; // sha256 hex
98+
};
99+
94100
export type PersistableValues = Pick<
95-
PersistableValues_4_9_0,
96-
keyof PersistableValues_4_9_0
101+
PersistableValues_5_0_0,
102+
keyof PersistableValues_5_0_0
97103
>;
98104

99105
export const migrations = {
@@ -171,4 +177,10 @@ export const migrations = {
171177
...before,
172178
isVideoCallScreenCaptureFallbackEnabled: false,
173179
}),
180+
// New migration for screen lock defaults
181+
'>=5.0.0': (before: PersistableValues_4_9_0): PersistableValues_5_0_0 => ({
182+
...before,
183+
screenLockTimeoutSeconds: 0,
184+
screenLockPasswordHash: null,
185+
}),
174186
};

src/app/main/app.ts

Lines changed: 251 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { app, session, BrowserWindow } from 'electron';
1+
/* eslint-disable import/order */
2+
import path from 'path';
3+
import crypto from 'crypto';
4+
25
import { rimraf } from 'rimraf';
6+
import { app, session, BrowserWindow, ipcMain, BrowserView } from 'electron';
37

48
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
59
// @ts-ignore:next-line
@@ -12,22 +16,26 @@ import packageJson from '../../../package.json';
1216
import { JITSI_SERVER_CAPTURE_SCREEN_PERMISSIONS_CLEARED } from '../../jitsi/actions';
1317
import { dispatch, listen } from '../../store';
1418
import { readSetting } from '../../store/readSetting';
19+
import {
20+
APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET,
21+
APP_MAIN_WINDOW_TITLE_SET,
22+
APP_PATH_SET,
23+
APP_VERSION_SET,
24+
APP_SCREEN_CAPTURE_FALLBACK_FORCED_SET,
25+
} from '../actions';
1526
import {
1627
SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS,
1728
SETTINGS_NTLM_CREDENTIALS_CHANGED,
1829
SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED,
1930
SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED,
31+
SETTINGS_SET_SCREEN_LOCK_PASSWORD_CHANGED,
32+
SETTINGS_SET_SCREEN_LOCK_PASSWORD_HASHED,
33+
MENU_BAR_LOCK_SCREEN_CLICKED,
2034
} from '../../ui/actions';
2135
import { askForClearScreenCapturePermission } from '../../ui/main/dialogs';
2236
import { getRootWindow } from '../../ui/main/rootWindow';
2337
import { preloadBrowsersList } from '../../utils/browserLauncher';
24-
import {
25-
APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET,
26-
APP_MAIN_WINDOW_TITLE_SET,
27-
APP_PATH_SET,
28-
APP_VERSION_SET,
29-
APP_SCREEN_CAPTURE_FALLBACK_FORCED_SET,
30-
} from '../actions';
38+
import { getPersistedValues } from './persistence';
3139

3240
export const packageJsonInformation = {
3341
productName: packageJson.productName,
@@ -39,8 +47,6 @@ export const electronBuilderJsonInformation = {
3947
protocol: electronBuilderJson.protocols.schemes[0],
4048
};
4149

42-
let isScreenCaptureFallbackForced = false;
43-
4450
export const getPlatformName = (): string => {
4551
switch (process.platform) {
4652
case 'win32':
@@ -54,6 +60,215 @@ export const getPlatformName = (): string => {
5460
}
5561
};
5662

63+
let isScreenCaptureFallbackForced = false;
64+
65+
let lockWindow: BrowserView | null = null;
66+
67+
// Track original rootWindow state while locked so we can restore it on unlock
68+
const lockState: {
69+
originalBounds?: Electron.Rectangle;
70+
prevResizable?: boolean;
71+
prevMinimizable?: boolean;
72+
prevMaximizable?: boolean;
73+
moveListener?: () => void;
74+
resizeListener?: () => void;
75+
} = {};
76+
77+
const showLockWindow = async (): Promise<void> => {
78+
try {
79+
const persisted = getPersistedValues();
80+
if (!persisted?.screenLockPasswordHash) {
81+
console.log('No screen lock password configured; skipping lock overlay');
82+
return;
83+
}
84+
85+
const rootWindow = await getRootWindow();
86+
87+
if (lockWindow && !lockWindow.webContents.isDestroyed()) {
88+
return;
89+
}
90+
91+
// Save current window flags and bounds so we can restore them
92+
try {
93+
lockState.originalBounds = rootWindow.getBounds();
94+
lockState.prevResizable = rootWindow.isResizable();
95+
lockState.prevMinimizable = rootWindow.isMinimizable();
96+
lockState.prevMaximizable = rootWindow.isMaximizable();
97+
// Capture movability if available on the platform
98+
try {
99+
if (typeof (rootWindow as any).isMovable === 'function') {
100+
(lockState as any).prevMovable = (rootWindow as any).isMovable();
101+
}
102+
} catch (e) {
103+
// ignore
104+
}
105+
} catch (e) {
106+
// if anything fails while saving/enforcing, continue — locking view still provides protection over content
107+
}
108+
109+
// Create a BrowserView and attach it to the root window so the lock screen
110+
// appears inside the application window (not above other apps) and covers
111+
// the entire content area.
112+
lockWindow = new BrowserView({
113+
webPreferences: {
114+
nodeIntegration: true,
115+
contextIsolation: false,
116+
},
117+
});
118+
119+
rootWindow.setBrowserView(lockWindow);
120+
121+
const updateBounds = () => {
122+
try {
123+
const { width, height } = rootWindow.getContentBounds();
124+
lockWindow?.setBounds({ x: 0, y: 0, width, height });
125+
} catch (e) {
126+
/* ignore errors while updating bounds */
127+
}
128+
};
129+
130+
// ensure the view resizes with the window
131+
lockWindow.setAutoResize({ width: true, height: true });
132+
updateBounds();
133+
134+
// keep bounds updated on move/resize
135+
rootWindow.addListener('resize', updateBounds);
136+
rootWindow.addListener('move', updateBounds);
137+
138+
lockWindow.webContents.loadFile(
139+
path.join(app.getAppPath(), 'app/lockScreen.html')
140+
);
141+
142+
lockWindow.webContents.once('did-finish-load', async () => {
143+
try {
144+
await lockWindow?.webContents.executeJavaScript(`
145+
window.electronAPI = {
146+
verifyPassword: (password) => require('electron').ipcRenderer.invoke('lock:verify', password),
147+
unlockApp: () => require('electron').ipcRenderer.invoke('lock:unlock')
148+
};
149+
null;
150+
`);
151+
} catch (e) {
152+
console.warn('Failed to inject electronAPI into lock view', e);
153+
}
154+
155+
lockWindow?.webContents.focus();
156+
});
157+
158+
// cleanup when view is destroyed
159+
lockWindow.webContents.once('destroyed', () => {
160+
try {
161+
rootWindow.removeBrowserView(lockWindow as BrowserView);
162+
} catch (e) {
163+
// ignore
164+
}
165+
lockWindow = null;
166+
167+
// restore window flags and remove listeners
168+
try {
169+
if (lockState.moveListener) {
170+
rootWindow.removeListener('move', lockState.moveListener);
171+
}
172+
if (lockState.resizeListener) {
173+
rootWindow.removeListener('resize', lockState.resizeListener);
174+
}
175+
176+
// remove the updateBounds listeners we added earlier
177+
rootWindow.removeListener('resize', updateBounds);
178+
rootWindow.removeListener('move', updateBounds);
179+
180+
if (typeof lockState.prevResizable === 'boolean') {
181+
rootWindow.setResizable(!!lockState.prevResizable);
182+
}
183+
if (typeof lockState.prevMinimizable === 'boolean') {
184+
rootWindow.setMinimizable(!!lockState.prevMinimizable);
185+
}
186+
if (typeof lockState.prevMaximizable === 'boolean') {
187+
rootWindow.setMaximizable(!!lockState.prevMaximizable);
188+
}
189+
if (
190+
typeof rootWindow.setMovable === 'function' &&
191+
typeof (lockState as any).prevMovable !== 'undefined'
192+
) {
193+
rootWindow.setMovable(!!(lockState as any).prevMovable);
194+
}
195+
} catch (e) {
196+
// ignore
197+
}
198+
199+
// clear stored state
200+
lockState.originalBounds = undefined;
201+
lockState.prevResizable = undefined;
202+
lockState.prevMinimizable = undefined;
203+
lockState.prevMaximizable = undefined;
204+
lockState.moveListener = undefined;
205+
lockState.resizeListener = undefined;
206+
});
207+
} catch (error) {
208+
console.error('Error showing lock window:', error);
209+
}
210+
};
211+
212+
// Register lock-related IPC handlers. This is called from `setupApp` after Electron is ready
213+
export const registerLockIpcHandlers = (): void => {
214+
if (!ipcMain || typeof ipcMain.handle !== 'function') {
215+
// eslint-disable-next-line no-console
216+
console.warn(
217+
'ipcMain is not available; lock IPC handlers will not be registered.'
218+
);
219+
return;
220+
}
221+
222+
ipcMain.handle('lock:verify', async (_event, password: string) => {
223+
try {
224+
const persisted = getPersistedValues();
225+
const storedHash = persisted?.screenLockPasswordHash ?? null;
226+
if (!storedHash) {
227+
return false;
228+
}
229+
230+
const hash = crypto
231+
.createHash('sha256')
232+
.update(String(password))
233+
.digest('hex');
234+
235+
return hash === storedHash;
236+
} catch (error) {
237+
console.error('Error verifying lock password:', error);
238+
return false;
239+
}
240+
});
241+
242+
ipcMain.handle('lock:unlock', async () => {
243+
try {
244+
if (lockWindow && !lockWindow.webContents.isDestroyed()) {
245+
try {
246+
const rootWindow = await getRootWindow();
247+
rootWindow.removeBrowserView(lockWindow);
248+
} catch (e) {
249+
// ignore
250+
}
251+
try {
252+
// WebContents.destroy may not be in the TypeScript definitions, cast to any
253+
(lockWindow.webContents as any).destroy?.();
254+
} catch (e) {
255+
// ignore
256+
}
257+
lockWindow = null;
258+
}
259+
const rootWindow = await getRootWindow();
260+
if (rootWindow && !rootWindow.isDestroyed()) {
261+
rootWindow.show();
262+
rootWindow.focus();
263+
}
264+
return true;
265+
} catch (error) {
266+
console.error('Error unlocking app:', error);
267+
return false;
268+
}
269+
});
270+
};
271+
57272
export const relaunchApp = (...args: string[]): void => {
58273
const command = process.argv.slice(1, app.isPackaged ? 1 : 2);
59274
app.relaunch({ args: [...command, ...args] });
@@ -171,7 +386,11 @@ export const setupApp = (): void => {
171386
}, 100); // Brief delay to let window state stabilize
172387
});
173388

174-
app.whenReady().then(() => preloadBrowsersList());
389+
// Register IPC handlers and other readiness tasks once Electron is ready
390+
app.whenReady().then(() => {
391+
preloadBrowsersList();
392+
registerLockIpcHandlers();
393+
});
175394

176395
listen(SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED, () => {
177396
relaunchApp();
@@ -199,6 +418,27 @@ export const setupApp = (): void => {
199418
}
200419
);
201420

421+
// Hash and persist screen lock password when renderer sends plaintext
422+
listen(SETTINGS_SET_SCREEN_LOCK_PASSWORD_CHANGED, (action) => {
423+
try {
424+
const plain = action.payload || '';
425+
const hash = plain
426+
? crypto.createHash('sha256').update(String(plain)).digest('hex')
427+
: null;
428+
dispatch({
429+
type: SETTINGS_SET_SCREEN_LOCK_PASSWORD_HASHED,
430+
payload: hash,
431+
});
432+
} catch (error) {
433+
console.error('Error hashing screen lock password:', error);
434+
}
435+
});
436+
437+
// Show lock overlay when menu item clicked
438+
listen(MENU_BAR_LOCK_SCREEN_CLICKED, async () => {
439+
await showLockWindow();
440+
});
441+
202442
listen(APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET, (action) => {
203443
if (action.payload.length > 0) {
204444
session.defaultSession.allowNTLMCredentialsForDomains(action.payload);

0 commit comments

Comments
 (0)