diff --git a/src/attack_flow_builder/public/settings_macos.json b/src/attack_flow_builder/public/settings_macos.json new file mode 100644 index 00000000..0027f5fb --- /dev/null +++ b/src/attack_flow_builder/public/settings_macos.json @@ -0,0 +1,75 @@ +{ + "file": { + "image_export": { + "padding": 30 + } + }, + "edit": { + "clone_offset": [20, 20] + }, + "view": { + "diagram": { + "display_grid": true, + "display_shadows": true, + "display_debug_mode": false, + "render_high_quality": true, + "disable_shadows_at": 0.7 + } + }, + "hotkeys": { + "file": { + "new_file": "Control+N", + "open_file": "Meta+O", + "save_file": "Meta+S", + "save_image": "", + "save_select_image": "", + "publish_file": "", + "open_library": "", + "save_library": "" + }, + "edit": { + "undo": "Meta+Z", + "redo": "Meta+Y", + "cut": "Meta+X", + "copy": "Meta+C", + "paste": "Meta+V", + "delete": "Backspace", + "duplicate": "Meta+D", + "find": "Meta+F", + "find_next": "Meta+G", + "find_previous": "Shift+Meta+G", + "select_all": "Meta+A" + }, + "layout": { + "selection_to_front": "Shift+Meta+F", + "selection_to_back": "Shift+Meta+B", + "bring_selection_forward": "", + "send_selection_backward": "", + "align_left": "", + "align_center": "", + "align_right": "", + "align_top": "", + "align_middle": "", + "align_bottom": "", + "group": "Meta+G", + "ungroup": "Meta+Shift+U", + "open_group": "", + "close_group": "" + }, + "view": { + "toggle_grid": "Alt+G", + "toggle_shadows": "", + "reset_view": "Escape", + "zoom_in": "", + "zoom_out": "", + "fullscreen": "", + "jump_to_selection": "Shift+Z", + "jump_to_parents": "Shift+P", + "jump_to_children": "Shift+C", + "toggle_debug_view": "" + }, + "select": { + "many": "Meta" + } + } +} diff --git a/src/attack_flow_builder/public/settings.json b/src/attack_flow_builder/public/settings_win.json similarity index 100% rename from src/attack_flow_builder/public/settings.json rename to src/attack_flow_builder/public/settings_win.json diff --git a/src/attack_flow_builder/src/App.vue b/src/attack_flow_builder/src/App.vue index d2fd4a03..b6eacee0 100644 --- a/src/attack_flow_builder/src/App.vue +++ b/src/attack_flow_builder/src/App.vue @@ -26,15 +26,16 @@ import Configuration from "@/assets/builder.config" import { clamp } from "./assets/scripts/BlockDiagram"; import { PointerTracker } from "./assets/scripts/PointerTracker"; import { mapMutations, mapState } from 'vuex'; +import { Browser, OperatingSystem } from "./assets/scripts/Browser"; import { defineComponent, markRaw, ref } from 'vue'; // Components +import FindDialog from "@/components/Elements/FindDialog.vue"; import SplashMenu from "@/components/Controls/SplashMenu.vue"; import AppTitleBar from "@/components/Elements/AppTitleBar.vue"; import AppHotkeyBox from "@/components/Elements/AppHotkeyBox.vue"; import BlockDiagram from "@/components/Elements/BlockDiagram.vue"; import AppFooterBar from "@/components/Elements/AppFooterBar.vue"; import EditorSidebar from "@/components/Elements/EditorSidebar.vue"; -import FindDialog from "@/components/Elements/FindDialog.vue"; const Handle = { None : 0, @@ -165,11 +166,20 @@ export default defineComponent({ }, async created() { // Import settings + let os = Browser.getOperatingSystemClass(); let settings; if(Configuration.is_web_hosted) { - settings = await (await fetch("./settings.json")).json(); + if(os === OperatingSystem.MacOS) { + settings = await (await fetch("../public/settings_macos.json")).json(); + } else { + settings = await (await fetch("../public/settings_win.json")).json(); + } } else { - settings = require("../public/settings.json"); + if(os === OperatingSystem.MacOS) { + settings = require("../public/settings_macos.json"); + } else { + settings = require("../public/settings_win.json"); + } } // Load settings this.execute(new App.LoadSettings(this.context, settings)); diff --git a/src/attack_flow_builder/src/assets/scripts/Browser.ts b/src/attack_flow_builder/src/assets/scripts/Browser.ts index 2df644fb..36f5080c 100644 --- a/src/attack_flow_builder/src/assets/scripts/Browser.ts +++ b/src/attack_flow_builder/src/assets/scripts/Browser.ts @@ -118,8 +118,43 @@ export class Browser { cast.msRequestFullscreen(); } } + + + /////////////////////////////////////////////////////////////////////////// + // 4. Operating System Detection //////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////// + + + /** + * Returns the device's current operating system class. + * @returns + * The device's current operating system class. + */ + public static getOperatingSystemClass(): OperatingSystem { + if(navigator.userAgent.search("Win") !== -1) { + return OperatingSystem.Windows + } else if(navigator.userAgent.search("Mac") !== -1) { + return OperatingSystem.MacOS; + } else if(navigator.userAgent.search("X11") !== -1) { + return OperatingSystem.UNIX; + } else if(navigator.userAgent.search("Linux") !== -1) { + return OperatingSystem.Linux; + } else { + return OperatingSystem.Other; + } + } - +} + +/** + * Recognized operating systems. + */ +export enum OperatingSystem { + Windows, + MacOS, + UNIX, + Linux, + Other } diff --git a/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts b/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts index c8df197f..1af24a46 100644 --- a/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts +++ b/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts @@ -6,14 +6,24 @@ export class HotkeyObserver { /** - * The advance key state function, bound to the current object. + * This map defines the evaluation order of modifier keys. */ - private _boundAdvanceKeyState: (e: KeyboardEvent) => void; + private static MODIFIER_KEYS = new Map Boolean>([ + ["control", e => e.ctrlKey], + ["meta", e => e.metaKey], + ["alt", e => e.altKey], + ["shift", e => e.shiftKey] + ]); - /** - * The reverse key state function, bound to the current object. - */ - private _boundReverseKeyState: (e: KeyboardEvent) => void; + /** + * The key down function, bound to the current object. + */ + private _boundOnKeyDown: (e: KeyboardEvent) => void; + + /** + * The key up function, bound to the current object. + */ + private _boundOnKeyUp: (e: KeyboardEvent) => void; /** * The function to call when a hotkey sequence is matched. @@ -31,7 +41,7 @@ export class HotkeyObserver { private _hotkeyIdMap: Map>; /** - * The current key state. + * The last observed key state. */ private _keyState: string; @@ -44,12 +54,12 @@ export class HotkeyObserver { * The time to wait (in milliseconds) before firing the hotkey callback. */ constructor(callback: (command: T) => void, recognitionDelay: number) { - this._boundAdvanceKeyState = this.advanceKeyState.bind(this); - this._boundReverseKeyState = this.reverseKeyState.bind(this); + this._boundOnKeyDown = this.onKeyDown.bind(this); + this._boundOnKeyUp = this.onKeyUp.bind(this); this._callback = callback; this._container = null; this._hotkeyIdMap = new Map(); - this._keyState = "."; + this._keyState = ""; } @@ -60,16 +70,16 @@ export class HotkeyObserver { */ public observe(container: HTMLElement) { this._container = container; - this._container.addEventListener("keydown", this._boundAdvanceKeyState); - this._container.addEventListener("keyup", this._boundReverseKeyState); + this._container.addEventListener("keydown", this._boundOnKeyDown); + this._container.addEventListener("keyup", this._boundOnKeyUp); } /** * Unbinds the HotkeyObserver from the HTML element. */ public disconnect() { - this._container?.removeEventListener("keydown", this._boundAdvanceKeyState); - this._container?.removeEventListener("keyup", this._boundReverseKeyState); + this._container?.removeEventListener("keydown", this._boundOnKeyDown); + this._container?.removeEventListener("keyup", this._boundOnKeyUp); } /** @@ -104,6 +114,11 @@ export class HotkeyObserver { /** * Tests if a hotkey sequence is active. + * @remarks + * Due to the inconsistent nature of the `keyup` event, this is not + * guaranteed to be correct. Refer to: `HotKeyObserver.onKeyUp()` for more + * information. This function will need to be refactored or deprecated in + * the future. * @param sequence * The hotkey sequence. * @param strict @@ -121,12 +136,11 @@ export class HotkeyObserver { } /** - * Adds a key event to the current key state. + * Key down behavior. * @param e * The keydown event. */ - private advanceKeyState(e: KeyboardEvent) { - let key = e.key.toLocaleLowerCase(); + private onKeyDown(e: KeyboardEvent) { // If inside input field, ignore hotkeys if((e.target as any).tagName === "INPUT") { @@ -134,16 +148,15 @@ export class HotkeyObserver { } // Advanced current key state - let isRepeat = this._keyState.endsWith(`.${ key }.`); - if(!isRepeat) { - this._keyState += `${ key }.` - } - + let nextKeyState = this.keyEventToHotKeyId(e); + let isRepeat = this._keyState === nextKeyState; + this._keyState = nextKeyState; + // Check key state if (this._hotkeyIdMap.has(this._keyState)) { let hotkey = this._hotkeyIdMap.get(this._keyState)!; - // If in repeat state and hotkey is not repeatable: - if(isRepeat && !hotkey.repeatable) { + // If disabled or if in repeat state and hotkey is not repeatable: + if(hotkey.disabled || (isRepeat && !hotkey.repeatable)) { // Prevent all browser behavior e.preventDefault(); // Bail @@ -163,13 +176,38 @@ export class HotkeyObserver { } /** - * Removes a key event from the current key state. + * Key up behavior. + * @remarks + * The `keyup` event will not fire in all cases. For example: + * + * - If a hotkey opens a separate window, there will be no `keyup` event. + * - If the Command key is held (on MacOS), any other `keyup` is ignored. + * - ...there are probably others. + * + * For these reasons, do not rely on `keyup` events for critical + * functionality (at least until these issues can be addressed somehow). * @param e * The keyup event. */ - private reverseKeyState(e: KeyboardEvent) { - let key = e.key.toLocaleLowerCase(); - this._keyState = this._keyState.replace(`.${ key }.`, "."); + private onKeyUp(e: KeyboardEvent) { + // Resolve next modifier keys state + let nextKeyState = this.keyEventToHotKeyId(e).split("."); + let lostKey = nextKeyState.splice(-2, 1)[0]; + // Resolve previous non-modifier + let prevNonModifier = this._keyState.split(".").at(-2)!; + // If lost key was non-modifier... + if(lostKey === prevNonModifier) { + // ...remove non-modifier. + nextKeyState.push(""); + } + // If lost key was anything else... + else { + // ...keep non-modifier. + nextKeyState.push(prevNonModifier) + } + nextKeyState.push(""); + // Update key state + this._keyState = nextKeyState.join("."); } /** @@ -178,14 +216,71 @@ export class HotkeyObserver { * The sequence to evaluate. * @returns * The sequence's hotkey id. + * @throws {InvalidKeySequenceError} + * If the key sequence contains more than one non-modifier key. */ private keySequenceToHotKeyId(sequence: string): string { - let hotkeyId = sequence - .toLocaleLowerCase() + let id = ""; + // Parse tokens + let tokens = sequence .replace(/\s+/g, '') - .split("+") - .join("."); - return `.${hotkeyId}.` + .toLocaleLowerCase() + .split("+"); + // Order modifier keys + for(let key of HotkeyObserver.MODIFIER_KEYS.keys()) { + let index = tokens.findIndex(o => o === key); + if(index !== -1) { + id += `.${ tokens.splice(index, 1)[0] }`; + } + } + // Resolve single non-modifier key + if(0 === tokens.length) { + id += `.`; + } + else if(2 <= tokens.length) { + throw new InvalidKeySequenceError( + `Hotkey contains ${ + tokens.length + } non-modifier keys (${ + tokens.join(",") + }).`); + } else if(!HotkeyObserver.MODIFIER_KEYS.has(tokens[0])) { + id += `.${ tokens[0] }` + } else { + throw new InvalidKeySequenceError( + `Hotkey duplicate modifier key '${ + tokens[0] + }'.` + ); + } + // Return id + return `${ id }.`; + } + + /** + * Converts a key event to its hotkey id. + * @param event + * The keyboard event. + * @returns + * The key event's hotkey id. + */ + private keyEventToHotKeyId(event: KeyboardEvent): string { + let id = "" + // Parse modifier keys + for(let [key, isKey] of HotkeyObserver.MODIFIER_KEYS) { + if(isKey(event)) { + id += `.${ key }`; + } + } + // Parse key pressed + let keyPressed = event.key.toLocaleLowerCase(); + if(!HotkeyObserver.MODIFIER_KEYS.has(keyPressed)) { + id += `.${ keyPressed }`; + } else { + id += `.`; + } + // Return id + return `${ id }.`; } } @@ -193,7 +288,20 @@ export class HotkeyObserver { export class OverlappingHotkeysError extends Error { /** - * Creates a new OverlappingHotkeysError. + * Creates a new {@link OverlappingHotkeysError}. + * @param message + * The error message. + */ + constructor(message: string) { + super(message); + } + +} + +export class InvalidKeySequenceError extends Error { + + /** + * Creates a new {@link InvalidKeySequenceError}. * @param message * The error message. */ diff --git a/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue b/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue index 8691333e..ba5f59b3 100644 --- a/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue +++ b/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue @@ -54,20 +54,35 @@