mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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:
parent
807d3bf5e5
commit
ec16757c82
34 changed files with 601 additions and 122 deletions
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
2
electron/electronAPI.d.ts
vendored
2
electron/electronAPI.d.ts
vendored
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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[],
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
98
src/app/features/config/keyboard-shortcut.util.ts
Normal file
98
src/app/features/config/keyboard-shortcut.util.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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'],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue