fix(keyboard): resolve macOS global shortcut layout mismatch (#8378) (#8381)

* fix(keyboard): resolve macOS global shortcut layout mismatch (#8378)

* fix(keyboard-layout): log layout-detection failure and resolve layoutReady with map copy

* fix(keyboard-shortcut): remove debug console logs and add macOS scope comment

* fix(keyboard-shortcut): preserve modifier separator when mapping plus key shortcut

* refactor(keyboard-shortcut): export mapping helpers and avoid as any cast in configuration mapping

* test(keyboard-shortcut): add unit tests for layout shortcut translation logic

* test(keyboard-shortcut): use correct KeyboardConfig type instead of as any in test fixture

* docs(keyboard-shortcut): hoist macOS physical shortcut layout comments to helper JSDoc

* refactor(config): extract global shortcut keys and mapping helpers

* test: add test helper capability for IS_ELECTRON and IS_MAC

* test: add comprehensive integration unit tests for global shortcut effects

* feat(electron): eagerly trigger layout detection on Electron startup for macOS timing fix

* refactor(config): InjectionToken migration, layout detection hardening, and startup optimization

* refactor: address non-blocking suggestions for layout detection and DI tokens

* build(electron): move keyboard-config.model to shared-with-frontend to fix ASAR require

* fix(client-id): increase entropy to 6 chars and fix related test regressions
This commit is contained in:
Maikel Hajiabadi 2026-06-17 12:57:47 +02:00 committed by GitHub
parent 807d3bf5e5
commit ec16757c82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 601 additions and 122 deletions

View file

@ -139,7 +139,7 @@ Specifically for the **Next Month** shortcut, the task is scheduled for the **1s
**Where shortcuts are defined:** **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). - Default mappings: `default-global-config.const.ts` (keyboard section).
- Settings form (which actions appear and their labels): `keyboard-form.const.ts`. - Settings form (which actions appear and their labels): `keyboard-form.const.ts`.

View file

@ -4,7 +4,7 @@ import {
TakeABreakConfig, TakeABreakConfig,
TaskWidgetConfig, TaskWidgetConfig,
} from '../src/app/features/config/global-config.model'; } 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 { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model';
import { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model'; import { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model';
import { Task } from '../src/app/features/tasks/task.model'; import { Task } from '../src/app/features/tasks/task.model';

View file

@ -1,6 +1,9 @@
import { globalShortcut, ipcMain } from 'electron'; import { globalShortcut, ipcMain } from 'electron';
import { IPC } from '../shared-with-frontend/ipc-events.const'; 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 { getWin, setWasMaximizedBeforeHide } from '../main-window';
import { toggleTaskWidgetVisibility } from '../task-widget/task-widget'; import { toggleTaskWidgetVisibility } from '../task-widget/task-widget';
import { showOrFocus } from '../various-shared'; import { showOrFocus } from '../various-shared';
@ -18,13 +21,6 @@ export const initGlobalShortcutsIpc = (): void => {
const registerShowAppShortCuts = (cfg: KeyboardConfig): void => { const registerShowAppShortCuts = (cfg: KeyboardConfig): void => {
// unregister all previous // unregister all previous
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
const GLOBAL_KEY_CFG_KEYS: (keyof KeyboardConfig)[] = [
'globalShowHide',
'globalToggleTaskStart',
'globalAddNote',
'globalAddTask',
'globalToggleTaskWidget',
];
if (cfg) { if (cfg) {
const mainWin = getWin(); const mainWin = getWin();

View file

@ -58,3 +58,11 @@ export type KeyboardConfig = Readonly<{
// Dynamic plugin shortcuts - added at runtime // Dynamic plugin shortcuts - added at runtime
[key: `plugin_${string}`]: string | null; [key: `plugin_${string}`]: string | null;
}>; }>;
export const GLOBAL_KEY_CFG_KEYS: (keyof KeyboardConfig)[] = [
'globalShowHide',
'globalToggleTaskStart',
'globalAddNote',
'globalAddTask',
'globalToggleTaskWidget',
];

View file

@ -8,16 +8,6 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"skipLibCheck": 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"], "typeRoots": ["node_modules/@types"],
"downlevelIteration": true, "downlevelIteration": true,
"lib": ["dom", "es2022"], "lib": ["dom", "es2022"],
@ -45,7 +35,8 @@
"packages/sync-providers/dist/provider-types.d.ts" "packages/sync-providers/dist/provider-types.d.ts"
], ],
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.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"], "include": ["main.ts", "**/*.ts", "../src/app/core/window-ea.d.ts"],

View file

@ -21,6 +21,7 @@ import { TaskWidgetSettingsService } from './features/config/task-widget-setting
import { LayoutService } from './core-ui/layout/layout.service'; import { LayoutService } from './core-ui/layout/layout.service';
import { SnackService } from './core/snack/snack.service'; import { SnackService } from './core/snack/snack.service';
import { IS_ELECTRON } from './app.constants'; import { IS_ELECTRON } from './app.constants';
import { IS_MAC } from './util/is-mac';
import { expandAnimation } from './ui/animations/expand.ani'; import { expandAnimation } from './ui/animations/expand.ani';
import { warpRouteAnimation } from './ui/animations/warp-route'; import { warpRouteAnimation } from './ui/animations/warp-route';
import { firstValueFrom, Subscription } from 'rxjs'; 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 // ! 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 // Connect the service to the utility functions
setKeyboardLayoutService(this._keyboardLayoutService); setKeyboardLayoutService(this._keyboardLayoutService);
// Defer keyboard layout detection to idle time for better initial load performance // Defer keyboard layout detection to idle time for better initial load performance,
if (typeof requestIdleCallback === 'function') { // 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()); requestIdleCallback(() => this._keyboardLayoutService.saveUserLayout());
} else { } else {
setTimeout(() => this._keyboardLayoutService.saveUserLayout(), 0); setTimeout(() => this._keyboardLayoutService.saveUserLayout(), 0);

View file

@ -1,6 +1,17 @@
import { InjectionToken } from '@angular/core';
import { IS_ANDROID_WEB_VIEW } from './util/is-android-web-view'; import { IS_ANDROID_WEB_VIEW } from './util/is-android-web-view';
export const IS_ELECTRON = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1; 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<boolean>('IS_ELECTRON', {
providedIn: 'root',
factory: () => IS_ELECTRON,
});
// effectively IS_BROWSER // effectively IS_BROWSER
export const IS_WEB_BROWSER = !IS_ELECTRON && !IS_ANDROID_WEB_VIEW; export const IS_WEB_BROWSER = !IS_ELECTRON && !IS_ANDROID_WEB_VIEW;
export const IS_GNOME_DESKTOP = IS_ELECTRON && window.ea.isGnomeDesktop(); export const IS_GNOME_DESKTOP = IS_ELECTRON && window.ea.isGnomeDesktop();

View file

@ -11,7 +11,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { LayoutService } from '../../layout/layout.service'; import { LayoutService } from '../../layout/layout.service';
import { T } from '../../../t.const'; 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 { GlobalConfigService } from '../../../features/config/global-config.service';
@Component({ @Component({

View file

@ -8,7 +8,7 @@ import { T } from '../../../t.const';
import { FocusModeService } from '../../../features/focus-mode/focus-mode.service'; import { FocusModeService } from '../../../features/focus-mode/focus-mode.service';
import { MetricService } from '../../../features/metric/metric.service'; import { MetricService } from '../../../features/metric/metric.service';
import { GlobalConfigService } from '../../../features/config/global-config.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 { DateService } from '../../../core/date/date.service';
import { FocusModeMode } from '../../../features/focus-mode/focus-mode.model'; import { FocusModeMode } from '../../../features/focus-mode/focus-mode.model';
import { ProgressCircleComponent } from '../../../ui/progress-circle/progress-circle.component'; import { ProgressCircleComponent } from '../../../ui/progress-circle/progress-circle.component';

View file

@ -23,7 +23,7 @@ import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service';
import { SnackService } from '../../core/snack/snack.service'; import { SnackService } from '../../core/snack/snack.service';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { GlobalConfigService } from '../../features/config/global-config.service'; 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 { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';

View file

@ -14,7 +14,7 @@ import { LayoutService } from '../../layout/layout.service';
import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.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 { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
import { T } from '../../../t.const'; 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 { GlobalConfigService } from '../../../features/config/global-config.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import {

View file

@ -15,7 +15,7 @@ import { WorkContextService } from '../../../features/work-context/work-context.
import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.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 { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
import { GlobalConfigService } from '../../../features/config/global-config.service'; import { GlobalConfigService } from '../../../features/config/global-config.service';
import { KeyboardConfig } from '../../../features/config/keyboard-config.model'; import { KeyboardConfig } from '@sp/keyboard-config';
@Component({ @Component({
selector: 'page-title', selector: 'page-title',

View file

@ -194,4 +194,60 @@ describe('KeyboardLayoutService', () => {
expect(service.layout.has('KeyX')).toBe(false); 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();
});
});
}); });

View file

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Log } from '../log';
export interface NavigatorWithKeyboard { export interface NavigatorWithKeyboard {
keyboard?: NavigatorKeyboard; keyboard?: NavigatorKeyboard;
@ -29,6 +30,10 @@ export type KeyboardLayout = Map<string, string>;
}) })
export class KeyboardLayoutService { export class KeyboardLayoutService {
private _layout: KeyboardLayout = new Map(); private _layout: KeyboardLayout = new Map();
private _resolveLayoutReady!: (layout: KeyboardLayout) => void;
readonly layoutReady: Promise<KeyboardLayout> = new Promise((resolve) => {
this._resolveLayoutReady = resolve;
});
/** /**
* Gets the current keyboard layout map. * Gets the current keyboard layout map.
@ -46,14 +51,25 @@ export class KeyboardLayoutService {
*/ */
async saveUserLayout(): Promise<void> { async saveUserLayout(): Promise<void> {
// If browser doesn't support keyboard API // 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; const keyboard = (navigator as NavigatorWithKeyboard).keyboard;
if (!keyboard) return; if (!keyboard) {
this._resolveLayoutReady(new Map(this._layout));
return;
}
const kbLayout = await keyboard.getLayoutMap(); try {
this._layout.clear(); const kbLayout = await keyboard.getLayoutMap();
kbLayout.forEach((value, key) => this._layout.set(key, value)); 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 { setLayout(layout: KeyboardLayout): void {
this._layout.clear(); this._layout.clear();
layout.forEach((value, key) => this._layout.set(key, value)); layout.forEach((value, key) => this._layout.set(key, value));
this._resolveLayoutReady(new Map(this._layout));
} }
} }

View file

@ -82,15 +82,15 @@ describe('ClientIdService', () => {
describe('resolution from SUP_OPS', () => { describe('resolution from SUP_OPS', () => {
it('returns a populated SUP_OPS id directly without opening pf', async () => { 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(); 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(); expect(pfSpy).not.toHaveBeenCalled();
}); });
it('caches the id after the first load', async () => { it('caches the id after the first load', async () => {
await seedSupOps('B_H8AR'); await seedSupOps('B_H8AR42');
const first = await service.loadClientId(); const first = await service.loadClientId();
// Remove the stored value — a second load must still return the cache. // Remove the stored value — a second load must still return the cache.
await clearSupOps(); await clearSupOps();
@ -100,10 +100,10 @@ describe('ClientIdService', () => {
describe('one-time migration from legacy pf', () => { describe('one-time migration from legacy pf', () => {
it('migrates pf.__client_id_ into SUP_OPS, unchanged', async () => { 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 service.loadClientId()).toBe('B_H8AR42');
expect(await readSupOps()).toBe('B_H8AR'); expect(await readSupOps()).toBe('B_H8AR42');
}); });
it('migrates pf.CLIENT_ID when __client_id_ is absent (bridge-ordering gap)', async () => { 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 () => { 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'); await seedPf(PF_LEGACY_CLIENT_ID_KEY, 'LegacyId123456');
expect(await service.loadClientId()).toBe('B_aaaa'); expect(await service.loadClientId()).toBe('B_aaaaaa');
expect(await readSupOps()).toBe('B_aaaa'); expect(await readSupOps()).toBe('B_aaaaaa');
}); });
it('lets a valid pf id win over an invalid-format SUP_OPS value', async () => { it('lets a valid pf id win over an invalid-format SUP_OPS value', async () => {
await seedSupOps('BAD'); 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 service.loadClientId()).toBe('B_goodid');
expect(await readSupOps()).toBe('B_good'); expect(await readSupOps()).toBe('B_goodid');
}); });
}); });
@ -137,7 +137,7 @@ describe('ClientIdService', () => {
it('getOrGenerateClientId() generates and persists a fresh id', async () => { it('getOrGenerateClientId() generates and persists a fresh id', async () => {
const id = await service.getOrGenerateClientId(); 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); expect(await readSupOps()).toBe(id);
// Persisted: a fresh service instance resolves to the same 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 () => { 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( spyOn(service as any, '_putClientIdIfAbsent').and.returnValue(
Promise.reject(new DOMException('quota', 'QuotaExceededError')), Promise.reject(new DOMException('quota', 'QuotaExceededError')),
); );
expect(await service.loadClientId()).toBe('B_good'); expect(await service.loadClientId()).toBe('B_goodid');
service.clearCache(); 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. // The failed copy means SUP_OPS stays empty — a later launch retries it.
expect(await readSupOps()).toBeUndefined(); expect(await readSupOps()).toBeUndefined();
}); });
@ -208,27 +208,27 @@ describe('ClientIdService', () => {
describe('getOrGenerateClientId()', () => { describe('getOrGenerateClientId()', () => {
it('returns an existing valid SUP_OPS id without generating', async () => { it('returns an existing valid SUP_OPS id without generating', async () => {
await seedSupOps('B_H8AR'); await seedSupOps('B_H8AR42');
expect(await service.getOrGenerateClientId()).toBe('B_H8AR'); expect(await service.getOrGenerateClientId()).toBe('B_H8AR42');
}); });
it('generates when the stored value is an invalid format', async () => { it('generates when the stored value is an invalid format', async () => {
await seedSupOps('BAD'); await seedSupOps('BAD');
const id = await service.getOrGenerateClientId(); 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()', () => { describe('persistClientId()', () => {
it('writes the id into SUP_OPS and sets the cache', async () => { it('writes the id into SUP_OPS and sets the cache', async () => {
await service.persistClientId('E_abcd'); await service.persistClientId('E_abcd66');
expect(await readSupOps()).toBe('E_abcd'); expect(await readSupOps()).toBe('E_abcd66');
// Cache is set — loadClientId() returns it without re-reading. // 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 () => { it('writes unconditionally, overwriting an existing SUP_OPS id', async () => {
await seedSupOps('B_old1'); await seedSupOps('B_old111');
await service.persistClientId('LegacyId123456'); await service.persistClientId('LegacyId123456');
expect(await readSupOps()).toBe('LegacyId123456'); expect(await readSupOps()).toBe('LegacyId123456');
}); });

View file

@ -2,12 +2,12 @@ import { generateClientId, isValidClientIdFormat } from './generate-client-id';
describe('generate-client-id', () => { describe('generate-client-id', () => {
describe('generateClientId()', () => { describe('generateClientId()', () => {
it('produces an id matching the new {platform}_{4-char} format', () => { it('produces an id matching the new {platform}_{6-char} format', () => {
expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(generateClientId())).toBeTrue(); expect(/^[BEAI]_[a-zA-Z0-9]{6}$/.test(generateClientId())).toBeTrue();
}); });
it('produces a distinct id on each call', () => { 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())); const ids = new Set(Array.from({ length: 50 }, () => generateClientId()));
expect(ids.size).toBe(50); expect(ids.size).toBe(50);
}); });
@ -21,9 +21,9 @@ describe('generate-client-id', () => {
describe('isValidClientIdFormat()', () => { describe('isValidClientIdFormat()', () => {
it('accepts the new compact format', () => { it('accepts the new compact format', () => {
expect(isValidClientIdFormat('B_a7Kx')).toBeTrue(); expect(isValidClientIdFormat('B_a7Kx9Z')).toBeTrue();
expect(isValidClientIdFormat('E_0000')).toBeTrue(); expect(isValidClientIdFormat('E_000000')).toBeTrue();
expect(isValidClientIdFormat('I_ZzZz')).toBeTrue(); expect(isValidClientIdFormat('I_ZzZzZz')).toBeTrue();
}); });
it('accepts legacy ids of length >= 10', () => { it('accepts legacy ids of length >= 10', () => {
@ -34,8 +34,8 @@ describe('generate-client-id', () => {
it('rejects short, non-conforming strings', () => { it('rejects short, non-conforming strings', () => {
expect(isValidClientIdFormat('BAD')).toBeFalse(); expect(isValidClientIdFormat('BAD')).toBeFalse();
expect(isValidClientIdFormat('')).toBeFalse(); expect(isValidClientIdFormat('')).toBeFalse();
expect(isValidClientIdFormat('B_a7K')).toBeFalse(); // 3-char suffix expect(isValidClientIdFormat('B_a7Kx9')).toBeFalse(); // 5-char suffix
expect(isValidClientIdFormat('X_a7Kx')).toBeFalse(); // unknown platform expect(isValidClientIdFormat('X_a7Kx9Z')).toBeFalse(); // unknown platform
}); });
it('rejects non-string values', () => { it('rejects non-string values', () => {

View file

@ -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 => { export const generateClientId = (): string => {
return `${_getEnvironmentId()}_${_generateBase62(4)}`; return `${_getEnvironmentId()}_${_generateBase62(6)}`;
}; };
/** /**
* Type guard: true if `id` matches a known valid client-ID format. * Type guard: true if `id` matches a known valid client-ID format.
* - Legacy format: any string of length >= 10 (legacy IDs). * - 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 * Used to narrow `unknown` values read from IndexedDB. An invalid format is
* treated as "absent" rather than fatal see issue #6197. * treated as "absent" rather than fatal see issue #6197.
*/ */
export const isValidClientIdFormat = (id: unknown): id is string => { export const isValidClientIdFormat = (id: unknown): id is string => {
return ( 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))
); );
}; };

View file

@ -1,11 +1,11 @@
import { ConfigFormSection, LimitedFormlyFieldConfig } from '../global-config.model'; import { ConfigFormSection, LimitedFormlyFieldConfig } from '../global-config.model';
import { T } from '../../../t.const'; import { T } from '../../../t.const';
import { IS_ELECTRON } from '../../../app.constants'; 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). */ /** Builds a single keyboard-shortcut form field (the dominant, repeated shape). */
const kbField = ( const kbField = (
key: keyof KeyboardConfig, key: keyof KeyboardConfig & (string | number),
label: string, label: string,
): LimitedFormlyFieldConfig<KeyboardConfig> => ({ ): LimitedFormlyFieldConfig<KeyboardConfig> => ({
key, key,

View file

@ -1,7 +1,7 @@
import { LimitedFormlyFieldConfig } from '../global-config.model'; import { LimitedFormlyFieldConfig } from '../global-config.model';
import { PluginShortcutCfg } from '../../../plugins/plugin-api.model'; import { PluginShortcutCfg } from '../../../plugins/plugin-api.model';
import { T } from '../../../t.const'; import { T } from '../../../t.const';
import { KeyboardConfig } from '../keyboard-config.model'; import { KeyboardConfig } from '@sp/keyboard-config';
export const createPluginShortcutFormItems = ( export const createPluginShortcutFormItems = (
shortcuts: PluginShortcutCfg[], shortcuts: PluginShortcutCfg[],

View file

@ -3,7 +3,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core';
import { LanguageCode, DateTimeLocale } from '../../core/locale.constants'; import { LanguageCode, DateTimeLocale } from '../../core/locale.constants';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { ProjectCfgFormKey } from '../project/project.model'; import { ProjectCfgFormKey } from '../project/project.model';
import { KeyboardConfig } from './keyboard-config.model'; import { KeyboardConfig } from '@sp/keyboard-config';
import { TaskReminderOptionId } from '../tasks/task.model'; import { TaskReminderOptionId } from '../tasks/task.model';
export type AppFeaturesConfig = Readonly<{ export type AppFeaturesConfig = Readonly<{
@ -356,7 +356,7 @@ export interface LimitedFormlyFieldConfig<FormModel> extends Omit<
FormlyFieldConfig, FormlyFieldConfig,
'key' 'key'
> { > {
key?: keyof FormModel; key?: keyof FormModel & (string | number);
} }
export type CustomCfgSection = export type CustomCfgSection =

View file

@ -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<string, string> = {
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<string, string | null | undefined> = { ...keyboardCfg };
for (const key of GLOBAL_KEY_CFG_KEYS) {
const originalVal = mappedCfg[key];
if (originalVal) {
mappedCfg[key] = mapShortcutToQwerty(originalVal, layout);
}
}
return mappedCfg as KeyboardConfig;
};

View file

@ -4,6 +4,14 @@ import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { GlobalConfigEffects } from './global-config.effects'; 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 { DateService } from 'src/app/core/date/date.service';
import { LanguageService } from '../../../core/language/language.service'; import { LanguageService } from '../../../core/language/language.service';
import { SnackService } from '../../../core/snack/snack.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 { AppStateActions } from '../../../root-store/app-state/app-state.actions';
import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; 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 { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { selectAllTasks } from '../../tasks/store/task.selectors'; import { selectAllTasks } from '../../tasks/store/task.selectors';
import { IS_ELECTRON_TOKEN } from '../../../app.constants';
import { IS_MAC_TOKEN } from '../../../util/is-mac';
describe('GlobalConfigEffects', () => { describe('GlobalConfigEffects', () => {
let effects: GlobalConfigEffects; let effects: GlobalConfigEffects;
@ -22,7 +33,11 @@ describe('GlobalConfigEffects', () => {
let dateServiceSpy: jasmine.SpyObj<DateService>; let dateServiceSpy: jasmine.SpyObj<DateService>;
let store: MockStore; let store: MockStore;
beforeEach(() => { const setup = (
isElectron = false,
isMac = false,
keyboardLayoutService?: KeyboardLayoutService,
): void => {
actions$ = new Subject<Action>(); actions$ = new Subject<Action>();
dateServiceSpy = jasmine.createSpyObj('DateService', [ dateServiceSpy = jasmine.createSpyObj('DateService', [
'setStartOfNextDayDiff', 'setStartOfNextDayDiff',
@ -51,6 +66,12 @@ describe('GlobalConfigEffects', () => {
provide: UserProfileService, provide: UserProfileService,
useValue: { updateDayIdFromRemote: jasmine.createSpy('updateDayIdFromRemote') }, 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, []); store.overrideSelector(selectAllTasks, []);
effects = TestBed.inject(GlobalConfigEffects); effects = TestBed.inject(GlobalConfigEffects);
effects.setStartOfNextDayDiffOnLoad.subscribe(); effects.setStartOfNextDayDiffOnLoad.subscribe();
}); };
afterEach(() => { afterEach(() => {
store.resetSelectors(); if (store) {
store.resetSelectors();
}
}); });
describe('setStartOfNextDayDiffOnChange', () => { describe('setStartOfNextDayDiffOnChange', () => {
beforeEach(() => setup());
it('should call setStartOfNextDayDiff when startOfNextDay is set to a non-zero value', () => { it('should call setStartOfNextDayDiff when startOfNextDay is set to a non-zero value', () => {
const dispatched: Action[] = []; const dispatched: Action[] = [];
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a)); effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
@ -224,6 +248,7 @@ describe('GlobalConfigEffects', () => {
}); });
describe('setStartOfNextDayDiffOnLoad', () => { describe('setStartOfNextDayDiffOnLoad', () => {
beforeEach(() => setup());
it('should call setStartOfNextDayDiff when loadAllData is dispatched', () => { it('should call setStartOfNextDayDiff when loadAllData is dispatched', () => {
actions$.next( actions$.next(
loadAllData({ loadAllData({
@ -292,4 +317,221 @@ describe('GlobalConfigEffects', () => {
expect(dateServiceSpy.setStartOfNextDayDiff).toHaveBeenCalledWith('04:00', 4); 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'],
]),
);
});
});
});
});
}); });

View file

@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { createEffect, ofType } from '@ngrx/effects'; import { createEffect, ofType } from '@ngrx/effects';
import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
import { import {
concatMap,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
map, map,
@ -10,19 +11,25 @@ import {
withLatestFrom, withLatestFrom,
} from 'rxjs/operators'; } from 'rxjs/operators';
import { Action, Store } from '@ngrx/store'; 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 { T } from '../../../t.const';
import { LanguageService } from '../../../core/language/language.service'; import { LanguageService } from '../../../core/language/language.service';
import { DateService } from '../../../core/date/date.service'; import { DateService } from '../../../core/date/date.service';
import { SnackService } from '../../../core/snack/snack.service'; import { SnackService } from '../../../core/snack/snack.service';
import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; 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 { updateGlobalConfigSection } from './global-config.actions';
import { import {
selectConfigFeatureState, selectConfigFeatureState,
selectLocalizationConfig, selectLocalizationConfig,
} from './global-config.reducer'; } from './global-config.reducer';
import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util';
import { AppFeaturesConfig, MiscConfig } from '../global-config.model'; import { AppFeaturesConfig, MiscConfig } from '../global-config.model';
import { UserProfileService } from '../../user-profile/user-profile.service'; import { UserProfileService } from '../../user-profile/user-profile.service';
import { AppStateActions } from '../../../root-store/app-state/app-state.actions'; 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 { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config';
import { Log } from '../../../core/log'; import { Log } from '../../../core/log';
const LAYOUT_DETECTION_TIMEOUT_MS = 1000;
@Injectable() @Injectable()
export class GlobalConfigEffects { export class GlobalConfigEffects {
private _actions$ = inject(LOCAL_ACTIONS); private _actions$ = inject(LOCAL_ACTIONS);
@ -39,6 +48,9 @@ export class GlobalConfigEffects {
private _snackService = inject(SnackService); private _snackService = inject(SnackService);
private _store = inject(Store); private _store = inject(Store);
private _userProfileService = inject(UserProfileService); private _userProfileService = inject(UserProfileService);
private _keyboardLayoutService = inject(KeyboardLayoutService);
private _isElectron = inject(IS_ELECTRON_TOKEN);
private _isMac = inject(IS_MAC_TOKEN);
snackUpdate$ = createEffect( snackUpdate$ = createEffect(
() => () =>
@ -65,9 +77,17 @@ export class GlobalConfigEffects {
() => () =>
this._actions$.pipe( this._actions$.pipe(
ofType(updateGlobalConfigSection), ofType(updateGlobalConfigSection),
filter(({ sectionKey, sectionCfg }) => IS_ELECTRON && sectionKey === 'keyboard'), filter(
({ sectionKey, sectionCfg }) => this._isElectron && sectionKey === 'keyboard',
),
tap(({ sectionKey, sectionCfg }) => { 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); window.ea.registerGlobalShortcuts(keyboardCfg);
}), }),
), ),
@ -78,13 +98,37 @@ export class GlobalConfigEffects {
() => () =>
this._actions$.pipe( this._actions$.pipe(
ofType(loadAllData), ofType(loadAllData),
filter(() => IS_ELECTRON), filter(() => this._isElectron),
tap((action) => { concatMap(async (action) => {
const appDataComplete = action.appDataComplete; const appDataComplete = action.appDataComplete;
const keyboardCfg: KeyboardConfig = ( const keyboardCfg: KeyboardConfig = (
appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG
).keyboard; ).keyboard;
window.ea.registerGlobalShortcuts(keyboardCfg); let layout: KeyboardLayout = new Map();
if (this._isMac) {
layout = await Promise.race([
this._keyboardLayoutService.layoutReady,
new Promise<KeyboardLayout>((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 }, { dispatch: false },
@ -176,35 +220,33 @@ export class GlobalConfigEffects {
), ),
); );
notifyElectronAboutCfgChange = notifyElectronAboutCfgChange = createEffect(
IS_ELECTRON && () =>
createEffect( this._actions$.pipe(
() => ofType(updateGlobalConfigSection),
this._actions$.pipe( filter(() => this._isElectron),
ofType(updateGlobalConfigSection), withLatestFrom(this._store.select(selectConfigFeatureState)),
withLatestFrom(this._store.select(selectConfigFeatureState)), tap(([action, globalConfig]) => {
tap(([action, globalConfig]) => { // Send the entire settings object to electron for overlay initialization
// Send the entire settings object to electron for overlay initialization window.ea.sendSettingsUpdate(globalConfig);
window.ea.sendSettingsUpdate(globalConfig); }),
}), ),
), { dispatch: false },
{ dispatch: false }, );
);
notifyElectronAboutCfgChangeInitially = notifyElectronAboutCfgChangeInitially = createEffect(
IS_ELECTRON && () =>
createEffect( this._actions$.pipe(
() => ofType(loadAllData),
this._actions$.pipe( filter(() => this._isElectron),
ofType(loadAllData), tap(({ appDataComplete }) => {
tap(({ appDataComplete }) => { const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG;
const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG; // Send initial settings to electron for overlay initialization
// Send initial settings to electron for overlay initialization window.ea.sendSettingsUpdate(cfg);
window.ea.sendSettingsUpdate(cfg); }),
}), ),
), { dispatch: false },
{ dispatch: false }, );
);
// Handle user profiles being enabled/disabled // Handle user profiles being enabled/disabled
handleUserProfilesToggle = createEffect( handleUserProfilesToggle = createEffect(

View file

@ -13,7 +13,7 @@ import {
MiscConfig, MiscConfig,
SyncConfig, SyncConfig,
} from '../global-config.model'; } 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 { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { getHoursFromClockString } from '../../../util/get-hours-from-clock-string'; import { getHoursFromClockString } from '../../../util/get-hours-from-clock-string';

View file

@ -9,7 +9,7 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { GlobalConfigState } from '../config/global-config.model'; import { GlobalConfigState } from '../config/global-config.model';
import { promiseTimeout } from '../../util/promise-timeout'; import { promiseTimeout } from '../../util/promise-timeout';
import { hideAddTaskBar } from '../../core-ui/layout/store/layout.actions'; 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 { WorkContextService } from '../work-context/work-context.service';
import { ShepherdService } from './shepherd.service'; import { ShepherdService } from './shepherd.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';

View file

@ -46,7 +46,7 @@ import { ProjectService } from '../../../project/project.service';
import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../../project/project.const'; import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../../project/project.const';
import { WorkContextService } from '../../../work-context/work-context.service'; import { WorkContextService } from '../../../work-context/work-context.service';
import { GlobalConfigService } from '../../../config/global-config.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 { DialogScheduleTaskComponent } from '../../../planner/dialog-schedule-task/dialog-schedule-task.component';
import { DialogDeadlineComponent } from '../../dialog-deadline/dialog-deadline.component'; import { DialogDeadlineComponent } from '../../dialog-deadline/dialog-deadline.component';
import { DialogTimeEstimateComponent } from '../../dialog-time-estimate/dialog-time-estimate.component'; import { DialogTimeEstimateComponent } from '../../dialog-time-estimate/dialog-time-estimate.component';

View file

@ -11,7 +11,7 @@ import { TaskWithSubTasks } from '../../task.model';
import { T } from 'src/app/t.const'; import { T } from 'src/app/t.const';
import { TaskComponent } from '../task.component'; import { TaskComponent } from '../task.component';
import { TranslateModule } from '@ngx-translate/core'; 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 { ICAL_TYPE } from '../../../issue/issue.const';
import { MatIconButton } from '@angular/material/button'; import { MatIconButton } from '@angular/material/button';
import { GlobalConfigService } from '../../../config/global-config.service'; import { GlobalConfigService } from '../../../config/global-config.service';

View file

@ -66,7 +66,7 @@ import { DateService } from '../../../core/date/date.service';
import { isTouchActive } from '../../../util/input-intent'; import { isTouchActive } from '../../../util/input-intent';
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary'; import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants'; 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 { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
import { PlannerActions } from '../../planner/store/planner.actions'; import { PlannerActions } from '../../planner/store/planner.actions';
import { PlannerService } from '../../planner/planner.service'; import { PlannerService } from '../../planner/planner.service';

View file

@ -389,7 +389,7 @@ describe('BackupService', () => {
.args[0] as Parameters<typeof mockOpLogStore.runDestructiveStateReplacement>[0]; .args[0] as Parameters<typeof mockOpLogStore.runDestructiveStateReplacement>[0];
// The clientId is freshly minted (generateClientId) — new compact format. // The clientId is freshly minted (generateClientId) — new compact format.
const op = args.syncImportOp; 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 }); expect(op.vectorClock).toEqual({ [op.clientId]: 1 });
}); });
@ -401,7 +401,7 @@ describe('BackupService', () => {
const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent() const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent()
.args[0] as Parameters<typeof mockOpLogStore.runDestructiveStateReplacement>[0]; .args[0] as Parameters<typeof mockOpLogStore.runDestructiveStateReplacement>[0];
const op = args.syncImportOp; 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 }); expect(op.vectorClock).toEqual({ [op.clientId]: 1 });
}); });

View file

@ -81,7 +81,7 @@ describe('CleanSlateService', () => {
expect(appendedOp.payload).toBe(mockState); expect(appendedOp.payload).toBe(mockState);
// The clientId is freshly minted (generateClientId) — new compact format. // The clientId is freshly minted (generateClientId) — new compact format.
// runDestructiveStateReplacement persists it inside its atomic tx. // 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.vectorClock).toEqual({ [appendedOp.clientId]: 1 });
expect(appendedOp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); expect(appendedOp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
}); });

View file

@ -89,7 +89,7 @@ describe('CleanSlate / Backup interrupt (issue #7709 regression)', () => {
STORE_NAMES.CLIENT_ID, STORE_NAMES.CLIENT_ID,
SINGLETON_KEY, SINGLETON_KEY,
); );
expect(rotatedId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/); expect(rotatedId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/);
}); });
}); });

View file

@ -1 +1,13 @@
import { InjectionToken } from '@angular/core';
export const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0; 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<boolean>('IS_MAC', {
providedIn: 'root',
factory: () => IS_MAC,
});

View file

@ -24,7 +24,8 @@
"packages/sync-providers/src/provider-types.ts" "packages/sync-providers/src/provider-types.ts"
], ],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.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"], "files": ["test.ts", "polyfills.ts"],

View file

@ -43,7 +43,8 @@
"packages/sync-providers/src/provider-types.ts" "packages/sync-providers/src/provider-types.ts"
], ],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.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": [ "plugins": [
{ {