diff --git a/docs/wiki/3.03-Keyboard-Shortcuts.md b/docs/wiki/3.03-Keyboard-Shortcuts.md index e45c597947..503bb838b5 100755 --- a/docs/wiki/3.03-Keyboard-Shortcuts.md +++ b/docs/wiki/3.03-Keyboard-Shortcuts.md @@ -139,7 +139,7 @@ Specifically for the **Next Month** shortcut, the task is scheduled for the **1s **Where shortcuts are defined:** -- Config model: `KeyboardConfig` (keyboard-config.model.ts). +- Config model: `KeyboardConfig` (electron/shared-with-frontend/keyboard-config.model.ts). - Default mappings: `default-global-config.const.ts` (keyboard section). - Settings form (which actions appear and their labels): `keyboard-form.const.ts`. diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index 1e98306626..92d83067dd 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -4,7 +4,7 @@ import { TakeABreakConfig, TaskWidgetConfig, } from '../src/app/features/config/global-config.model'; -import { KeyboardConfig } from '../src/app/features/config/keyboard-config.model'; +import { KeyboardConfig } from './shared-with-frontend/keyboard-config.model'; import { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model'; import { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model'; import { Task } from '../src/app/features/tasks/task.model'; diff --git a/electron/ipc-handlers/global-shortcuts.ts b/electron/ipc-handlers/global-shortcuts.ts index c4f1d418f8..e7a0decb94 100644 --- a/electron/ipc-handlers/global-shortcuts.ts +++ b/electron/ipc-handlers/global-shortcuts.ts @@ -1,6 +1,9 @@ import { globalShortcut, ipcMain } from 'electron'; import { IPC } from '../shared-with-frontend/ipc-events.const'; -import { KeyboardConfig } from '../../src/app/features/config/keyboard-config.model'; +import { + KeyboardConfig, + GLOBAL_KEY_CFG_KEYS, +} from '../shared-with-frontend/keyboard-config.model'; import { getWin, setWasMaximizedBeforeHide } from '../main-window'; import { toggleTaskWidgetVisibility } from '../task-widget/task-widget'; import { showOrFocus } from '../various-shared'; @@ -18,13 +21,6 @@ export const initGlobalShortcutsIpc = (): void => { const registerShowAppShortCuts = (cfg: KeyboardConfig): void => { // unregister all previous globalShortcut.unregisterAll(); - const GLOBAL_KEY_CFG_KEYS: (keyof KeyboardConfig)[] = [ - 'globalShowHide', - 'globalToggleTaskStart', - 'globalAddNote', - 'globalAddTask', - 'globalToggleTaskWidget', - ]; if (cfg) { const mainWin = getWin(); diff --git a/src/app/features/config/keyboard-config.model.ts b/electron/shared-with-frontend/keyboard-config.model.ts similarity index 91% rename from src/app/features/config/keyboard-config.model.ts rename to electron/shared-with-frontend/keyboard-config.model.ts index db5f3492a9..0eeb356b4c 100644 --- a/src/app/features/config/keyboard-config.model.ts +++ b/electron/shared-with-frontend/keyboard-config.model.ts @@ -58,3 +58,11 @@ export type KeyboardConfig = Readonly<{ // Dynamic plugin shortcuts - added at runtime [key: `plugin_${string}`]: string | null; }>; + +export const GLOBAL_KEY_CFG_KEYS: (keyof KeyboardConfig)[] = [ + 'globalShowHide', + 'globalToggleTaskStart', + 'globalAddNote', + 'globalAddTask', + 'globalToggleTaskWidget', +]; diff --git a/electron/tsconfig.electron.json b/electron/tsconfig.electron.json index f1f103915a..99ddfa4aee 100644 --- a/electron/tsconfig.electron.json +++ b/electron/tsconfig.electron.json @@ -8,16 +8,6 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "skipLibCheck": true, - "paths": { - "@sp/sync-providers/dropbox": ["node_modules/@sp/sync-providers/dist/dropbox.d.ts"], - "@sp/sync-providers/local-file": [ - "node_modules/@sp/sync-providers/dist/local-file.d.ts" - ], - "@sp/sync-providers/webdav": ["node_modules/@sp/sync-providers/dist/webdav.d.ts"], - "@sp/sync-providers/super-sync": [ - "node_modules/@sp/sync-providers/dist/super-sync.d.ts" - ] - }, "typeRoots": ["node_modules/@types"], "downlevelIteration": true, "lib": ["dom", "es2022"], @@ -45,7 +35,8 @@ "packages/sync-providers/dist/provider-types.d.ts" ], "@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"], - "@sp/sync-providers/onedrive": ["packages/sync-providers/dist/onedrive.d.ts"] + "@sp/sync-providers/onedrive": ["packages/sync-providers/dist/onedrive.d.ts"], + "@sp/keyboard-config": ["electron/shared-with-frontend/keyboard-config.model.ts"] } }, "include": ["main.ts", "**/*.ts", "../src/app/core/window-ea.d.ts"], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6e7ecc891f..5a829c9912 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -21,6 +21,7 @@ import { TaskWidgetSettingsService } from './features/config/task-widget-setting import { LayoutService } from './core-ui/layout/layout.service'; import { SnackService } from './core/snack/snack.service'; import { IS_ELECTRON } from './app.constants'; +import { IS_MAC } from './util/is-mac'; import { expandAnimation } from './ui/animations/expand.ani'; import { warpRouteAnimation } from './ui/animations/warp-route'; import { firstValueFrom, Subscription } from 'rxjs'; @@ -313,8 +314,11 @@ export class AppComponent implements OnDestroy, AfterViewInit { // ! For keyboard shortcuts to work correctly with any layouts (QWERTZ/AZERTY/etc) - user's keyboard layout must be presaved // Connect the service to the utility functions setKeyboardLayoutService(this._keyboardLayoutService); - // Defer keyboard layout detection to idle time for better initial load performance - if (typeof requestIdleCallback === 'function') { + // Defer keyboard layout detection to idle time for better initial load performance, + // EXCEPT on macOS Electron where it is needed eagerly for the initial global shortcut registration. + if (IS_ELECTRON && IS_MAC) { + void this._keyboardLayoutService.saveUserLayout(); + } else if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => this._keyboardLayoutService.saveUserLayout()); } else { setTimeout(() => this._keyboardLayoutService.saveUserLayout(), 0); diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index bbcc857edf..71c32e6361 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -1,6 +1,17 @@ +import { InjectionToken } from '@angular/core'; import { IS_ANDROID_WEB_VIEW } from './util/is-android-web-view'; export const IS_ELECTRON = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1; + +/** + * Injection token for IS_ELECTRON to enable testing. + * New DI-tested effects/services should prefer this token over the IS_ELECTRON + * constant to ensure testability and prevent logic drift. + */ +export const IS_ELECTRON_TOKEN = new InjectionToken('IS_ELECTRON', { + providedIn: 'root', + factory: () => IS_ELECTRON, +}); // effectively IS_BROWSER export const IS_WEB_BROWSER = !IS_ELECTRON && !IS_ANDROID_WEB_VIEW; export const IS_GNOME_DESKTOP = IS_ELECTRON && window.ea.isGnomeDesktop(); diff --git a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts index bf62f96141..1c01461ae0 100644 --- a/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts +++ b/src/app/core-ui/main-header/desktop-panel-buttons/desktop-panel-buttons.component.ts @@ -11,7 +11,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { LayoutService } from '../../layout/layout.service'; import { T } from '../../../t.const'; -import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { GlobalConfigService } from '../../../features/config/global-config.service'; @Component({ diff --git a/src/app/core-ui/main-header/focus-button/focus-button.component.ts b/src/app/core-ui/main-header/focus-button/focus-button.component.ts index 9b1554ade4..452074bdda 100644 --- a/src/app/core-ui/main-header/focus-button/focus-button.component.ts +++ b/src/app/core-ui/main-header/focus-button/focus-button.component.ts @@ -8,7 +8,7 @@ import { T } from '../../../t.const'; import { FocusModeService } from '../../../features/focus-mode/focus-mode.service'; import { MetricService } from '../../../features/metric/metric.service'; import { GlobalConfigService } from '../../../features/config/global-config.service'; -import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { DateService } from '../../../core/date/date.service'; import { FocusModeMode } from '../../../features/focus-mode/focus-mode.model'; import { ProgressCircleComponent } from '../../../ui/progress-circle/progress-circle.component'; diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index 5a105e33db..4cdaf4e611 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -23,7 +23,7 @@ import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { SnackService } from '../../core/snack/snack.service'; import { NavigationEnd, Router } from '@angular/router'; import { GlobalConfigService } from '../../features/config/global-config.service'; -import { KeyboardConfig } from 'src/app/features/config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; diff --git a/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts b/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts index abd311b04f..5899a61041 100644 --- a/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts +++ b/src/app/core-ui/main-header/mobile-side-panel-menu/mobile-side-panel-menu.component.ts @@ -14,7 +14,7 @@ import { LayoutService } from '../../layout/layout.service'; import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service'; import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component'; import { T } from '../../../t.const'; -import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { GlobalConfigService } from '../../../features/config/global-config.service'; import { Store } from '@ngrx/store'; import { diff --git a/src/app/core-ui/main-header/page-title/page-title.component.ts b/src/app/core-ui/main-header/page-title/page-title.component.ts index ca0e04bb27..b1a09408a2 100644 --- a/src/app/core-ui/main-header/page-title/page-title.component.ts +++ b/src/app/core-ui/main-header/page-title/page-title.component.ts @@ -15,7 +15,7 @@ import { WorkContextService } from '../../../features/work-context/work-context. import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service'; import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component'; import { GlobalConfigService } from '../../../features/config/global-config.service'; -import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; @Component({ selector: 'page-title', diff --git a/src/app/core/keyboard-layout/keyboard-layout.service.spec.ts b/src/app/core/keyboard-layout/keyboard-layout.service.spec.ts index cf2e993c44..37401167c9 100644 --- a/src/app/core/keyboard-layout/keyboard-layout.service.spec.ts +++ b/src/app/core/keyboard-layout/keyboard-layout.service.spec.ts @@ -194,4 +194,60 @@ describe('KeyboardLayoutService', () => { expect(service.layout.has('KeyX')).toBe(false); }); }); + + describe('layoutReady promise', () => { + const originalNavigator = globalThis.navigator; + + afterEach(() => { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + it('should resolve when layout is saved', async () => { + const mockLayoutMap = new Map([['KeyA', 'a']]); + Object.defineProperty(globalThis, 'navigator', { + value: { + keyboard: { + getLayoutMap: () => Promise.resolve(mockLayoutMap), + }, + } as NavigatorWithKeyboard, + writable: true, + configurable: true, + }); + + const promise = service.layoutReady; + await service.saveUserLayout(); + const layout = await promise; + + expect(layout.size).toBe(1); + expect(layout.get('KeyA')).toBe('a'); + }); + + it('should resolve when layout is set directly', async () => { + const promise = service.layoutReady; + service.setLayout(new Map([['KeyB', 'b']])); + const layout = await promise; + + expect(layout.size).toBe(1); + expect(layout.get('KeyB')).toBe('b'); + }); + + it('should resolve with a copy of the layout map', async () => { + const promise = service.layoutReady; + service.setLayout(new Map([['KeyB', 'b']])); + const layout = await promise; + + expect(layout.size).toBe(1); + expect(layout.get('KeyB')).toBe('b'); + + // Modifying service layout shouldn't affect the resolved copy + service.setLayout(new Map([['KeyC', 'c']])); + expect(layout.size).toBe(1); + expect(layout.get('KeyB')).toBe('b'); + expect(layout.get('KeyC')).toBeUndefined(); + }); + }); }); diff --git a/src/app/core/keyboard-layout/keyboard-layout.service.ts b/src/app/core/keyboard-layout/keyboard-layout.service.ts index 0c2d30f80c..694af6353d 100644 --- a/src/app/core/keyboard-layout/keyboard-layout.service.ts +++ b/src/app/core/keyboard-layout/keyboard-layout.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { Log } from '../log'; export interface NavigatorWithKeyboard { keyboard?: NavigatorKeyboard; @@ -29,6 +30,10 @@ export type KeyboardLayout = Map; }) export class KeyboardLayoutService { private _layout: KeyboardLayout = new Map(); + private _resolveLayoutReady!: (layout: KeyboardLayout) => void; + readonly layoutReady: Promise = new Promise((resolve) => { + this._resolveLayoutReady = resolve; + }); /** * Gets the current keyboard layout map. @@ -46,14 +51,25 @@ export class KeyboardLayoutService { */ async saveUserLayout(): Promise { // If browser doesn't support keyboard API - if (!('keyboard' in navigator)) return; + if (!('keyboard' in navigator)) { + this._resolveLayoutReady(new Map(this._layout)); + return; + } const keyboard = (navigator as NavigatorWithKeyboard).keyboard; - if (!keyboard) return; + if (!keyboard) { + this._resolveLayoutReady(new Map(this._layout)); + return; + } - const kbLayout = await keyboard.getLayoutMap(); - this._layout.clear(); - kbLayout.forEach((value, key) => this._layout.set(key, value)); + try { + const kbLayout = await keyboard.getLayoutMap(); + this._layout.clear(); + kbLayout.forEach((value, key) => this._layout.set(key, value)); + } catch (e) { + Log.err(e); + } + this._resolveLayoutReady(new Map(this._layout)); } /** @@ -71,5 +87,6 @@ export class KeyboardLayoutService { setLayout(layout: KeyboardLayout): void { this._layout.clear(); layout.forEach((value, key) => this._layout.set(key, value)); + this._resolveLayoutReady(new Map(this._layout)); } } diff --git a/src/app/core/util/client-id.service.spec.ts b/src/app/core/util/client-id.service.spec.ts index 24db5c4f63..de0f91523a 100644 --- a/src/app/core/util/client-id.service.spec.ts +++ b/src/app/core/util/client-id.service.spec.ts @@ -82,15 +82,15 @@ describe('ClientIdService', () => { describe('resolution from SUP_OPS', () => { it('returns a populated SUP_OPS id directly without opening pf', async () => { - await seedSupOps('B_H8AR'); + await seedSupOps('B_H8AR42'); const pfSpy = spyOn(service as any, '_readPf').and.callThrough(); - expect(await service.loadClientId()).toBe('B_H8AR'); + expect(await service.loadClientId()).toBe('B_H8AR42'); expect(pfSpy).not.toHaveBeenCalled(); }); it('caches the id after the first load', async () => { - await seedSupOps('B_H8AR'); + await seedSupOps('B_H8AR42'); const first = await service.loadClientId(); // Remove the stored value — a second load must still return the cache. await clearSupOps(); @@ -100,10 +100,10 @@ describe('ClientIdService', () => { describe('one-time migration from legacy pf', () => { it('migrates pf.__client_id_ into SUP_OPS, unchanged', async () => { - await seedPf(PF_CLIENT_ID_KEY, 'B_H8AR'); + await seedPf(PF_CLIENT_ID_KEY, 'B_H8AR42'); - expect(await service.loadClientId()).toBe('B_H8AR'); - expect(await readSupOps()).toBe('B_H8AR'); + expect(await service.loadClientId()).toBe('B_H8AR42'); + expect(await readSupOps()).toBe('B_H8AR42'); }); it('migrates pf.CLIENT_ID when __client_id_ is absent (bridge-ordering gap)', async () => { @@ -114,19 +114,19 @@ describe('ClientIdService', () => { }); it('prefers __client_id_ over CLIENT_ID when both pf keys are valid', async () => { - await seedPf(PF_CLIENT_ID_KEY, 'B_aaaa'); + await seedPf(PF_CLIENT_ID_KEY, 'B_aaaaaa'); await seedPf(PF_LEGACY_CLIENT_ID_KEY, 'LegacyId123456'); - expect(await service.loadClientId()).toBe('B_aaaa'); - expect(await readSupOps()).toBe('B_aaaa'); + expect(await service.loadClientId()).toBe('B_aaaaaa'); + expect(await readSupOps()).toBe('B_aaaaaa'); }); it('lets a valid pf id win over an invalid-format SUP_OPS value', async () => { await seedSupOps('BAD'); - await seedPf(PF_CLIENT_ID_KEY, 'B_good'); + await seedPf(PF_CLIENT_ID_KEY, 'B_goodid'); - expect(await service.loadClientId()).toBe('B_good'); - expect(await readSupOps()).toBe('B_good'); + expect(await service.loadClientId()).toBe('B_goodid'); + expect(await readSupOps()).toBe('B_goodid'); }); }); @@ -137,7 +137,7 @@ describe('ClientIdService', () => { it('getOrGenerateClientId() generates and persists a fresh id', async () => { const id = await service.getOrGenerateClientId(); - expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)).toBeTrue(); + expect(/^[BEAI]_[a-zA-Z0-9]{6}$/.test(id)).toBeTrue(); expect(await readSupOps()).toBe(id); // Persisted: a fresh service instance resolves to the same id. @@ -193,14 +193,14 @@ describe('ClientIdService', () => { }); it('copy-forward write fails: returns the valid pf id, no throw, no generation', async () => { - await seedPf(PF_CLIENT_ID_KEY, 'B_good'); + await seedPf(PF_CLIENT_ID_KEY, 'B_goodid'); spyOn(service as any, '_putClientIdIfAbsent').and.returnValue( Promise.reject(new DOMException('quota', 'QuotaExceededError')), ); - expect(await service.loadClientId()).toBe('B_good'); + expect(await service.loadClientId()).toBe('B_goodid'); service.clearCache(); - expect(await service.getOrGenerateClientId()).toBe('B_good'); + expect(await service.getOrGenerateClientId()).toBe('B_goodid'); // The failed copy means SUP_OPS stays empty — a later launch retries it. expect(await readSupOps()).toBeUndefined(); }); @@ -208,27 +208,27 @@ describe('ClientIdService', () => { describe('getOrGenerateClientId()', () => { it('returns an existing valid SUP_OPS id without generating', async () => { - await seedSupOps('B_H8AR'); - expect(await service.getOrGenerateClientId()).toBe('B_H8AR'); + await seedSupOps('B_H8AR42'); + expect(await service.getOrGenerateClientId()).toBe('B_H8AR42'); }); it('generates when the stored value is an invalid format', async () => { await seedSupOps('BAD'); const id = await service.getOrGenerateClientId(); - expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)).toBeTrue(); + expect(/^[BEAI]_[a-zA-Z0-9]{6}$/.test(id)).toBeTrue(); }); }); describe('persistClientId()', () => { it('writes the id into SUP_OPS and sets the cache', async () => { - await service.persistClientId('E_abcd'); - expect(await readSupOps()).toBe('E_abcd'); + await service.persistClientId('E_abcd66'); + expect(await readSupOps()).toBe('E_abcd66'); // Cache is set — loadClientId() returns it without re-reading. - expect(await service.loadClientId()).toBe('E_abcd'); + expect(await service.loadClientId()).toBe('E_abcd66'); }); it('writes unconditionally, overwriting an existing SUP_OPS id', async () => { - await seedSupOps('B_old1'); + await seedSupOps('B_old111'); await service.persistClientId('LegacyId123456'); expect(await readSupOps()).toBe('LegacyId123456'); }); diff --git a/src/app/core/util/generate-client-id.spec.ts b/src/app/core/util/generate-client-id.spec.ts index 44d878fb01..856e5e4096 100644 --- a/src/app/core/util/generate-client-id.spec.ts +++ b/src/app/core/util/generate-client-id.spec.ts @@ -2,12 +2,12 @@ import { generateClientId, isValidClientIdFormat } from './generate-client-id'; describe('generate-client-id', () => { describe('generateClientId()', () => { - it('produces an id matching the new {platform}_{4-char} format', () => { - expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(generateClientId())).toBeTrue(); + it('produces an id matching the new {platform}_{6-char} format', () => { + expect(/^[BEAI]_[a-zA-Z0-9]{6}$/.test(generateClientId())).toBeTrue(); }); it('produces a distinct id on each call', () => { - // 50 random 4-char base62 ids — a collision is astronomically unlikely. + // 50 random 6-char base62 ids — a collision is astronomically unlikely. const ids = new Set(Array.from({ length: 50 }, () => generateClientId())); expect(ids.size).toBe(50); }); @@ -21,9 +21,9 @@ describe('generate-client-id', () => { describe('isValidClientIdFormat()', () => { it('accepts the new compact format', () => { - expect(isValidClientIdFormat('B_a7Kx')).toBeTrue(); - expect(isValidClientIdFormat('E_0000')).toBeTrue(); - expect(isValidClientIdFormat('I_ZzZz')).toBeTrue(); + expect(isValidClientIdFormat('B_a7Kx9Z')).toBeTrue(); + expect(isValidClientIdFormat('E_000000')).toBeTrue(); + expect(isValidClientIdFormat('I_ZzZzZz')).toBeTrue(); }); it('accepts legacy ids of length >= 10', () => { @@ -34,8 +34,8 @@ describe('generate-client-id', () => { it('rejects short, non-conforming strings', () => { expect(isValidClientIdFormat('BAD')).toBeFalse(); expect(isValidClientIdFormat('')).toBeFalse(); - expect(isValidClientIdFormat('B_a7K')).toBeFalse(); // 3-char suffix - expect(isValidClientIdFormat('X_a7Kx')).toBeFalse(); // unknown platform + expect(isValidClientIdFormat('B_a7Kx9')).toBeFalse(); // 5-char suffix + expect(isValidClientIdFormat('X_a7Kx9Z')).toBeFalse(); // unknown platform }); it('rejects non-string values', () => { diff --git a/src/app/core/util/generate-client-id.ts b/src/app/core/util/generate-client-id.ts index 3a757f59dc..a85e0b86c4 100644 --- a/src/app/core/util/generate-client-id.ts +++ b/src/app/core/util/generate-client-id.ts @@ -52,22 +52,22 @@ const _generateBase62 = (length: number): string => { }; /** - * Generates a compact client ID: {platform}_{4-char-base62}, e.g. "B_a7Kx". + * Generates a compact client ID: {platform}_{6-char-base62}, e.g. "B_a7Kx9Z". */ export const generateClientId = (): string => { - return `${_getEnvironmentId()}_${_generateBase62(4)}`; + return `${_getEnvironmentId()}_${_generateBase62(6)}`; }; /** * Type guard: true if `id` matches a known valid client-ID format. * - Legacy format: any string of length >= 10 (legacy IDs). - * - New format: {platform}_{4-char-base62}, e.g. "B_a7Kx". + * - New format: {platform}_{6-char-base62}, e.g. "B_a7Kx9Z". * * Used to narrow `unknown` values read from IndexedDB. An invalid format is * treated as "absent" rather than fatal — see issue #6197. */ export const isValidClientIdFormat = (id: unknown): id is string => { return ( - typeof id === 'string' && (id.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)) + typeof id === 'string' && (id.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{6}$/.test(id)) ); }; diff --git a/src/app/features/config/form-cfgs/keyboard-form.const.ts b/src/app/features/config/form-cfgs/keyboard-form.const.ts index 67d5dd3159..f864326d6c 100644 --- a/src/app/features/config/form-cfgs/keyboard-form.const.ts +++ b/src/app/features/config/form-cfgs/keyboard-form.const.ts @@ -1,11 +1,11 @@ import { ConfigFormSection, LimitedFormlyFieldConfig } from '../global-config.model'; import { T } from '../../../t.const'; import { IS_ELECTRON } from '../../../app.constants'; -import { KeyboardConfig } from '../keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; /** Builds a single keyboard-shortcut form field (the dominant, repeated shape). */ const kbField = ( - key: keyof KeyboardConfig, + key: keyof KeyboardConfig & (string | number), label: string, ): LimitedFormlyFieldConfig => ({ key, diff --git a/src/app/features/config/form-cfgs/plugin-keyboard-shortcuts.ts b/src/app/features/config/form-cfgs/plugin-keyboard-shortcuts.ts index cdc75d8efc..a7c489bd00 100644 --- a/src/app/features/config/form-cfgs/plugin-keyboard-shortcuts.ts +++ b/src/app/features/config/form-cfgs/plugin-keyboard-shortcuts.ts @@ -1,7 +1,7 @@ import { LimitedFormlyFieldConfig } from '../global-config.model'; import { PluginShortcutCfg } from '../../../plugins/plugin-api.model'; import { T } from '../../../t.const'; -import { KeyboardConfig } from '../keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; export const createPluginShortcutFormItems = ( shortcuts: PluginShortcutCfg[], diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 0eb067dd9b..c8dace3eae 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -3,7 +3,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core'; import { LanguageCode, DateTimeLocale } from '../../core/locale.constants'; import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; import { ProjectCfgFormKey } from '../project/project.model'; -import { KeyboardConfig } from './keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { TaskReminderOptionId } from '../tasks/task.model'; export type AppFeaturesConfig = Readonly<{ @@ -356,7 +356,7 @@ export interface LimitedFormlyFieldConfig extends Omit< FormlyFieldConfig, 'key' > { - key?: keyof FormModel; + key?: keyof FormModel & (string | number); } export type CustomCfgSection = diff --git a/src/app/features/config/keyboard-shortcut.util.ts b/src/app/features/config/keyboard-shortcut.util.ts new file mode 100644 index 0000000000..3fa029c5ca --- /dev/null +++ b/src/app/features/config/keyboard-shortcut.util.ts @@ -0,0 +1,98 @@ +import { KeyboardLayout } from '../../core/keyboard-layout/keyboard-layout.service'; +import { GLOBAL_KEY_CFG_KEYS, KeyboardConfig } from '@sp/keyboard-config'; + +const QWERTY_CODE_MAP: Record = { + Minus: '-', + Equal: '+', + Semicolon: ';', + Comma: ',', + Period: '.', + Slash: '/', + Backquote: '`', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Quote: "'", +}; + +/** + * Maps a single shortcut character to its physical US-QWERTY representation. + * On macOS, Electron's globalShortcut API registers shortcuts by physical keyboard position + * (US-QWERTY), regardless of the user's localized keyboard layout. + * We resolve this by finding the layout key code that produces the user's localized keyName, + * and mapping that key code back to its physical US-QWERTY character. + * + * Note: If multiple key codes map to the same character, the first matching code in the layout + * map's iteration order will be selected. + * + * @see https://github.com/johannesjo/super-productivity/issues/8378 + */ +export const mapShortcutToQwerty = ( + shortcut: string | null | undefined, + layout: KeyboardLayout, +): string | null | undefined => { + if (!shortcut || !layout || !layout.size) return shortcut; + + let keyName = ''; + let modifiersPart = ''; + + if (shortcut.endsWith('++')) { + keyName = '+'; + modifiersPart = shortcut.slice(0, -1); + } else if (shortcut === '+') { + keyName = '+'; + modifiersPart = ''; + } else { + const parts = shortcut.split('+'); + keyName = parts[parts.length - 1]; + modifiersPart = parts.slice(0, -1).join('+') + (parts.length > 1 ? '+' : ''); + } + + if (!keyName) return shortcut; + + let foundCode: string | null = null; + for (const [code, val] of layout.entries()) { + if (val.toUpperCase() === keyName.toUpperCase()) { + foundCode = code; + break; + } + } + + if (!foundCode) { + return shortcut; + } + + let qwertyKey = foundCode; + if (qwertyKey.startsWith('Key')) { + qwertyKey = qwertyKey.substring(3); + } else if (qwertyKey.startsWith('Digit')) { + qwertyKey = qwertyKey.substring(5); + } else if (QWERTY_CODE_MAP[qwertyKey]) { + qwertyKey = QWERTY_CODE_MAP[qwertyKey]; + } + + return modifiersPart + qwertyKey; +}; + +/** + * Maps macOS-specific global shortcuts in KeyboardConfig to their US-QWERTY layout equivalents. + * Only translates properties defined in GLOBAL_KEY_CFG_KEYS (system-wide global shortcuts). + * On macOS, Electron's globalShortcut API registers shortcuts by physical keyboard position. + * + * @see https://github.com/johannesjo/super-productivity/issues/8378 + */ +export const mapKeyboardConfigToQwerty = ( + keyboardCfg: KeyboardConfig, + layout: KeyboardLayout, +): KeyboardConfig => { + const mappedCfg: Record = { ...keyboardCfg }; + + for (const key of GLOBAL_KEY_CFG_KEYS) { + const originalVal = mappedCfg[key]; + if (originalVal) { + mappedCfg[key] = mapShortcutToQwerty(originalVal, layout); + } + } + + return mappedCfg as KeyboardConfig; +}; diff --git a/src/app/features/config/store/global-config.effects.spec.ts b/src/app/features/config/store/global-config.effects.spec.ts index b731277617..d330cd05e7 100644 --- a/src/app/features/config/store/global-config.effects.spec.ts +++ b/src/app/features/config/store/global-config.effects.spec.ts @@ -4,6 +4,14 @@ import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { Subject } from 'rxjs'; import { Action } from '@ngrx/store'; import { GlobalConfigEffects } from './global-config.effects'; +import { + mapKeyboardConfigToQwerty, + mapShortcutToQwerty, +} from '../keyboard-shortcut.util'; +import { + KeyboardLayout, + KeyboardLayoutService, +} from '../../../core/keyboard-layout/keyboard-layout.service'; import { DateService } from 'src/app/core/date/date.service'; import { LanguageService } from '../../../core/language/language.service'; import { SnackService } from '../../../core/snack/snack.service'; @@ -13,8 +21,11 @@ import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; import { AppStateActions } from '../../../root-store/app-state/app-state.actions'; import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { selectAllTasks } from '../../tasks/store/task.selectors'; +import { IS_ELECTRON_TOKEN } from '../../../app.constants'; +import { IS_MAC_TOKEN } from '../../../util/is-mac'; describe('GlobalConfigEffects', () => { let effects: GlobalConfigEffects; @@ -22,7 +33,11 @@ describe('GlobalConfigEffects', () => { let dateServiceSpy: jasmine.SpyObj; let store: MockStore; - beforeEach(() => { + const setup = ( + isElectron = false, + isMac = false, + keyboardLayoutService?: KeyboardLayoutService, + ): void => { actions$ = new Subject(); dateServiceSpy = jasmine.createSpyObj('DateService', [ 'setStartOfNextDayDiff', @@ -51,6 +66,12 @@ describe('GlobalConfigEffects', () => { provide: UserProfileService, useValue: { updateDayIdFromRemote: jasmine.createSpy('updateDayIdFromRemote') }, }, + { provide: IS_ELECTRON_TOKEN, useValue: isElectron }, + { provide: IS_MAC_TOKEN, useValue: isMac }, + { + provide: KeyboardLayoutService, + useValue: keyboardLayoutService || new KeyboardLayoutService(), + }, ], }); @@ -58,13 +79,16 @@ describe('GlobalConfigEffects', () => { store.overrideSelector(selectAllTasks, []); effects = TestBed.inject(GlobalConfigEffects); effects.setStartOfNextDayDiffOnLoad.subscribe(); - }); + }; afterEach(() => { - store.resetSelectors(); + if (store) { + store.resetSelectors(); + } }); describe('setStartOfNextDayDiffOnChange', () => { + beforeEach(() => setup()); it('should call setStartOfNextDayDiff when startOfNextDay is set to a non-zero value', () => { const dispatched: Action[] = []; effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a)); @@ -224,6 +248,7 @@ describe('GlobalConfigEffects', () => { }); describe('setStartOfNextDayDiffOnLoad', () => { + beforeEach(() => setup()); it('should call setStartOfNextDayDiff when loadAllData is dispatched', () => { actions$.next( loadAllData({ @@ -292,4 +317,221 @@ describe('GlobalConfigEffects', () => { expect(dateServiceSpy.setStartOfNextDayDiff).toHaveBeenCalledWith('04:00', 4); }); }); + + describe('shortcut mapping logic', () => { + beforeEach(() => setup()); + let mockLayout: KeyboardLayout; + + beforeEach(() => { + // Create a mock keyboard layout representing QWERTZ + mockLayout = new Map([ + ['KeyA', 'a'], + ['KeyB', 'b'], + ['KeyY', 'z'], + ['KeyZ', 'y'], + ['Digit1', '1'], + ['Digit2', '2'], + ['BracketRight', '+'], + ]); + }); + + describe('mapShortcutToQwerty', () => { + it('should handle QWERTZ Y -> Z mapping', () => { + expect(mapShortcutToQwerty('Ctrl+Y', mockLayout)).toBe('Ctrl+Z'); + expect(mapShortcutToQwerty('Ctrl+y', mockLayout)).toBe('Ctrl+Z'); + }); + + it('should handle QWERTZ Z -> Y mapping', () => { + expect(mapShortcutToQwerty('Ctrl+Z', mockLayout)).toBe('Ctrl+Y'); + expect(mapShortcutToQwerty('Ctrl+z', mockLayout)).toBe('Ctrl+Y'); + }); + + it('should handle digit keys', () => { + expect(mapShortcutToQwerty('Ctrl+1', mockLayout)).toBe('Ctrl+1'); + expect(mapShortcutToQwerty('Ctrl+Alt+2', mockLayout)).toBe('Ctrl+Alt+2'); + }); + + it('should map punctuation key according to qwertyCodeMap (BracketRight -> + key on German layout)', () => { + expect(mapShortcutToQwerty('Ctrl++', mockLayout)).toBe('Ctrl+]'); + expect(mapShortcutToQwerty('+', mockLayout)).toBe(']'); + }); + + it('should return unchanged if character is not found in layout', () => { + expect(mapShortcutToQwerty('Ctrl+F2', mockLayout)).toBe('Ctrl+F2'); + expect(mapShortcutToQwerty('Ctrl+Y', new Map())).toBe('Ctrl+Y'); + }); + + it('should return null/undefined/empty inputs unchanged', () => { + expect(mapShortcutToQwerty(null, mockLayout)).toBeNull(); + expect(mapShortcutToQwerty(undefined, mockLayout)).toBeUndefined(); + expect(mapShortcutToQwerty('', mockLayout)).toBe(''); + }); + }); + + describe('mapKeyboardConfigToQwerty', () => { + it('should map keyboard config shortcuts', () => { + const keyboardCfg = { + globalShowHide: 'Ctrl+Y', + globalToggleTaskStart: 'Ctrl++', + globalAddNote: 'Ctrl+F2', + globalAddTask: null, + globalToggleTaskWidget: undefined, + toggleBacklog: 'Ctrl+Y', + } as KeyboardConfig; + + const result = mapKeyboardConfigToQwerty(keyboardCfg, mockLayout); + + expect(result.globalShowHide).toBe('Ctrl+Z'); + expect(result.globalToggleTaskStart).toBe('Ctrl+]'); + expect(result.globalAddNote).toBe('Ctrl+F2'); + expect(result.globalAddTask).toBeNull(); + expect(result.globalToggleTaskWidget).toBeUndefined(); + expect(result.toggleBacklog).toBe('Ctrl+Y'); + }); + }); + }); + + describe('global shortcut effects', () => { + let registerGlobalShortcutsSpy: jasmine.Spy; + + beforeEach(() => { + registerGlobalShortcutsSpy = jasmine.createSpy('registerGlobalShortcuts'); + (window as any).ea = { + registerGlobalShortcuts: registerGlobalShortcutsSpy, + }; + }); + + afterEach(() => { + delete (window as any).ea; + }); + + describe('updateGlobalShortcut$', () => { + it('should register shortcuts in Electron', () => { + setup(true, false); + effects.updateGlobalShortcut$.subscribe(); + + const keyboardCfg = { + ...DEFAULT_GLOBAL_CONFIG.keyboard, + globalShowHide: 'Ctrl+X', + }; + actions$.next( + updateGlobalConfigSection({ + sectionKey: 'keyboard', + sectionCfg: keyboardCfg, + }), + ); + + expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith(keyboardCfg); + }); + + it('should translate shortcuts to QWERTY on macOS Electron', () => { + const keyboardLayoutService = new KeyboardLayoutService(); + setup(true, true, keyboardLayoutService); + keyboardLayoutService.setLayout( + new Map([ + ['KeyY', 'z'], + ['KeyZ', 'y'], + ]), + ); + effects.updateGlobalShortcut$.subscribe(); + + const keyboardCfg = { + ...DEFAULT_GLOBAL_CONFIG.keyboard, + globalShowHide: 'Ctrl+Y', + }; + actions$.next( + updateGlobalConfigSection({ + sectionKey: 'keyboard', + sectionCfg: keyboardCfg, + }), + ); + + // Ctrl+Y on QWERTZ is KeyY, which is Ctrl+Z on QWERTY + expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + globalShowHide: 'Ctrl+Z', + }), + ); + }); + + it('should NOT register shortcuts if NOT in Electron', () => { + setup(false, false); + effects.updateGlobalShortcut$.subscribe(); + actions$.next( + updateGlobalConfigSection({ + sectionKey: 'keyboard', + sectionCfg: DEFAULT_GLOBAL_CONFIG.keyboard, + }), + ); + expect(registerGlobalShortcutsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('registerGlobalShortcutInitially$', () => { + it('should register shortcuts initially in Electron', (done) => { + setup(true, false); + + const keyboardCfg = { + ...DEFAULT_GLOBAL_CONFIG.keyboard, + globalShowHide: 'Ctrl+X', + }; + effects.registerGlobalShortcutInitially$.subscribe(() => { + expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith(keyboardCfg); + done(); + }); + + actions$.next( + loadAllData({ + appDataComplete: { + globalConfig: { + ...DEFAULT_GLOBAL_CONFIG, + keyboard: keyboardCfg, + }, + } as any, + }), + ); + }); + + it('should wait for layout and translate to QWERTY initially on macOS Electron', (done) => { + const keyboardLayoutService = new KeyboardLayoutService(); + setup(true, true, keyboardLayoutService); + + // We don't call setLayout yet, so layoutReady is still pending + const keyboardCfg = { + ...DEFAULT_GLOBAL_CONFIG.keyboard, + globalShowHide: 'Ctrl+Y', + }; + + effects.registerGlobalShortcutInitially$.subscribe(() => { + expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + globalShowHide: 'Ctrl+Z', + }), + ); + done(); + }); + + actions$.next( + loadAllData({ + appDataComplete: { + globalConfig: { + ...DEFAULT_GLOBAL_CONFIG, + keyboard: keyboardCfg, + }, + } as any, + }), + ); + + // Now resolve the layout in a macrotask to ensure the await logic is exercised + setTimeout(() => { + keyboardLayoutService.setLayout( + new Map([ + ['KeyY', 'z'], + ['KeyZ', 'y'], + ]), + ); + }); + }); + }); + }); }); diff --git a/src/app/features/config/store/global-config.effects.ts b/src/app/features/config/store/global-config.effects.ts index 0a226ac0ff..11e6835a32 100644 --- a/src/app/features/config/store/global-config.effects.ts +++ b/src/app/features/config/store/global-config.effects.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { createEffect, ofType } from '@ngrx/effects'; import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; import { + concatMap, distinctUntilChanged, filter, map, @@ -10,19 +11,25 @@ import { withLatestFrom, } from 'rxjs/operators'; import { Action, Store } from '@ngrx/store'; -import { IS_ELECTRON } from '../../../app.constants'; +import { IS_MAC_TOKEN } from '../../../util/is-mac'; +import { + KeyboardLayout, + KeyboardLayoutService, +} from '../../../core/keyboard-layout/keyboard-layout.service'; +import { IS_ELECTRON_TOKEN } from '../../../app.constants'; import { T } from '../../../t.const'; import { LanguageService } from '../../../core/language/language.service'; import { DateService } from '../../../core/date/date.service'; import { SnackService } from '../../../core/snack/snack.service'; import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; -import { KeyboardConfig } from '../keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { updateGlobalConfigSection } from './global-config.actions'; import { selectConfigFeatureState, selectLocalizationConfig, } from './global-config.reducer'; +import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util'; import { AppFeaturesConfig, MiscConfig } from '../global-config.model'; import { UserProfileService } from '../../user-profile/user-profile.service'; import { AppStateActions } from '../../../root-store/app-state/app-state.actions'; @@ -31,6 +38,8 @@ import { selectAllTasks } from '../../tasks/store/task.selectors'; import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config'; import { Log } from '../../../core/log'; +const LAYOUT_DETECTION_TIMEOUT_MS = 1000; + @Injectable() export class GlobalConfigEffects { private _actions$ = inject(LOCAL_ACTIONS); @@ -39,6 +48,9 @@ export class GlobalConfigEffects { private _snackService = inject(SnackService); private _store = inject(Store); private _userProfileService = inject(UserProfileService); + private _keyboardLayoutService = inject(KeyboardLayoutService); + private _isElectron = inject(IS_ELECTRON_TOKEN); + private _isMac = inject(IS_MAC_TOKEN); snackUpdate$ = createEffect( () => @@ -65,9 +77,17 @@ export class GlobalConfigEffects { () => this._actions$.pipe( ofType(updateGlobalConfigSection), - filter(({ sectionKey, sectionCfg }) => IS_ELECTRON && sectionKey === 'keyboard'), + filter( + ({ sectionKey, sectionCfg }) => this._isElectron && sectionKey === 'keyboard', + ), tap(({ sectionKey, sectionCfg }) => { - const keyboardCfg: KeyboardConfig = sectionCfg as KeyboardConfig; + let keyboardCfg: KeyboardConfig = sectionCfg as KeyboardConfig; + if (this._isMac) { + keyboardCfg = mapKeyboardConfigToQwerty( + keyboardCfg, + this._keyboardLayoutService.layout, + ); + } window.ea.registerGlobalShortcuts(keyboardCfg); }), ), @@ -78,13 +98,37 @@ export class GlobalConfigEffects { () => this._actions$.pipe( ofType(loadAllData), - filter(() => IS_ELECTRON), - tap((action) => { + filter(() => this._isElectron), + concatMap(async (action) => { const appDataComplete = action.appDataComplete; const keyboardCfg: KeyboardConfig = ( appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG ).keyboard; - window.ea.registerGlobalShortcuts(keyboardCfg); + let layout: KeyboardLayout = new Map(); + if (this._isMac) { + layout = await Promise.race([ + this._keyboardLayoutService.layoutReady, + new Promise((resolve) => { + const timeoutId = setTimeout(() => { + Log.log( + `Layout detection timed out after ${LAYOUT_DETECTION_TIMEOUT_MS}ms. Falling back to empty layout.`, + ); + resolve(new Map()); + }, LAYOUT_DETECTION_TIMEOUT_MS); + void this._keyboardLayoutService.layoutReady.then(() => + clearTimeout(timeoutId), + ); + }), + ]); + } + return { keyboardCfg, layout }; + }), + tap(({ keyboardCfg, layout }) => { + let cfg = keyboardCfg; + if (this._isMac) { + cfg = mapKeyboardConfigToQwerty(keyboardCfg, layout); + } + window.ea.registerGlobalShortcuts(cfg); }), ), { dispatch: false }, @@ -176,35 +220,33 @@ export class GlobalConfigEffects { ), ); - notifyElectronAboutCfgChange = - IS_ELECTRON && - createEffect( - () => - this._actions$.pipe( - ofType(updateGlobalConfigSection), - withLatestFrom(this._store.select(selectConfigFeatureState)), - tap(([action, globalConfig]) => { - // Send the entire settings object to electron for overlay initialization - window.ea.sendSettingsUpdate(globalConfig); - }), - ), - { dispatch: false }, - ); + notifyElectronAboutCfgChange = createEffect( + () => + this._actions$.pipe( + ofType(updateGlobalConfigSection), + filter(() => this._isElectron), + withLatestFrom(this._store.select(selectConfigFeatureState)), + tap(([action, globalConfig]) => { + // Send the entire settings object to electron for overlay initialization + window.ea.sendSettingsUpdate(globalConfig); + }), + ), + { dispatch: false }, + ); - notifyElectronAboutCfgChangeInitially = - IS_ELECTRON && - createEffect( - () => - this._actions$.pipe( - ofType(loadAllData), - tap(({ appDataComplete }) => { - const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG; - // Send initial settings to electron for overlay initialization - window.ea.sendSettingsUpdate(cfg); - }), - ), - { dispatch: false }, - ); + notifyElectronAboutCfgChangeInitially = createEffect( + () => + this._actions$.pipe( + ofType(loadAllData), + filter(() => this._isElectron), + tap(({ appDataComplete }) => { + const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG; + // Send initial settings to electron for overlay initialization + window.ea.sendSettingsUpdate(cfg); + }), + ), + { dispatch: false }, + ); // Handle user profiles being enabled/disabled handleUserProfilesToggle = createEffect( diff --git a/src/app/features/config/store/global-config.reducer.ts b/src/app/features/config/store/global-config.reducer.ts index 5270e8e23b..6be569d29b 100644 --- a/src/app/features/config/store/global-config.reducer.ts +++ b/src/app/features/config/store/global-config.reducer.ts @@ -13,7 +13,7 @@ import { MiscConfig, SyncConfig, } from '../global-config.model'; -import type { KeyboardConfig } from '../keyboard-config.model'; +import type { KeyboardConfig } from '@sp/keyboard-config'; import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { getHoursFromClockString } from '../../../util/get-hours-from-clock-string'; diff --git a/src/app/features/shepherd/shepherd-steps.const.ts b/src/app/features/shepherd/shepherd-steps.const.ts index 2cd19e3657..74b8bf383f 100644 --- a/src/app/features/shepherd/shepherd-steps.const.ts +++ b/src/app/features/shepherd/shepherd-steps.const.ts @@ -9,7 +9,7 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions'; import { GlobalConfigState } from '../config/global-config.model'; import { promiseTimeout } from '../../util/promise-timeout'; import { hideAddTaskBar } from '../../core-ui/layout/store/layout.actions'; -import { KeyboardConfig } from '../config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { WorkContextService } from '../work-context/work-context.service'; import { ShepherdService } from './shepherd.service'; import { Observable } from 'rxjs'; diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts index a88687bf1f..c42dc62bc3 100644 --- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts +++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts @@ -46,7 +46,7 @@ import { ProjectService } from '../../../project/project.service'; import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../../project/project.const'; import { WorkContextService } from '../../../work-context/work-context.service'; import { GlobalConfigService } from '../../../config/global-config.service'; -import { KeyboardConfig } from '../../../config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { DialogScheduleTaskComponent } from '../../../planner/dialog-schedule-task/dialog-schedule-task.component'; import { DialogDeadlineComponent } from '../../dialog-deadline/dialog-deadline.component'; import { DialogTimeEstimateComponent } from '../../dialog-time-estimate/dialog-time-estimate.component'; diff --git a/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.ts b/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.ts index c6b181af64..48e9458889 100644 --- a/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.ts +++ b/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.ts @@ -11,7 +11,7 @@ import { TaskWithSubTasks } from '../../task.model'; import { T } from 'src/app/t.const'; import { TaskComponent } from '../task.component'; import { TranslateModule } from '@ngx-translate/core'; -import { KeyboardConfig } from '../../../config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { ICAL_TYPE } from '../../../issue/issue.const'; import { MatIconButton } from '@angular/material/button'; import { GlobalConfigService } from '../../../config/global-config.service'; diff --git a/src/app/features/tasks/task/task.component.ts b/src/app/features/tasks/task/task.component.ts index 8998514be3..6e8316870a 100644 --- a/src/app/features/tasks/task/task.component.ts +++ b/src/app/features/tasks/task/task.component.ts @@ -66,7 +66,7 @@ import { DateService } from '../../../core/date/date.service'; import { isTouchActive } from '../../../util/input-intent'; import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary'; import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants'; -import { KeyboardConfig } from '../../config/keyboard-config.model'; +import { KeyboardConfig } from '@sp/keyboard-config'; import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component'; import { PlannerActions } from '../../planner/store/planner.actions'; import { PlannerService } from '../../planner/planner.service'; diff --git a/src/app/op-log/backup/backup.service.spec.ts b/src/app/op-log/backup/backup.service.spec.ts index 865eafef97..8b5e85ea73 100644 --- a/src/app/op-log/backup/backup.service.spec.ts +++ b/src/app/op-log/backup/backup.service.spec.ts @@ -389,7 +389,7 @@ describe('BackupService', () => { .args[0] as Parameters[0]; // The clientId is freshly minted (generateClientId) — new compact format. const op = args.syncImportOp; - expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/); expect(op.vectorClock).toEqual({ [op.clientId]: 1 }); }); @@ -401,7 +401,7 @@ describe('BackupService', () => { const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() .args[0] as Parameters[0]; const op = args.syncImportOp; - expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/); expect(op.vectorClock).toEqual({ [op.clientId]: 1 }); }); diff --git a/src/app/op-log/clean-slate/clean-slate.service.spec.ts b/src/app/op-log/clean-slate/clean-slate.service.spec.ts index 647e3c2f86..011f8a5f20 100644 --- a/src/app/op-log/clean-slate/clean-slate.service.spec.ts +++ b/src/app/op-log/clean-slate/clean-slate.service.spec.ts @@ -81,7 +81,7 @@ describe('CleanSlateService', () => { expect(appendedOp.payload).toBe(mockState); // The clientId is freshly minted (generateClientId) — new compact format. // runDestructiveStateReplacement persists it inside its atomic tx. - expect(appendedOp.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(appendedOp.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/); expect(appendedOp.vectorClock).toEqual({ [appendedOp.clientId]: 1 }); expect(appendedOp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); }); diff --git a/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts b/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts index e44d2ae999..9b82175cc4 100644 --- a/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts +++ b/src/app/op-log/testing/integration/clean-slate-interrupt.integration.spec.ts @@ -89,7 +89,7 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => { STORE_NAMES.CLIENT_ID, SINGLETON_KEY, ); - expect(rotatedId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); + expect(rotatedId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/); }); }); diff --git a/src/app/util/is-mac.ts b/src/app/util/is-mac.ts index 96cf1bc3d1..e64f4cff58 100644 --- a/src/app/util/is-mac.ts +++ b/src/app/util/is-mac.ts @@ -1 +1,13 @@ +import { InjectionToken } from '@angular/core'; + export const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + +/** + * Injection token for IS_MAC to enable testing. + * New DI-tested effects/services should prefer this token over the IS_MAC + * constant to ensure testability and prevent logic drift. + */ +export const IS_MAC_TOKEN = new InjectionToken('IS_MAC', { + providedIn: 'root', + factory: () => IS_MAC, +}); diff --git a/src/tsconfig.spec.json b/src/tsconfig.spec.json index a3e2365203..07bd753a58 100644 --- a/src/tsconfig.spec.json +++ b/src/tsconfig.spec.json @@ -24,7 +24,8 @@ "packages/sync-providers/src/provider-types.ts" ], "@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"], - "@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"] + "@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"], + "@sp/keyboard-config": ["electron/shared-with-frontend/keyboard-config.model.ts"] } }, "files": ["test.ts", "polyfills.ts"], diff --git a/tsconfig.base.json b/tsconfig.base.json index 5616cfa1c9..67c1612bcb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,7 +43,8 @@ "packages/sync-providers/src/provider-types.ts" ], "@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"], - "@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"] + "@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"], + "@sp/keyboard-config": ["electron/shared-with-frontend/keyboard-config.model.ts"] }, "plugins": [ {