open screen progress

This commit is contained in:
antelle 2021-05-29 16:16:16 +02:00
parent caa17a48a8
commit 40dd3637d7
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
26 changed files with 657 additions and 499 deletions

View File

@ -28,6 +28,7 @@ import { FileManager } from 'models/file-manager';
import { Updater } from './comp/app/updater';
import { Timeouts } from 'const/timeouts';
import { PluginManager } from 'plugins/plugin-manager';
import { Workspace } from 'models/workspace';
declare global {
interface Window {
@ -168,6 +169,8 @@ async function bootstrap() {
}
function showView() {
Workspace.showOpen();
const root = document.querySelector('.app');
if (root) {
const app = h(App, null);

View File

@ -106,6 +106,14 @@ class FileManager extends Model<FileManagerEvents> {
this.files = this.files.filter((f) => f !== file);
}
getFirstFileInfoToOpen(): FileInfo | undefined {
for (const fi of this.fileInfos) {
if (!this.getFileById(fi.id)) {
return fi;
}
}
}
private fileClosed(file: File) {
if (file.storage === 'file' && file.path) {
Storage.file.unwatch(file.path);

View File

@ -1,22 +1,229 @@
import * as kdbxweb from 'kdbxweb';
import { Model } from 'util/model';
import { StorageFileOptions } from 'storage/types';
import { FileChalRespConfig } from 'models/file-info';
import { FileChalRespConfig, FileInfo } from 'models/file-info';
import { FileOpener } from 'util/browser/file-opener';
import { AppSettings } from 'models/app-settings';
import { Alerts } from 'comp/ui/alerts';
import { Locale } from 'util/locale';
import { FileManager } from 'models/file-manager';
import { DropboxChooser } from 'storage/dropbox-chooser';
export class OpenState extends Model {
id?: string;
name?: string;
password?: kdbxweb.ProtectedValue;
id?: string;
storage?: string;
path?: string;
fileData?: ArrayBuffer;
fileXml?: string;
keyFileName?: string;
keyFileData?: Uint8Array;
keyFileData?: ArrayBuffer;
keyFileHash?: string;
keyFilePath?: string;
fileData?: Uint8Array;
rev?: string;
opts?: StorageFileOptions;
chalResp?: FileChalRespConfig;
busy = false;
secondRowVisible = false;
autoFocusPassword = true;
capsLockPressed = false;
visualFocus = false;
constructor() {
super();
const fileInfo = FileManager.getFirstFileInfoToOpen();
if (fileInfo) {
this.selectFileInfo(fileInfo);
}
}
selectFileInfo(fileInfo: FileInfo): void {
if (this.busy) {
return;
}
this.batchSet(() => {
this.resetInternal();
this.id = fileInfo.id;
this.name = fileInfo.name;
this.storage = fileInfo.storage;
this.path = fileInfo.path;
this.keyFileName = fileInfo.keyFileName;
this.keyFileHash = fileInfo.keyFileHash;
this.keyFilePath = fileInfo.keyFilePath;
this.rev = fileInfo.rev;
this.opts = fileInfo.opts;
this.chalResp = fileInfo.chalResp;
});
}
openFile(): void {
if (this.busy || !AppSettings.canOpen) {
return;
}
FileOpener.openBinary((file, fileData) => {
const format = OpenState.getOpenFileFormat(fileData);
switch (format) {
case 'kdbx':
this.batchSet(() => {
this.resetInternal();
this.name = file.name.replace(/\.kdbx$/i, '');
this.fileData = fileData;
this.path = file.path || undefined;
this.storage = file.path ? 'file' : undefined;
});
break;
case 'xml':
this.batchSet(() => {
this.resetInternal();
this.fileXml = kdbxweb.ByteUtils.bytesToString(fileData);
this.name = file.name.replace(/\.\w+$/i, '');
});
break;
case 'kdb':
Alerts.error({
header: Locale.openWrongFile,
body: Locale.openKdbFileBody
});
break;
default:
Alerts.error({
header: Locale.openWrongFile,
body: Locale.openWrongFileBody
});
break;
}
});
}
openKeyFile(): void {
if (this.busy || !AppSettings.canOpen) {
return;
}
FileOpener.openBinary((file, keyFileData) => {
this.batchSet(() => {
this.resetKeyFileInternal();
this.keyFileName = file.name;
if (AppSettings.rememberKeyFiles === 'path' && file.path) {
this.keyFilePath = file.path;
}
this.keyFileData = keyFileData;
});
});
}
openKeyFileFromDropbox(): void {
if (this.busy || !AppSettings.canOpen) {
return;
}
const dropboxChooser = new DropboxChooser((err, res) => {
if (!err && res) {
this.batchSet(() => {
this.resetKeyFileInternal();
this.keyFileName = res.name;
this.keyFileData = res.data;
});
}
});
dropboxChooser.choose();
}
clearKeyFile(): void {
if (this.busy) {
return;
}
this.batchSet(() => {
this.resetKeyFileInternal();
});
}
selectNextFile(): void {
if (this.busy) {
return;
}
let found = false;
for (const fileInfo of FileManager.fileInfos) {
if (found) {
this.selectFileInfo(fileInfo);
return;
}
if (fileInfo.id === this.id) {
found = true;
}
}
}
selectPreviousFile(): void {
if (this.busy) {
return;
}
let prevFileInfo: FileInfo | undefined;
for (const fileInfo of FileManager.fileInfos) {
if (fileInfo.id === this.id) {
if (prevFileInfo) {
this.selectFileInfo(prevFileInfo);
}
return;
}
prevFileInfo = fileInfo;
}
}
open(): void {
// TODO
}
private resetInternal() {
this.id = undefined;
this.name = undefined;
this.password = kdbxweb.ProtectedValue.fromString('');
this.storage = undefined;
this.path = undefined;
this.fileData = undefined;
this.fileXml = undefined;
this.keyFileName = undefined;
this.keyFileData = undefined;
this.keyFileHash = undefined;
this.keyFilePath = undefined;
this.rev = undefined;
this.opts = undefined;
this.chalResp = undefined;
}
private resetKeyFileInternal() {
this.keyFileName = undefined;
this.keyFileData = undefined;
this.keyFileHash = undefined;
this.keyFilePath = undefined;
}
private static getOpenFileFormat(fileData: ArrayBuffer): 'kdbx' | 'kdb' | 'xml' | undefined {
if (fileData.byteLength < 8) {
return undefined;
}
const fileSig = new Uint32Array(fileData, 0, 2);
if (fileSig[0] === kdbxweb.Consts.Signatures.FileMagic) {
if (fileSig[1] === kdbxweb.Consts.Signatures.Sig2Kdb) {
return 'kdb';
} else if (fileSig[1] === kdbxweb.Consts.Signatures.Sig2Kdbx) {
return 'kdbx';
} else {
return undefined;
}
} else if (AppSettings.canImportXml) {
try {
const str = kdbxweb.ByteUtils.bytesToString(fileSig).trim();
if (str.startsWith('<?xml')) {
return 'xml';
}
} catch (e) {}
return undefined;
} else {
return undefined;
}
}
}

View File

@ -54,6 +54,7 @@ class Workspace extends Model {
if (!FileManager.getFileByName('Demo')) {
const demoFile = await File.openDemo();
FileManager.addFile(demoFile);
AppSettings.demoOpened = true;
}
this.showList();
}
@ -112,6 +113,20 @@ class Workspace extends Model {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toggleSettings(page: string): void {
// TODO: settings page
if (this.mode === 'settings') {
if (FileManager.hasOpenFiles) {
this.showList();
} else {
this.showOpen();
}
} else {
this.showSettings();
}
}
showList() {
if (FileManager.hasOpenFiles) {
this.mode = 'list';
@ -123,6 +138,10 @@ class Workspace extends Model {
this.mode = 'open';
}
showSettings(): void {
this.mode = 'settings';
}
private filesChanged() {
this.updateTags();
@ -265,6 +284,12 @@ class Workspace extends Model {
);
KeyHandler.onKey(Keys.DOM_VK_O, () => this.toggleOpen(), KeyHandler.SHORTCUT_ACTION);
KeyHandler.onKey(
Keys.DOM_VK_COMMA,
() => this.showSettings(),
KeyHandler.SHORTCUT_ACTION,
'open'
);
}
}

View File

@ -11,7 +11,6 @@ class FooterView extends View {
this.onKey(Keys.DOM_VK_G, this.genPass, KeyHandler.SHORTCUT_ACTION);
this.onKey(Keys.DOM_VK_S, this.saveAll, KeyHandler.SHORTCUT_ACTION);
this.onKey(Keys.DOM_VK_COMMA, this.toggleSettings, KeyHandler.SHORTCUT_ACTION);
this.listenTo(this, 'hide', this.viewHidden);
this.listenTo(this.model.files, 'change', this.render);
@ -60,10 +59,6 @@ class FooterView extends View {
toggleHelp() {
Events.emit('toggle-settings', 'help');
}
toggleSettings() {
Events.emit('toggle-settings', 'general');
}
}
export { FooterView };

View File

@ -2,15 +2,9 @@ import * as kdbxweb from 'kdbxweb';
import { View } from 'framework/views/view';
import { Events } from 'framework/events';
import { Storage } from 'storage';
import { DropboxChooser } from 'comp/app/dropbox-chooser';
import { FocusDetector } from 'comp/browser/focus-detector';
import { KeyHandler } from 'comp/browser/key-handler';
import { SecureInput } from 'comp/browser/secure-input';
import { Launcher } from 'comp/launcher';
import { Alerts } from 'comp/ui/alerts';
import { UsbListener } from 'comp/app/usb-listener';
import { YubiKey } from 'comp/app/yubikey';
import { Keys } from 'const/keys';
import { Comparators } from 'util/data/comparators';
import { Features } from 'util/features';
import { UrlFormat } from 'util/formatting/url-format';
@ -23,16 +17,10 @@ import { OpenChalRespView } from 'views/open-chal-resp-view';
import { omit } from 'util/fn';
import { GeneratorView } from 'views/generator-view';
import { NativeModules } from 'comp/launcher/native-modules';
import template from 'templates/open.hbs';
const logger = new Logger('open-view');
class OpenView extends View {
parent = '.app__body';
modal = 'open';
template = template;
events = {
'change .open__file-ctrl': 'fileSelected',
'click .open__icon-open': 'openFile',
@ -58,23 +46,8 @@ class OpenView extends View {
drop: 'drop'
};
params = null;
passwordInput = null;
busy = false;
currentSelectedIndex = -1;
encryptedPassword = null;
constructor(model) {
super(model);
window.$ = $;
this.resetParams();
this.passwordInput = new SecureInput();
this.onKey(Keys.DOM_VK_Z, this.undoKeyPress, KeyHandler.SHORTCUT_ACTION, 'open');
this.onKey(Keys.DOM_VK_TAB, this.tabKeyPress, null, 'open');
this.onKey(Keys.DOM_VK_ENTER, this.enterKeyPress, null, 'open');
this.onKey(Keys.DOM_VK_RETURN, this.enterKeyPress, null, 'open');
this.onKey(Keys.DOM_VK_DOWN, this.moveOpenFileSelectionDown, null, 'open');
this.onKey(Keys.DOM_VK_UP, this.moveOpenFileSelectionUp, null, 'open');
constructor() {
super();
this.listenTo(Events, 'usb-devices-changed', this.usbDevicesChanged.bind(this));
}
@ -84,33 +57,11 @@ class OpenView extends View {
}
}
resetParams() {
this.params = {
id: null,
name: '',
storage: null,
path: null,
keyFileName: null,
keyFileData: null,
keyFilePath: null,
fileData: null,
rev: null,
opts: null,
chalResp: null
};
}
windowFocused() {
this.inputEl.focus();
this.checkIfEncryptedPasswordDateIsValid();
}
focusInput(focusOnMobile) {
if (FocusDetector.hasFocus() && (focusOnMobile || !Features.isMobile)) {
this.inputEl.focus();
}
}
showLocalFileAlert() {
if (this.model.settings.skipOpenLocalWarn) {
return;
@ -152,126 +103,6 @@ class OpenView extends View {
}
}
processFile(file, complete) {
const reader = new FileReader();
reader.onload = (e) => {
let success = false;
switch (this.reading) {
case 'fileData': {
const format = this.getOpenFileFormat(e.target.result);
switch (format) {
case 'kdbx':
this.params.id = null;
this.params.fileData = e.target.result;
this.params.name = file.name.replace(/(.+)\.\w+$/i, '$1');
this.params.path = file.path || null;
this.params.storage = file.path ? 'file' : null;
this.params.rev = null;
if (!this.params.keyFileData) {
this.params.keyFileName = null;
}
this.encryptedPassword = null;
this.displayOpenFile();
this.displayOpenKeyFile();
this.displayOpenDeviceOwnerAuth();
success = true;
break;
case 'xml':
this.params.id = null;
this.params.fileXml = kdbxweb.ByteUtils.bytesToString(e.target.result);
this.params.name = file.name.replace(/\.\w+$/i, '');
this.params.path = null;
this.params.storage = null;
this.params.rev = null;
this.encryptedPassword = null;
this.importDbWithXml();
this.displayOpenDeviceOwnerAuth();
success = true;
break;
case 'kdb':
Alerts.error({
header: Locale.openWrongFile,
body: Locale.openKdbFileBody
});
break;
default:
Alerts.error({
header: Locale.openWrongFile,
body: Locale.openWrongFileBody
});
break;
}
break;
}
case 'keyFileData':
this.params.keyFileData = e.target.result;
this.params.keyFileName = file.name;
if (this.model.settings.rememberKeyFiles === 'path') {
this.params.keyFilePath = file.path;
}
this.displayOpenKeyFile();
success = true;
break;
}
if (complete) {
complete(success);
}
};
reader.onerror = () => {
Alerts.error({ header: Locale.openFailedRead });
if (complete) {
complete(false);
}
};
if (this.reading === 'fileXml') {
reader.readAsText(file);
} else {
reader.readAsArrayBuffer(file);
}
}
getOpenFileFormat(fileData) {
if (fileData.byteLength < 8) {
return undefined;
}
const fileSig = new Uint32Array(fileData, 0, 2);
if (fileSig[0] === kdbxweb.Consts.Signatures.FileMagic) {
if (fileSig[1] === kdbxweb.Consts.Signatures.Sig2Kdb) {
return 'kdb';
} else if (fileSig[1] === kdbxweb.Consts.Signatures.Sig2Kdbx) {
return 'kdbx';
} else {
return undefined;
}
} else if (this.model.settings.canImportXml) {
try {
const str = kdbxweb.ByteUtils.bytesToString(fileSig).trim();
if (str.startsWith('<?xml')) {
return 'xml';
}
} catch (e) {}
return undefined;
} else {
return undefined;
}
}
displayOpenFile() {
this.$el.addClass('open--file');
this.$el.find('.open__settings-key-file,.open__settings-yubikey').removeClass('hide');
this.inputEl[0].removeAttribute('readonly');
this.inputEl[0].setAttribute('placeholder', Locale.openPassFor + ' ' + this.params.name);
this.focusInput();
}
displayOpenKeyFile() {
this.$el.toggleClass('open--key-file', !!this.params.keyFileName);
this.$el
.find('.open__settings-key-file-name')
.text(this.params.keyFileName || this.params.keyFilePath || Locale.openKeyFile);
this.focusInput();
}
displayOpenChalResp() {
this.$el
.find('.open__settings-yubikey')
@ -287,80 +118,6 @@ class OpenView extends View {
.classList.toggle('open__pass-enter-btn--touch-id', canUseEncryptedPassword);
}
setFile(file, keyFile, fileReadyCallback) {
this.reading = 'fileData';
this.processFile(file, (success) => {
if (success && keyFile) {
this.reading = 'keyFileData';
this.processFile(keyFile);
}
if (success && typeof fileReadyCallback === 'function') {
fileReadyCallback();
}
});
}
openFile() {
if (this.model.settings.canOpen === false) {
return;
}
if (!this.busy) {
this.closeConfig();
this.openAny('fileData');
}
}
openKeyFile(e) {
if ($(e.target).hasClass('open__settings-key-file-dropbox')) {
this.openKeyFileFromDropbox();
} else if (!this.busy && this.params.name) {
if (this.params.keyFileName) {
this.params.keyFileData = null;
this.params.keyFilePath = null;
this.params.keyFileName = '';
this.$el.removeClass('open--key-file');
this.$el.find('.open__settings-key-file-name').text(Locale.openKeyFile);
} else {
this.openAny('keyFileData');
}
}
}
openKeyFileFromDropbox() {
if (!this.busy) {
new DropboxChooser((err, res) => {
if (err) {
return;
}
this.params.keyFileData = res.data;
this.params.keyFileName = res.name;
this.displayOpenKeyFile();
}).choose();
}
}
openAny(reading, ext) {
this.reading = reading;
this.params[reading] = null;
const fileInput = this.$el
.find('.open__file-ctrl')
.attr('accept', ext || '')
.val(null);
if (Launcher && Launcher.openFileChooser) {
Launcher.openFileChooser((err, file) => {
if (err) {
logger.error('Error opening file chooser', err);
} else {
this.processFile(file);
}
});
} else {
fileInput.click();
}
}
openLast(e) {
if (this.busy) {
return;
@ -399,40 +156,10 @@ class OpenView extends View {
this.render();
}
inputKeydown(e) {
const code = e.keyCode || e.which;
if (code === Keys.DOM_VK_RETURN) {
this.openDb();
} else if (code === Keys.DOM_VK_CAPS_LOCK) {
this.toggleCapsLockWarning(false);
}
}
inputKeyup(e) {
const code = e.keyCode || e.which;
if (code === Keys.DOM_VK_CAPS_LOCK) {
this.toggleCapsLockWarning(false);
}
}
inputKeypress(e) {
const charCode = e.keyCode || e.which;
const ch = String.fromCharCode(charCode);
const lower = ch.toLowerCase();
const upper = ch.toUpperCase();
if (lower !== upper && !e.shiftKey) {
this.toggleCapsLockWarning(ch !== lower);
}
}
inputInput() {
this.displayOpenDeviceOwnerAuth();
}
toggleCapsLockWarning(on) {
this.$el.find('.open__pass-warning').toggleClass('invisible', !on);
}
dragover(e) {
if (this.model.settings.canOpen === false) {
return;
@ -507,88 +234,6 @@ class OpenView extends View {
}
}
undoKeyPress(e) {
e.preventDefault();
}
tabKeyPress() {
this.$el.addClass('open--show-focus');
}
enterKeyPress(e) {
const el = this.$el.find('[tabindex]:focus');
if (el.length) {
el.trigger('click', e);
}
}
showOpenFileInfo(fileInfo, fileWasClicked) {
if (this.busy || !fileInfo) {
return;
}
this.params.id = fileInfo.id;
this.params.storage = fileInfo.storage;
this.params.path = fileInfo.path;
this.params.name = fileInfo.name;
this.params.fileData = null;
this.params.rev = null;
this.params.keyFileName = fileInfo.keyFileName;
this.params.keyFilePath = fileInfo.keyFilePath;
this.params.keyFileData = null;
this.params.opts = fileInfo.opts;
this.params.chalResp = fileInfo.chalResp;
this.setEncryptedPassword(fileInfo);
this.displayOpenFile();
this.displayOpenKeyFile();
this.displayOpenChalResp();
this.displayOpenDeviceOwnerAuth();
if (fileWasClicked) {
this.focusInput(true);
}
}
showOpenLocalFile(path, keyFilePath) {
if (this.busy) {
return;
}
this.params.id = null;
this.params.storage = 'file';
this.params.path = path;
this.params.name = path.match(/[^/\\]*$/)[0];
this.params.rev = null;
this.params.fileData = null;
this.encryptedPassword = null;
this.displayOpenFile();
this.displayOpenDeviceOwnerAuth();
if (keyFilePath) {
const parsed = Launcher.parsePath(keyFilePath);
this.params.keyFileName = parsed.file;
this.params.keyFilePath = keyFilePath;
this.params.keyFileData = null;
this.displayOpenKeyFile();
}
}
createDemo() {
if (!this.busy) {
this.closeConfig();
if (!this.model.createDemoFile()) {
this.emit('close');
}
if (!this.model.settings.demoOpened) {
this.model.settings.demoOpened = true;
}
}
}
createNew() {
if (!this.busy) {
this.model.createNewFile();
}
}
openDb() {
if (this.params.id && this.model.files.get(this.params.id)) {
this.emit('close');
@ -686,18 +331,6 @@ class OpenView extends View {
);
}
toggleMore() {
if (this.busy) {
return;
}
this.closeConfig();
this.$el.find('.open__icons--lower').toggleClass('hide');
}
openSettings() {
Events.emit('toggle-settings');
}
openStorage(e) {
if (this.busy) {
return;
@ -899,31 +532,6 @@ class OpenView extends View {
}
}
moveOpenFileSelection(steps) {
const lastOpenFiles = this.getLastOpenFiles();
if (
this.currentSelectedIndex + steps >= 0 &&
this.currentSelectedIndex + steps <= lastOpenFiles.length - 1
) {
this.currentSelectedIndex = this.currentSelectedIndex + steps;
}
const lastOpenFile = lastOpenFiles[this.currentSelectedIndex];
if (!lastOpenFile) {
return;
}
const fileInfo = this.model.fileInfos.get(lastOpenFiles[this.currentSelectedIndex].id);
this.showOpenFileInfo(fileInfo);
}
moveOpenFileSelectionDown() {
this.moveOpenFileSelection(1);
}
moveOpenFileSelectionUp() {
this.moveOpenFileSelection(-1);
}
toggleGenerator(e) {
e.stopPropagation();
if (this.views.gen) {

View File

@ -17,6 +17,8 @@ export const Footer: FunctionComponent = () => {
const openClicked = () => Workspace.toggleOpen();
const settingsClicked = () => Workspace.toggleSettings('general');
const lockWorkspaceClicked = () => Workspace.lockWorkspace();
return h(FooterView, {
@ -24,6 +26,7 @@ export const Footer: FunctionComponent = () => {
updateAvailable,
openClicked,
settingsClicked,
lockWorkspaceClicked
});
};

View File

@ -40,6 +40,10 @@ export const OpenButtons: FunctionComponent = () => {
AppSettings.yubiKeyShowIcon &&
!FileManager.getFileByName('yubikey');
const openClicked = () => {
Workspace.openState.openFile();
};
const newClicked = () => {
Workspace.createNewFile().catch((e) => logger.error(e));
};
@ -52,6 +56,10 @@ export const OpenButtons: FunctionComponent = () => {
Workspace.createDemoFile().catch((e) => logger.error(e));
};
const settingsClicked = () => {
Workspace.showSettings();
};
return h(OpenButtonsView, {
secondRowVisible,
showOpen: AppSettings.canOpen,
@ -65,8 +73,10 @@ export const OpenButtons: FunctionComponent = () => {
showSettings: AppSettings.canOpenSettings,
storageProviders,
openClicked,
newClicked,
moreClicked,
openDemoClicked
openDemoClicked,
settingsClicked
});
};

View File

@ -3,6 +3,7 @@ import { OpenLastFilesView } from 'views/open/open-last-files-view';
import { FileManager } from 'models/file-manager';
import { Storage } from 'storage';
import { AppSettings } from 'models/app-settings';
import { Workspace } from 'models/workspace';
export const OpenLastFiles: FunctionComponent = () => {
const lastOpenFiles = FileManager.fileInfos.map((fi) => {
@ -17,8 +18,17 @@ export const OpenLastFiles: FunctionComponent = () => {
};
});
const lastFileSelected = (id: string) => {
const fileInfo = FileManager.getFileInfoById(id);
if (fileInfo) {
Workspace.openState.selectFileInfo(fileInfo);
}
};
return h(OpenLastFilesView, {
lastOpenFiles,
canRemoveLatest: AppSettings.canRemoveLatest
canRemoveLatest: AppSettings.canRemoveLatest,
lastFileSelected
});
};

View File

@ -1,9 +1,11 @@
import * as kdbxweb from 'kdbxweb';
import { h, FunctionComponent } from 'preact';
import { OpenPasswordView } from 'views/open/open-password-view';
import { AppSettings } from 'models/app-settings';
import { Workspace } from 'models/workspace';
import { useEvent, useModelWatcher } from 'util/ui/hooks';
import { Locale } from 'util/locale';
import { AppSettings } from 'models/app-settings';
import { Keys } from 'const/keys';
export const OpenPassword: FunctionComponent = () => {
useModelWatcher(Workspace.openState);
@ -12,14 +14,59 @@ export const OpenPassword: FunctionComponent = () => {
Workspace.openState.password = kdbxweb.ProtectedValue.fromString('');
});
const passwordClicked = () => {
Workspace.openState.openFile();
};
const passwordChanged = (password: kdbxweb.ProtectedValue) => {
Workspace.openState.password = password;
};
const passwordKeyUp = (e: KeyboardEvent) => {
const code = e.keyCode || e.which;
if (code === Keys.DOM_VK_CAPS_LOCK) {
Workspace.openState.capsLockPressed = false;
}
};
const passwordKeyDown = (e: KeyboardEvent) => {
const code = e.keyCode || e.which;
if (code === Keys.DOM_VK_RETURN) {
Workspace.openState.open();
} else if (code === Keys.DOM_VK_CAPS_LOCK) {
Workspace.openState.capsLockPressed = false;
}
};
const passwordKeyPress = (e: KeyboardEvent) => {
const charCode = e.keyCode || e.which;
const ch = String.fromCharCode(charCode);
const lower = ch.toLowerCase();
const upper = ch.toUpperCase();
if (lower !== upper && !e.shiftKey) {
Workspace.openState.capsLockPressed = ch !== lower;
}
};
let passwordPlaceholder = '';
if (Workspace.openState.name) {
passwordPlaceholder = `${Locale.openPassFor} ${Workspace.openState.name}`;
} else if (AppSettings.canOpen) {
passwordPlaceholder = Locale.openClickToOpen;
}
return h(OpenPasswordView, {
password: Workspace.openState.password,
canOpen: AppSettings.canOpen,
passwordReadOnly: !Workspace.openState.name,
passwordPlaceholder,
autoFocusPassword: Workspace.openState.autoFocusPassword,
buttonFingerprint: false,
capsLockPressed: Workspace.openState.capsLockPressed,
passwordChanged
passwordClicked,
passwordChanged,
passwordKeyUp,
passwordKeyDown,
passwordKeyPress
});
};

View File

@ -1,6 +1,37 @@
import { h, FunctionComponent } from 'preact';
import { OpenScreenView } from 'views/open/open-screen-view';
import { Workspace } from 'models/workspace';
import { useKey, useModal, useModelField } from 'util/ui/hooks';
import { Keys } from 'const/keys';
import { KeyHandler } from 'comp/browser/key-handler';
export const OpenScreen: FunctionComponent = () => {
return h(OpenScreenView, {});
useModal('open');
const openFile = () => {
Workspace.openState.openFile();
};
const setVisualFocus = () => {
Workspace.openState.visualFocus = true;
};
const undoKeyPress = (e: KeyboardEvent) => e.preventDefault();
const name = useModelField(Workspace.openState, 'name');
const keyFileName = useModelField(Workspace.openState, 'keyFileName');
const visualFocus = useModelField(Workspace.openState, 'visualFocus');
useKey(Keys.DOM_VK_O, openFile, KeyHandler.SHORTCUT_ACTION, 'open');
useKey(Keys.DOM_VK_DOWN, () => Workspace.openState.selectNextFile(), undefined, 'open');
useKey(Keys.DOM_VK_UP, () => Workspace.openState.selectPreviousFile(), undefined, 'open');
useKey(Keys.DOM_VK_TAB, setVisualFocus, undefined, 'open');
useKey(Keys.DOM_VK_TAB, setVisualFocus, KeyHandler.SHORTCUT_SHIFT, 'open');
useKey(Keys.DOM_VK_Z, undoKeyPress, undefined, 'open');
return h(OpenScreenView, {
fileSelected: !!name,
keyFileSelected: !!keyFileName,
visualFocus
});
};

View File

@ -4,13 +4,35 @@ import { Launcher } from 'comp/launcher';
import { Storage } from 'storage';
import { AppSettings } from 'models/app-settings';
import { UsbListener } from 'comp/devices/usb-listener';
import { useModelField } from 'util/ui/hooks';
import { Workspace } from 'models/workspace';
export const OpenSettings: FunctionComponent = () => {
const name = useModelField(Workspace.openState, 'name');
const keyFileName = useModelField(Workspace.openState, 'keyFileName');
const hasYubiKeys = UsbListener.attachedYubiKeys > 0;
const canUseChalRespYubiKey = hasYubiKeys && AppSettings.yubiKeyShowChalResp;
const selectKeyFileClicked = () => {
if (keyFileName) {
Workspace.openState.clearKeyFile();
} else {
Workspace.openState.openKeyFile();
}
};
const selectKeyFileFromDropboxClicked = () => {
Workspace.openState.openKeyFileFromDropbox();
};
return h(OpenSettingsView, {
canSelectKeyFile: !!name,
canOpenKeyFromDropbox: !Launcher && Storage.dropbox.enabled,
canUseChalRespYubiKey
canUseChalRespYubiKey,
keyFileName,
selectKeyFileClicked,
selectKeyFileFromDropboxClicked
});
};

View File

@ -0,0 +1,66 @@
import { Logger } from 'util/logger';
let el: HTMLInputElement | undefined;
const logger = new Logger('file-reader');
export const FileOpener = {
openText(selected: (file: File, data: string) => void): void {
this.open((file, data) => {
if (typeof data === 'string') {
selected(file, data);
} else {
logger.error(`Error reading file: expected a string, got`, data);
}
}, true);
},
openBinary(selected: (file: File, data: ArrayBuffer) => void): void {
this.open((file, data) => {
if (data instanceof ArrayBuffer) {
selected(file, data);
} else {
logger.error(`Error reading file: expected an ArrayBuffer, got`, data);
}
}, false);
},
open(selected: (file: File, data: unknown) => void, asText: boolean): void {
el?.remove();
el = document.createElement('input');
el.type = 'file';
el.classList.add('hide-by-pos');
el.click();
el.addEventListener('change', () => {
const file = el?.files?.[0];
if (!file) {
logger.error('No file selected');
el?.remove();
return;
}
if (!file.name) {
logger.error('File with empty name selected');
el?.remove();
return;
}
const reader = new FileReader();
reader.addEventListener('error', () => {
el?.remove();
logger.error('Error reading file');
});
reader.addEventListener('load', () => {
selected(file, reader.result);
el?.remove();
});
if (asText) {
reader.readAsText(file);
} else {
reader.readAsArrayBuffer(file);
}
});
}
};

View File

@ -44,5 +44,12 @@ export function unreachable(msg: string, arg: never): never {
}
export function errorToString(err: unknown): string {
return err instanceof Error ? err.message : String(err);
if (err instanceof Error) {
return err.message;
}
const str = String(err);
if (str === String({})) {
return `Error: ${JSON.stringify(str)}`;
}
return str;
}

View File

@ -0,0 +1,9 @@
export function withoutPropagation<EventType extends Event, Args extends unknown[]>(
listener?: (...args: Args) => void,
...args: Args
): (e: EventType) => void {
return (e: EventType) => {
e.stopPropagation();
listener?.(...args);
};
}

View File

@ -5,6 +5,7 @@ import { ListenerSignature, Model } from 'util/model';
import { KeyHandler } from 'comp/browser/key-handler';
import { Keys } from 'const/keys';
import { Events, GlobalEventSpec } from 'util/events';
import { FocusManager } from 'comp/app/focus-manager';
export function useModelField<
ModelEventsType extends ListenerSignature<ModelEventsType>,
@ -61,3 +62,10 @@ export function useKey(
return KeyHandler.onKey(key, handler, shortcut, modal, noPrevent);
}, []);
}
export function useModal(name: string): void {
useEffect(() => {
FocusManager.pushModal(name);
return () => FocusManager.popModal();
}, []);
}

View File

@ -5,14 +5,13 @@ import { classes } from 'util/ui/classes';
const MaxLength = 1024;
export interface SecureInputEvent {
export interface SecureInputEvent extends InputEvent {
value: kdbxweb.ProtectedValue;
}
export const SecureInput: FunctionComponent<{
name?: string;
inputClass?: string;
autofocus?: boolean;
error?: boolean;
readonly?: boolean;
disabled?: boolean;
@ -23,10 +22,13 @@ export const SecureInput: FunctionComponent<{
value?: kdbxweb.ProtectedValue;
onInput?: (e: SecureInputEvent) => void;
onClick?: (e: MouseEvent) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyUp?: (e: KeyboardEvent) => void;
onKeyPress?: (e: KeyboardEvent) => void;
}> = ({
name,
inputClass,
autofocus,
error,
readonly,
disabled,
@ -36,7 +38,11 @@ export const SecureInput: FunctionComponent<{
inputRef,
value,
onInput
onInput,
onClick,
onKeyDown,
onKeyUp,
onKeyPress
}) => {
const minChar = useRef(0x1400 + Math.round(Math.random() * 100));
const length = useRef(0);
@ -100,7 +106,10 @@ export const SecureInput: FunctionComponent<{
lastValue.current = getValue();
onInput?.({ value: lastValue.current });
const ev = e as SecureInputEvent;
ev.value = lastValue.current;
onInput?.(ev);
};
function getChar(ix: number): string {
@ -145,15 +154,18 @@ export const SecureInput: FunctionComponent<{
type="password"
autocomplete="new-password"
maxLength={MaxLength}
autofocus={autofocus}
tabIndex={tabIndex}
placeholder={placeholder}
readonly={readonly}
disabled={disabled}
size={size}
ref={inputRef}
onInput={onInternalInput}
value={realInputValue}
onInput={onInternalInput}
onClick={onClick}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onKeyPress={onKeyPress}
/>
);
};

View File

@ -15,8 +15,9 @@ export const FooterView: FunctionComponent<{
updateAvailable: boolean;
openClicked: () => void;
settingsClicked: () => void;
lockWorkspaceClicked: () => void;
}> = ({ files, updateAvailable, openClicked, lockWorkspaceClicked }) => {
}> = ({ files, updateAvailable, settingsClicked, openClicked, lockWorkspaceClicked }) => {
return (
<div class="footer">
{files.map((file) => (
@ -57,6 +58,7 @@ export const FooterView: FunctionComponent<{
class="footer__btn footer__btn-settings"
tip-placement="top"
id="footer__btn-settings"
onClick={settingsClicked}
>
<kw-tip text={Locale.settings} />
{updateAvailable ? (

View File

@ -5,6 +5,7 @@ import { Locale } from 'util/locale';
import { useState } from 'preact/hooks';
import { MenuItem } from 'models/menu/menu-item';
import { AppMenuItem } from 'ui/menu/app-menu-item';
import { withoutPropagation } from 'util/ui/events';
export const AppMenuItemView: FunctionComponent<{
title: string;
@ -49,36 +50,6 @@ export const AppMenuItemView: FunctionComponent<{
}) => {
const [hover, setHover] = useState(false);
const mouseOver = (e: Event) => {
e.stopPropagation();
setHover(true);
};
const mouseOut = (e: Event) => {
e.stopPropagation();
setHover(false);
};
const itemClickedStopPropagation = (e: Event) => {
e.stopPropagation();
itemClicked?.();
};
const itemDblClickedStopPropagation = (e: Event) => {
e.stopPropagation();
itemDblClicked?.();
};
const optionClickedStopPropagation = (e: Event, option: MenuOption) => {
e.stopPropagation();
optionClicked?.(option);
};
const actionClickedStopPropagation = (e: Event) => {
e.stopPropagation();
actionClicked?.();
};
return (
<div
class={classes({
@ -90,10 +61,10 @@ export const AppMenuItemView: FunctionComponent<{
'menu__item--collapsed': !expanded,
...(cls ? { [cls]: true } : null)
})}
onMouseOver={(e) => mouseOver(e)}
onMouseOut={(e) => mouseOut(e)}
onClick={itemClickedStopPropagation}
onDblClick={itemDblClickedStopPropagation}
onMouseOver={withoutPropagation(setHover, true)}
onMouseOut={withoutPropagation(setHover, false)}
onClick={withoutPropagation(itemClicked)}
onDblClick={withoutPropagation(itemDblClicked)}
>
{collapsible ? (
<i class="menu__item-collapse fa fa-ellipsis-v muted-color">
@ -123,7 +94,7 @@ export const AppMenuItemView: FunctionComponent<{
<div
class={`menu__item-option ${opt.cls || ''}`}
key={opt.value}
onClick={(e) => optionClickedStopPropagation(e, opt)}
onClick={withoutPropagation(optionClicked, opt)}
>
{opt.title}
</div>
@ -131,13 +102,16 @@ export const AppMenuItemView: FunctionComponent<{
</div>
) : null}
{editable ? (
<i class="menu__item-edit fa fa-cog" onClick={actionClickedStopPropagation} />
<i
class="menu__item-edit fa fa-cog"
onClick={withoutPropagation(actionClicked)}
/>
) : null}
{isTrash ? (
<i
class="menu__item-empty-trash fa fa-minus-circle"
tip-placement="right"
onClick={actionClickedStopPropagation}
onClick={withoutPropagation(actionClicked)}
>
<kw-tip text={Locale.menuEmptyTrash} />
</i>

View File

@ -14,7 +14,7 @@ export const AppMenuSectionView: FunctionComponent<{
items: MenuItem[];
height?: number;
}> = ({ id, scrollable, grow, drag, items, height }) => {
const menuRef = useRef<HTMLDivElement>();
const menu = useRef<HTMLDivElement>();
const menuItems = items.map((item) => <AppMenuItem item={item} key={item.id} />);
return (
@ -27,14 +27,14 @@ export const AppMenuSectionView: FunctionComponent<{
'menu__section--drag': drag
})}
style={{ height }}
ref={menuRef}
ref={menu}
>
{scrollable ? <Scrollable>{menuItems}</Scrollable> : menuItems}
</div>
{drag ? (
<div class="menu__drag-section">
<DragHandle
target={menuRef}
target={menu}
coord="y"
name={`menu-section:${id}`}
min={55}

View File

@ -1,5 +1,6 @@
import { Fragment, FunctionComponent } from 'preact';
import { classes } from 'util/ui/classes';
import { withoutPropagation } from 'util/ui/events';
export interface ModalViewButton {
title: string;
@ -43,11 +44,6 @@ export const ModalView: FunctionComponent<ModalViewProps> = ({
buttonClicked,
checkboxChanged
}) => {
const buttonClickedWithoutPropagation = (e: MouseEvent, result: string) => {
e.stopPropagation();
buttonClicked(result);
};
return (
<div
class={classes({
@ -90,7 +86,7 @@ export const ModalView: FunctionComponent<ModalViewProps> = ({
'btn-error': btn.error || !btn.result,
'btn-silent': btn.silent
})}
onClick={(e) => buttonClickedWithoutPropagation(e, btn.result)}
onClick={withoutPropagation(buttonClicked, btn.result)}
data-result={btn.result}
>
{btn.title}

View File

@ -19,9 +19,11 @@ export const OpenButtonsView: FunctionComponent<{
showSettings: boolean;
storageProviders: StorageProvider[];
openClicked: () => void;
moreClicked: () => void;
newClicked: () => void;
openDemoClicked: () => void;
settingsClicked: () => void;
}> = ({
secondRowVisible,
showOpen,
@ -35,17 +37,23 @@ export const OpenButtonsView: FunctionComponent<{
showSettings,
storageProviders,
openClicked,
moreClicked,
newClicked,
openDemoClicked
openDemoClicked,
settingsClicked
}) => {
let tabIndex = 0;
let tabIndex = 100;
return (
<>
<div class="open__icons">
{showOpen ? (
<div class="open__icon open__icon-open" tabIndex={++tabIndex}>
<div
class="open__icon open__icon-open"
tabIndex={++tabIndex}
onClick={openClicked}
>
<i class="fa fa-lock open__icon-i" />
<div class="open__icon-text">{Locale.openOpen}</div>
</div>
@ -122,7 +130,11 @@ export const OpenButtonsView: FunctionComponent<{
</div>
) : null}
{showSettings ? (
<div class="open__icon open__icon-settings" tabIndex={++tabIndex}>
<div
class="open__icon open__icon-settings"
tabIndex={++tabIndex}
onClick={settingsClicked}
>
<i class="fa fa-cog open__icon-i" />
<div class="open__icon-text">{Locale.settings}</div>
</div>

View File

@ -10,13 +10,20 @@ interface LastOpenFile {
export const OpenLastFilesView: FunctionComponent<{
lastOpenFiles: LastOpenFile[];
canRemoveLatest: boolean;
}> = ({ lastOpenFiles, canRemoveLatest }) => {
let tabIndex = 0;
lastFileSelected: (id: string) => void;
}> = ({ lastOpenFiles, canRemoveLatest, lastFileSelected }) => {
let tabIndex = 400;
return (
<div class="open__last">
{lastOpenFiles.map((file) => (
<div key={file.id} class="open__last-item" tabIndex={++tabIndex}>
<div
key={file.id}
class="open__last-item"
tabIndex={++tabIndex}
onClick={() => lastFileSelected(file.id)}
>
{file.path ? <kw-tip text={file.path} /> : null}
{file.icon ? <i class={`fa fa-${file.icon} open__last-item-icon`} /> : null}
<span class="open__last-item-text">{file.name}</span>

View File

@ -2,21 +2,51 @@ import * as kdbxweb from 'kdbxweb';
import { FunctionComponent } from 'preact';
import { Locale } from 'util/locale';
import { SecureInput, SecureInputEvent } from 'views/components/secure-input';
import { useRef } from 'preact/hooks';
import { useLayoutEffect, useRef } from 'preact/hooks';
import { useEvent } from 'util/ui/hooks';
import { classes } from 'util/ui/classes';
import { FocusDetector } from 'comp/browser/focus-detector';
import { Features } from 'util/features';
export const OpenPasswordView: FunctionComponent<{
password?: kdbxweb.ProtectedValue;
canOpen: boolean;
passwordReadOnly: boolean;
passwordPlaceholder: string;
autoFocusPassword: boolean;
buttonFingerprint: boolean;
capsLockPressed: boolean;
passwordClicked?: () => void;
passwordChanged?: (password: kdbxweb.ProtectedValue) => void;
}> = ({ password, canOpen, passwordChanged }) => {
let tabIndex = 0;
passwordKeyDown?: (e: KeyboardEvent) => void;
passwordKeyUp?: (e: KeyboardEvent) => void;
passwordKeyPress?: (e: KeyboardEvent) => void;
}> = ({
password,
passwordReadOnly,
passwordPlaceholder,
passwordChanged,
buttonFingerprint,
capsLockPressed,
const passwordInputRef = useRef<HTMLInputElement>();
autoFocusPassword,
passwordClicked,
passwordKeyDown,
passwordKeyUp,
passwordKeyPress
}) => {
let tabIndex = 200;
const passwordInput = useRef<HTMLInputElement>();
useEvent('main-window-focus', () => {
passwordInputRef.current?.focus();
passwordInput.current?.focus();
});
useLayoutEffect(() => {
if (autoFocusPassword && !Features.isMobile) {
passwordInput.current?.focus();
}
});
const passwordInputChanged = (e: SecureInputEvent) => {
@ -25,32 +55,44 @@ export const OpenPasswordView: FunctionComponent<{
return (
<>
<input type="file" class="open__file-ctrl hide-by-pos" />
<div class="hide">
{/* we need these inputs to screw browsers passwords autocompletion */}
<input type="text" name="username" />
<input type="password" name="password" />
</div>
<div class="open__pass-warn-wrap">
<div class="open__pass-warning muted-color invisible">
<div
class={classes({
'open__pass-warning': true,
'muted-color': true,
'invisible': !capsLockPressed
})}
>
<i class="fa fa-exclamation-triangle" /> {Locale.openCaps}
</div>
</div>
<div class="open__pass-field-wrap">
<SecureInput
value={password}
onInput={passwordInputChanged}
inputClass="open__pass-input"
name="password"
size={30}
placeholder={canOpen ? Locale.openClickToOpen : ''}
// readonly={true}
placeholder={passwordPlaceholder}
readonly={passwordReadOnly}
tabIndex={++tabIndex}
inputRef={passwordInputRef}
inputRef={passwordInput}
onInput={passwordInputChanged}
onClick={passwordReadOnly ? passwordClicked : undefined}
onKeyUp={passwordKeyUp}
onKeyDown={passwordKeyDown}
onKeyPress={passwordKeyPress}
/>
<div class="open__pass-enter-btn" tabIndex={++tabIndex}>
<i class="fa fa-level-down-alt rotate-90 open__pass-enter-btn-icon-enter" />
<i class="fa fa-fingerprint open__pass-enter-btn-icon-touch-id" />
{buttonFingerprint ? (
<i class="fa fa-fingerprint open__pass-enter-btn-icon-touch-id" />
) : (
<i class="fa fa-level-down-alt rotate-90 open__pass-enter-btn-icon-enter" />
)}
</div>
<div class="open__pass-opening-icon">
<i class="fa fa-spinner spin" />

View File

@ -6,10 +6,38 @@ import { OpenStorageConfig } from 'ui/open/open-storage-config';
import { OpenPassword } from 'ui/open/open-password';
import { OpenLastFiles } from 'ui/open/open-last-files';
import { OpenSettings } from 'ui/open/open-settings';
import { classes } from 'util/ui/classes';
import { useKey } from 'util/ui/hooks';
import { Keys } from 'const/keys';
import { useRef } from 'preact/hooks';
export const OpenScreenView: FunctionComponent<{
fileSelected: boolean;
keyFileSelected: boolean;
visualFocus: boolean;
}> = ({ fileSelected, keyFileSelected, visualFocus }) => {
const openEl = useRef<HTMLDivElement>();
const enterKeyPress = () => {
const el = openEl.current.querySelector('[tabindex]:focus');
if (el instanceof HTMLDivElement) {
el.click();
}
};
useKey(Keys.DOM_VK_ENTER, enterKeyPress, undefined, 'open');
useKey(Keys.DOM_VK_RETURN, enterKeyPress, undefined, 'open');
export const OpenScreenView: FunctionComponent = () => {
return (
<div class="open">
<div
ref={openEl}
class={classes({
'open': true,
'open--file': fileSelected,
'open--key-file': keyFileSelected,
'open--show-focus': visualFocus
})}
>
<OpenUnlockMessage />
<OpenButtons />

View File

@ -1,24 +1,50 @@
import { FunctionComponent } from 'preact';
import { Locale } from 'util/locale';
import { classes } from 'util/ui/classes';
import { withoutPropagation } from 'util/ui/events';
export const OpenSettingsView: FunctionComponent<{
canSelectKeyFile: boolean;
canOpenKeyFromDropbox: boolean;
canUseChalRespYubiKey: boolean;
}> = ({ canOpenKeyFromDropbox, canUseChalRespYubiKey }) => {
let tabIndex = 0;
keyFileName?: string;
selectKeyFileClicked: () => void;
selectKeyFileFromDropboxClicked: () => void;
}> = ({
canSelectKeyFile,
canOpenKeyFromDropbox,
canUseChalRespYubiKey,
keyFileName,
selectKeyFileClicked,
selectKeyFileFromDropboxClicked
}) => {
let tabIndex = 300;
return (
<div class="open__settings">
<div class="open__settings-key-file hide" tabIndex={++tabIndex}>
<i class="fa fa-key open__settings-key-file-icon" />
<span class="open__settings-key-file-name">{Locale.openKeyFile}</span>
{canOpenKeyFromDropbox ? (
<span class="open__settings-key-file-dropbox">
{' '}
{Locale.openKeyFileDropbox}
{canSelectKeyFile ? (
<div
class="open__settings-key-file"
tabIndex={++tabIndex}
onClick={selectKeyFileClicked}
>
<i class="fa fa-key open__settings-key-file-icon" />
<span class="open__settings-key-file-name">
{keyFileName || Locale.openKeyFile}
</span>
) : null}
</div>
{canOpenKeyFromDropbox ? (
<span
class="open__settings-key-file-dropbox"
onClick={withoutPropagation(selectKeyFileFromDropboxClicked)}
>
{' '}
{Locale.openKeyFileDropbox}
</span>
) : null}
</div>
) : undefined}
<div
class={classes({
'open__settings-yubikey': true,