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:**
- Config model: `KeyboardConfig` (keyboard-config.model.ts).
- Config model: `KeyboardConfig` (electron/shared-with-frontend/keyboard-config.model.ts).
- Default mappings: `default-global-config.const.ts` (keyboard section).
- Settings form (which actions appear and their labels): `keyboard-form.const.ts`.

View file

@ -4,7 +4,7 @@ import {
TakeABreakConfig,
TaskWidgetConfig,
} from '../src/app/features/config/global-config.model';
import { KeyboardConfig } from '../src/app/features/config/keyboard-config.model';
import { KeyboardConfig } from './shared-with-frontend/keyboard-config.model';
import { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model';
import { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model';
import { Task } from '../src/app/features/tasks/task.model';

View file

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

View file

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

View file

@ -8,16 +8,6 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"paths": {
"@sp/sync-providers/dropbox": ["node_modules/@sp/sync-providers/dist/dropbox.d.ts"],
"@sp/sync-providers/local-file": [
"node_modules/@sp/sync-providers/dist/local-file.d.ts"
],
"@sp/sync-providers/webdav": ["node_modules/@sp/sync-providers/dist/webdav.d.ts"],
"@sp/sync-providers/super-sync": [
"node_modules/@sp/sync-providers/dist/super-sync.d.ts"
]
},
"typeRoots": ["node_modules/@types"],
"downlevelIteration": true,
"lib": ["dom", "es2022"],
@ -45,7 +35,8 @@
"packages/sync-providers/dist/provider-types.d.ts"
],
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"],
"@sp/sync-providers/onedrive": ["packages/sync-providers/dist/onedrive.d.ts"]
"@sp/sync-providers/onedrive": ["packages/sync-providers/dist/onedrive.d.ts"],
"@sp/keyboard-config": ["electron/shared-with-frontend/keyboard-config.model.ts"]
}
},
"include": ["main.ts", "**/*.ts", "../src/app/core/window-ea.d.ts"],

View file

@ -21,6 +21,7 @@ import { TaskWidgetSettingsService } from './features/config/task-widget-setting
import { LayoutService } from './core-ui/layout/layout.service';
import { SnackService } from './core/snack/snack.service';
import { IS_ELECTRON } from './app.constants';
import { IS_MAC } from './util/is-mac';
import { expandAnimation } from './ui/animations/expand.ani';
import { warpRouteAnimation } from './ui/animations/warp-route';
import { firstValueFrom, Subscription } from 'rxjs';
@ -313,8 +314,11 @@ export class AppComponent implements OnDestroy, AfterViewInit {
// ! For keyboard shortcuts to work correctly with any layouts (QWERTZ/AZERTY/etc) - user's keyboard layout must be presaved
// Connect the service to the utility functions
setKeyboardLayoutService(this._keyboardLayoutService);
// Defer keyboard layout detection to idle time for better initial load performance
if (typeof requestIdleCallback === 'function') {
// Defer keyboard layout detection to idle time for better initial load performance,
// EXCEPT on macOS Electron where it is needed eagerly for the initial global shortcut registration.
if (IS_ELECTRON && IS_MAC) {
void this._keyboardLayoutService.saveUserLayout();
} else if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => this._keyboardLayoutService.saveUserLayout());
} else {
setTimeout(() => this._keyboardLayoutService.saveUserLayout(), 0);

View file

@ -1,6 +1,17 @@
import { InjectionToken } from '@angular/core';
import { IS_ANDROID_WEB_VIEW } from './util/is-android-web-view';
export const IS_ELECTRON = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1;
/**
* Injection token for IS_ELECTRON to enable testing.
* New DI-tested effects/services should prefer this token over the IS_ELECTRON
* constant to ensure testability and prevent logic drift.
*/
export const IS_ELECTRON_TOKEN = new InjectionToken<boolean>('IS_ELECTRON', {
providedIn: 'root',
factory: () => IS_ELECTRON,
});
// effectively IS_BROWSER
export const IS_WEB_BROWSER = !IS_ELECTRON && !IS_ANDROID_WEB_VIEW;
export const IS_GNOME_DESKTOP = IS_ELECTRON && window.ea.isGnomeDesktop();

View file

@ -11,7 +11,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { LayoutService } from '../../layout/layout.service';
import { T } from '../../../t.const';
import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
import { KeyboardConfig } from '@sp/keyboard-config';
import { GlobalConfigService } from '../../../features/config/global-config.service';
@Component({

View file

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

View file

@ -23,7 +23,7 @@ import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service';
import { SnackService } from '../../core/snack/snack.service';
import { NavigationEnd, Router } from '@angular/router';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { KeyboardConfig } from 'src/app/features/config/keyboard-config.model';
import { KeyboardConfig } from '@sp/keyboard-config';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';

View file

@ -14,7 +14,7 @@ import { LayoutService } from '../../layout/layout.service';
import { TaskViewCustomizerService } from '../../../features/task-view-customizer/task-view-customizer.service';
import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
import { T } from '../../../t.const';
import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
import { KeyboardConfig } from '@sp/keyboard-config';
import { GlobalConfigService } from '../../../features/config/global-config.service';
import { Store } from '@ngrx/store';
import {

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

View file

@ -194,4 +194,60 @@ describe('KeyboardLayoutService', () => {
expect(service.layout.has('KeyX')).toBe(false);
});
});
describe('layoutReady promise', () => {
const originalNavigator = globalThis.navigator;
afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
writable: true,
configurable: true,
});
});
it('should resolve when layout is saved', async () => {
const mockLayoutMap = new Map([['KeyA', 'a']]);
Object.defineProperty(globalThis, 'navigator', {
value: {
keyboard: {
getLayoutMap: () => Promise.resolve(mockLayoutMap),
},
} as NavigatorWithKeyboard,
writable: true,
configurable: true,
});
const promise = service.layoutReady;
await service.saveUserLayout();
const layout = await promise;
expect(layout.size).toBe(1);
expect(layout.get('KeyA')).toBe('a');
});
it('should resolve when layout is set directly', async () => {
const promise = service.layoutReady;
service.setLayout(new Map([['KeyB', 'b']]));
const layout = await promise;
expect(layout.size).toBe(1);
expect(layout.get('KeyB')).toBe('b');
});
it('should resolve with a copy of the layout map', async () => {
const promise = service.layoutReady;
service.setLayout(new Map([['KeyB', 'b']]));
const layout = await promise;
expect(layout.size).toBe(1);
expect(layout.get('KeyB')).toBe('b');
// Modifying service layout shouldn't affect the resolved copy
service.setLayout(new Map([['KeyC', 'c']]));
expect(layout.size).toBe(1);
expect(layout.get('KeyB')).toBe('b');
expect(layout.get('KeyC')).toBeUndefined();
});
});
});

View file

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

View file

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

View file

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

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 => {
return `${_getEnvironmentId()}_${_generateBase62(4)}`;
return `${_getEnvironmentId()}_${_generateBase62(6)}`;
};
/**
* Type guard: true if `id` matches a known valid client-ID format.
* - Legacy format: any string of length >= 10 (legacy IDs).
* - New format: {platform}_{4-char-base62}, e.g. "B_a7Kx".
* - New format: {platform}_{6-char-base62}, e.g. "B_a7Kx9Z".
*
* Used to narrow `unknown` values read from IndexedDB. An invalid format is
* treated as "absent" rather than fatal see issue #6197.
*/
export const isValidClientIdFormat = (id: unknown): id is string => {
return (
typeof id === 'string' && (id.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{4}$/.test(id))
typeof id === 'string' && (id.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{6}$/.test(id))
);
};

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core';
import { LanguageCode, DateTimeLocale } from '../../core/locale.constants';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { ProjectCfgFormKey } from '../project/project.model';
import { KeyboardConfig } from './keyboard-config.model';
import { KeyboardConfig } from '@sp/keyboard-config';
import { TaskReminderOptionId } from '../tasks/task.model';
export type AppFeaturesConfig = Readonly<{
@ -356,7 +356,7 @@ export interface LimitedFormlyFieldConfig<FormModel> extends Omit<
FormlyFieldConfig,
'key'
> {
key?: keyof FormModel;
key?: keyof FormModel & (string | number);
}
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 { Action } from '@ngrx/store';
import { GlobalConfigEffects } from './global-config.effects';
import {
mapKeyboardConfigToQwerty,
mapShortcutToQwerty,
} from '../keyboard-shortcut.util';
import {
KeyboardLayout,
KeyboardLayoutService,
} from '../../../core/keyboard-layout/keyboard-layout.service';
import { DateService } from 'src/app/core/date/date.service';
import { LanguageService } from '../../../core/language/language.service';
import { SnackService } from '../../../core/snack/snack.service';
@ -13,8 +21,11 @@ import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
import { AppStateActions } from '../../../root-store/app-state/app-state.actions';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
import { KeyboardConfig } from '@sp/keyboard-config';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { selectAllTasks } from '../../tasks/store/task.selectors';
import { IS_ELECTRON_TOKEN } from '../../../app.constants';
import { IS_MAC_TOKEN } from '../../../util/is-mac';
describe('GlobalConfigEffects', () => {
let effects: GlobalConfigEffects;
@ -22,7 +33,11 @@ describe('GlobalConfigEffects', () => {
let dateServiceSpy: jasmine.SpyObj<DateService>;
let store: MockStore;
beforeEach(() => {
const setup = (
isElectron = false,
isMac = false,
keyboardLayoutService?: KeyboardLayoutService,
): void => {
actions$ = new Subject<Action>();
dateServiceSpy = jasmine.createSpyObj('DateService', [
'setStartOfNextDayDiff',
@ -51,6 +66,12 @@ describe('GlobalConfigEffects', () => {
provide: UserProfileService,
useValue: { updateDayIdFromRemote: jasmine.createSpy('updateDayIdFromRemote') },
},
{ provide: IS_ELECTRON_TOKEN, useValue: isElectron },
{ provide: IS_MAC_TOKEN, useValue: isMac },
{
provide: KeyboardLayoutService,
useValue: keyboardLayoutService || new KeyboardLayoutService(),
},
],
});
@ -58,13 +79,16 @@ describe('GlobalConfigEffects', () => {
store.overrideSelector(selectAllTasks, []);
effects = TestBed.inject(GlobalConfigEffects);
effects.setStartOfNextDayDiffOnLoad.subscribe();
});
};
afterEach(() => {
if (store) {
store.resetSelectors();
}
});
describe('setStartOfNextDayDiffOnChange', () => {
beforeEach(() => setup());
it('should call setStartOfNextDayDiff when startOfNextDay is set to a non-zero value', () => {
const dispatched: Action[] = [];
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
@ -224,6 +248,7 @@ describe('GlobalConfigEffects', () => {
});
describe('setStartOfNextDayDiffOnLoad', () => {
beforeEach(() => setup());
it('should call setStartOfNextDayDiff when loadAllData is dispatched', () => {
actions$.next(
loadAllData({
@ -292,4 +317,221 @@ describe('GlobalConfigEffects', () => {
expect(dateServiceSpy.setStartOfNextDayDiff).toHaveBeenCalledWith('04:00', 4);
});
});
describe('shortcut mapping logic', () => {
beforeEach(() => setup());
let mockLayout: KeyboardLayout;
beforeEach(() => {
// Create a mock keyboard layout representing QWERTZ
mockLayout = new Map([
['KeyA', 'a'],
['KeyB', 'b'],
['KeyY', 'z'],
['KeyZ', 'y'],
['Digit1', '1'],
['Digit2', '2'],
['BracketRight', '+'],
]);
});
describe('mapShortcutToQwerty', () => {
it('should handle QWERTZ Y -> Z mapping', () => {
expect(mapShortcutToQwerty('Ctrl+Y', mockLayout)).toBe('Ctrl+Z');
expect(mapShortcutToQwerty('Ctrl+y', mockLayout)).toBe('Ctrl+Z');
});
it('should handle QWERTZ Z -> Y mapping', () => {
expect(mapShortcutToQwerty('Ctrl+Z', mockLayout)).toBe('Ctrl+Y');
expect(mapShortcutToQwerty('Ctrl+z', mockLayout)).toBe('Ctrl+Y');
});
it('should handle digit keys', () => {
expect(mapShortcutToQwerty('Ctrl+1', mockLayout)).toBe('Ctrl+1');
expect(mapShortcutToQwerty('Ctrl+Alt+2', mockLayout)).toBe('Ctrl+Alt+2');
});
it('should map punctuation key according to qwertyCodeMap (BracketRight -> + key on German layout)', () => {
expect(mapShortcutToQwerty('Ctrl++', mockLayout)).toBe('Ctrl+]');
expect(mapShortcutToQwerty('+', mockLayout)).toBe(']');
});
it('should return unchanged if character is not found in layout', () => {
expect(mapShortcutToQwerty('Ctrl+F2', mockLayout)).toBe('Ctrl+F2');
expect(mapShortcutToQwerty('Ctrl+Y', new Map())).toBe('Ctrl+Y');
});
it('should return null/undefined/empty inputs unchanged', () => {
expect(mapShortcutToQwerty(null, mockLayout)).toBeNull();
expect(mapShortcutToQwerty(undefined, mockLayout)).toBeUndefined();
expect(mapShortcutToQwerty('', mockLayout)).toBe('');
});
});
describe('mapKeyboardConfigToQwerty', () => {
it('should map keyboard config shortcuts', () => {
const keyboardCfg = {
globalShowHide: 'Ctrl+Y',
globalToggleTaskStart: 'Ctrl++',
globalAddNote: 'Ctrl+F2',
globalAddTask: null,
globalToggleTaskWidget: undefined,
toggleBacklog: 'Ctrl+Y',
} as KeyboardConfig;
const result = mapKeyboardConfigToQwerty(keyboardCfg, mockLayout);
expect(result.globalShowHide).toBe('Ctrl+Z');
expect(result.globalToggleTaskStart).toBe('Ctrl+]');
expect(result.globalAddNote).toBe('Ctrl+F2');
expect(result.globalAddTask).toBeNull();
expect(result.globalToggleTaskWidget).toBeUndefined();
expect(result.toggleBacklog).toBe('Ctrl+Y');
});
});
});
describe('global shortcut effects', () => {
let registerGlobalShortcutsSpy: jasmine.Spy;
beforeEach(() => {
registerGlobalShortcutsSpy = jasmine.createSpy('registerGlobalShortcuts');
(window as any).ea = {
registerGlobalShortcuts: registerGlobalShortcutsSpy,
};
});
afterEach(() => {
delete (window as any).ea;
});
describe('updateGlobalShortcut$', () => {
it('should register shortcuts in Electron', () => {
setup(true, false);
effects.updateGlobalShortcut$.subscribe();
const keyboardCfg = {
...DEFAULT_GLOBAL_CONFIG.keyboard,
globalShowHide: 'Ctrl+X',
};
actions$.next(
updateGlobalConfigSection({
sectionKey: 'keyboard',
sectionCfg: keyboardCfg,
}),
);
expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith(keyboardCfg);
});
it('should translate shortcuts to QWERTY on macOS Electron', () => {
const keyboardLayoutService = new KeyboardLayoutService();
setup(true, true, keyboardLayoutService);
keyboardLayoutService.setLayout(
new Map([
['KeyY', 'z'],
['KeyZ', 'y'],
]),
);
effects.updateGlobalShortcut$.subscribe();
const keyboardCfg = {
...DEFAULT_GLOBAL_CONFIG.keyboard,
globalShowHide: 'Ctrl+Y',
};
actions$.next(
updateGlobalConfigSection({
sectionKey: 'keyboard',
sectionCfg: keyboardCfg,
}),
);
// Ctrl+Y on QWERTZ is KeyY, which is Ctrl+Z on QWERTY
expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
globalShowHide: 'Ctrl+Z',
}),
);
});
it('should NOT register shortcuts if NOT in Electron', () => {
setup(false, false);
effects.updateGlobalShortcut$.subscribe();
actions$.next(
updateGlobalConfigSection({
sectionKey: 'keyboard',
sectionCfg: DEFAULT_GLOBAL_CONFIG.keyboard,
}),
);
expect(registerGlobalShortcutsSpy).not.toHaveBeenCalled();
});
});
describe('registerGlobalShortcutInitially$', () => {
it('should register shortcuts initially in Electron', (done) => {
setup(true, false);
const keyboardCfg = {
...DEFAULT_GLOBAL_CONFIG.keyboard,
globalShowHide: 'Ctrl+X',
};
effects.registerGlobalShortcutInitially$.subscribe(() => {
expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith(keyboardCfg);
done();
});
actions$.next(
loadAllData({
appDataComplete: {
globalConfig: {
...DEFAULT_GLOBAL_CONFIG,
keyboard: keyboardCfg,
},
} as any,
}),
);
});
it('should wait for layout and translate to QWERTY initially on macOS Electron', (done) => {
const keyboardLayoutService = new KeyboardLayoutService();
setup(true, true, keyboardLayoutService);
// We don't call setLayout yet, so layoutReady is still pending
const keyboardCfg = {
...DEFAULT_GLOBAL_CONFIG.keyboard,
globalShowHide: 'Ctrl+Y',
};
effects.registerGlobalShortcutInitially$.subscribe(() => {
expect(registerGlobalShortcutsSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
globalShowHide: 'Ctrl+Z',
}),
);
done();
});
actions$.next(
loadAllData({
appDataComplete: {
globalConfig: {
...DEFAULT_GLOBAL_CONFIG,
keyboard: keyboardCfg,
},
} as any,
}),
);
// Now resolve the layout in a macrotask to ensure the await logic is exercised
setTimeout(() => {
keyboardLayoutService.setLayout(
new Map([
['KeyY', 'z'],
['KeyZ', 'y'],
]),
);
});
});
});
});
});

View file

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

View file

@ -13,7 +13,7 @@ import {
MiscConfig,
SyncConfig,
} from '../global-config.model';
import type { KeyboardConfig } from '../keyboard-config.model';
import type { KeyboardConfig } from '@sp/keyboard-config';
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { getHoursFromClockString } from '../../../util/get-hours-from-clock-string';

View file

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

View file

@ -46,7 +46,7 @@ import { ProjectService } from '../../../project/project.service';
import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../../project/project.const';
import { WorkContextService } from '../../../work-context/work-context.service';
import { GlobalConfigService } from '../../../config/global-config.service';
import { KeyboardConfig } from '../../../config/keyboard-config.model';
import { KeyboardConfig } from '@sp/keyboard-config';
import { DialogScheduleTaskComponent } from '../../../planner/dialog-schedule-task/dialog-schedule-task.component';
import { DialogDeadlineComponent } from '../../dialog-deadline/dialog-deadline.component';
import { DialogTimeEstimateComponent } from '../../dialog-time-estimate/dialog-time-estimate.component';

View file

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

View file

@ -66,7 +66,7 @@ import { DateService } from '../../../core/date/date.service';
import { isTouchActive } from '../../../util/input-intent';
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
import { KeyboardConfig } from '../../config/keyboard-config.model';
import { KeyboardConfig } from '@sp/keyboard-config';
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
import { PlannerActions } from '../../planner/store/planner.actions';
import { PlannerService } from '../../planner/planner.service';

View file

@ -389,7 +389,7 @@ describe('BackupService', () => {
.args[0] as Parameters<typeof mockOpLogStore.runDestructiveStateReplacement>[0];
// The clientId is freshly minted (generateClientId) — new compact format.
const op = args.syncImportOp;
expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/);
expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/);
expect(op.vectorClock).toEqual({ [op.clientId]: 1 });
});
@ -401,7 +401,7 @@ describe('BackupService', () => {
const args = mockOpLogStore.runDestructiveStateReplacement.calls.mostRecent()
.args[0] as Parameters<typeof mockOpLogStore.runDestructiveStateReplacement>[0];
const op = args.syncImportOp;
expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/);
expect(op.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/);
expect(op.vectorClock).toEqual({ [op.clientId]: 1 });
});

View file

@ -81,7 +81,7 @@ describe('CleanSlateService', () => {
expect(appendedOp.payload).toBe(mockState);
// The clientId is freshly minted (generateClientId) — new compact format.
// runDestructiveStateReplacement persists it inside its atomic tx.
expect(appendedOp.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{4}$/);
expect(appendedOp.clientId).toMatch(/^[BEAI]_[a-zA-Z0-9]{6}$/);
expect(appendedOp.vectorClock).toEqual({ [appendedOp.clientId]: 1 });
expect(appendedOp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
});

View file

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

View file

@ -1 +1,13 @@
import { InjectionToken } from '@angular/core';
export const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
/**
* Injection token for IS_MAC to enable testing.
* New DI-tested effects/services should prefer this token over the IS_MAC
* constant to ensure testability and prevent logic drift.
*/
export const IS_MAC_TOKEN = new InjectionToken<boolean>('IS_MAC', {
providedIn: 'root',
factory: () => IS_MAC,
});

View file

@ -24,7 +24,8 @@
"packages/sync-providers/src/provider-types.ts"
],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"],
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"]
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"],
"@sp/keyboard-config": ["electron/shared-with-frontend/keyboard-config.model.ts"]
}
},
"files": ["test.ts", "polyfills.ts"],

View file

@ -43,7 +43,8 @@
"packages/sync-providers/src/provider-types.ts"
],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"],
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"]
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"],
"@sp/keyboard-config": ["electron/shared-with-frontend/keyboard-config.model.ts"]
},
"plugins": [
{