super-productivity/src/main.ts
Johannes Millan d6880d47da fix(sync): scope interval timer to file-based providers and drop unneeded migration
Guard the periodic sync interval timer behind a useIntervalTimer flag so it
only fires for file-based providers (WebDAV, LocalFile, Nextcloud). SuperSync
uses WebSocket push notifications and does not need polling.

Remove the syncFolderPath backwards-compatibility migration: master already
routes localFileSync settings to the credential store via PROP_MAP_TO_FORM,
so no real users have the path in global config.
2026-04-14 22:09:02 +02:00

491 lines
18 KiB
TypeScript

import {
APP_INITIALIZER,
enableProdMode,
EnvironmentInjector,
ErrorHandler,
Injector,
createEnvironmentInjector,
importProvidersFrom,
provideZonelessChangeDetection,
SecurityContext,
} from '@angular/core';
import { registerLocaleData } from '@angular/common';
import { environment } from './environments/environment';
import { IS_ELECTRON } from './app/app.constants';
import {
DEFAULT_LANGUAGE,
DEFAULT_LOCALE_DATA,
LocaleImportFns,
} from './app/core/locale.constants';
import { IS_ANDROID_WEB_VIEW } from './app/util/is-android-web-view';
import { androidInterface } from './app/features/android/android-interface';
import { IS_IOS_NATIVE, IS_NATIVE_PLATFORM } from './app/util/is-native-platform';
import { DataInitStateService } from './app/core/data-init/data-init-state.service';
// Type definitions for window.ea are in ./app/core/window-ea.d.ts
import { App as CapacitorApp } from '@capacitor/app';
import { GlobalErrorHandler } from './app/core/error-handler/global-error-handler.class';
import { bootstrapApplication, BrowserModule } from '@angular/platform-browser';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { MarkdownModule, MARKED_OPTIONS, SANITIZE } from 'ngx-markdown';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
import { IS_TOUCH_PRIMARY } from './app/util/is-mouse-primary';
import { FeatureStoresModule } from './app/root-store/feature-stores.module';
import {
MATERIAL_ANIMATIONS,
MatNativeDateModule,
MAT_DATE_FORMATS,
MatDateFormats,
DateAdapter,
} from '@angular/material/core';
import { FormlyConfigModule } from './app/ui/formly-config.module';
import { markedOptionsFactory } from './app/ui/marked-options-factory';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { DEFAULT_TODAY_TAG_COLOR } from './app/features/work-context/work-context.const';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { ReminderModule } from './app/features/reminder/reminder.module';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {
PreloadAllModules,
provideRouter,
withHashLocation,
withPreloading,
} from '@angular/router';
import { APP_ROUTES } from './app/app.routes';
import { StoreModule } from '@ngrx/store';
// import { Store } from '@ngrx/store'; // #6230: uncomment if re-enabling __e2eTestHelpers below
import { META_REDUCERS } from './app/root-store/meta/meta-reducer-registry';
import { setOperationCaptureService } from './app/root-store/meta/task-shared-meta-reducers';
import { OperationCaptureService } from './app/op-log/capture/operation-capture.service';
import { EncryptionPasswordDialogOpenerService } from './app/imex/sync/encryption-password-dialog-opener.service';
import { DataInitService } from './app/core/data-init/data-init.service';
import { EffectsModule } from '@ngrx/effects';
// StoreDevtoolsModule lazy-loaded only in dev mode below
import { ReactiveFormsModule } from '@angular/forms';
import { ServiceWorkerModule } from '@angular/service-worker';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import {
TRANSLATE_HTTP_LOADER_CONFIG,
TranslateHttpLoader,
} from '@ngx-translate/http-loader';
import { CdkDropListGroup } from '@angular/cdk/drag-drop';
import { AppComponent } from './app/app.component';
import { ShortTimeHtmlPipe } from './app/ui/pipes/short-time-html.pipe';
import { ShortTimePipe } from './app/ui/pipes/short-time.pipe';
import { BackgroundTask } from '@capawesome/capacitor-background-task';
import { PLUGIN_INITIALIZER_PROVIDER } from './app/plugins/plugin-initializer';
import { initializeMatMenuTouchFix } from './app/features/tasks/task-context-menu/mat-menu-touch-monkey-patch';
import { Log } from './app/core/log';
import { OperationWriteFlushService } from './app/op-log/sync/operation-write-flush.service';
import { PluginOAuthRedirectHandler } from './app/plugins/oauth/plugin-oauth-redirect.handler';
import { OAuthCallbackHandlerService } from './app/imex/sync/oauth-callback-handler.service';
import { GlobalConfigService } from './app/features/config/global-config.service';
import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe';
import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service';
import { CustomDateAdapter } from './app/core/date-time-format/custom-date-adapter';
import { unlockAudioContext } from './app/util/audio-context';
if (environment.production || environment.stage) {
enableProdMode();
}
// Window.ea declaration is in src/app/core/window-ea.d.ts
// Module-level injector for use in Capacitor lifecycle handlers.
// Set after Angular bootstrap completes.
let appInjector: Injector | null = null;
// Register one-time user gesture listener to unlock AudioContext.
// Required on iOS/Android where AudioContext starts suspended.
unlockAudioContext();
bootstrapApplication(AppComponent, {
providers: [
// Provide configuration for TranslateHttpLoader
{
provide: TRANSLATE_HTTP_LOADER_CONFIG,
useValue: {
prefix: './assets/i18n/',
suffix: '.json',
},
},
importProvidersFrom(
FeatureStoresModule,
MatNativeDateModule,
FormlyConfigModule,
MarkdownModule.forRoot({
markedOptions: {
provide: MARKED_OPTIONS,
useFactory: markedOptionsFactory,
},
sanitize: { provide: SANITIZE, useValue: SecurityContext.HTML },
}),
MaterialCssVarsModule.forRoot({
primary: DEFAULT_TODAY_TAG_COLOR,
}),
MatSidenavModule,
MatBottomSheetModule,
ReminderModule,
// External
BrowserModule,
// NOTE: both need to be present to use forFeature stores
// Meta-reducers are defined in meta-reducer-registry.ts with detailed phase documentation
StoreModule.forRoot(undefined, {
metaReducers: META_REDUCERS,
...(environment.production
? {
runtimeChecks: {
strictStateImmutability: false,
strictActionImmutability: false,
strictStateSerializability: false,
strictActionSerializability: false,
},
}
: {
runtimeChecks: {
strictStateImmutability: true,
strictActionImmutability: true,
strictStateSerializability: true,
strictActionSerializability: true,
strictActionTypeUniqueness: true,
},
}),
}),
EffectsModule.forRoot([]),
// StoreDevtoolsModule lazy-loaded in dev mode after bootstrap
ReactiveFormsModule,
ServiceWorkerModule.register('ngsw-worker.js', {
enabled:
!IS_ELECTRON &&
!IS_NATIVE_PLATFORM &&
(environment.production || environment.stage),
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000',
}),
TranslateModule.forRoot({
fallbackLang: DEFAULT_LANGUAGE,
loader: {
provide: TranslateLoader,
useClass: TranslateHttpLoader,
},
}),
CdkDropListGroup,
),
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
provideHttpClient(withInterceptorsFromDi()),
LocaleDatePipe,
ShortTimeHtmlPipe,
ShortTimePipe,
{ provide: DateAdapter, useClass: CustomDateAdapter },
{
provide: MAT_DATE_FORMATS,
useFactory: (dateTimeFormatService: DateTimeFormatService): MatDateFormats => {
// Use getters so dateInput re-evaluates when the user changes locale
return {
parse: {
get dateInput(): string {
return dateTimeFormatService.dateFormat().raw;
},
timeInput: { hour: 'numeric', minute: 'numeric' },
},
display: {
get dateInput(): string {
return dateTimeFormatService.dateFormat().raw;
},
monthYearLabel: { year: 'numeric', month: 'short' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' },
timeInput: { hour: 'numeric', minute: 'numeric' },
timeOptionLabel: { hour: 'numeric', minute: 'numeric' },
},
};
},
deps: [DateTimeFormatService],
},
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: { appearance: 'fill', subscriptSizing: 'dynamic' },
},
// Disable autofocus for touch-primary devices to prevent virtual keyboard popup
...(IS_TOUCH_PRIMARY
? [{ provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: { autoFocus: false } }]
: []),
provideAnimationsAsync(),
{
provide: MATERIAL_ANIMATIONS,
deps: [GlobalConfigService],
useFactory: (globalConfigService: GlobalConfigService) => ({
get animationsDisabled(): boolean {
return globalConfigService.misc()?.isDisableAnimations ?? false;
},
}),
},
provideRouter(APP_ROUTES, withHashLocation(), withPreloading(PreloadAllModules)),
PLUGIN_INITIALIZER_PROVIDER,
provideZonelessChangeDetection(),
// Initialize operation capture service for synchronous state change capture
// This must run before any persistent actions are dispatched
{
provide: APP_INITIALIZER,
useFactory: (captureService: OperationCaptureService) => {
return () => {
setOperationCaptureService(captureService);
};
},
deps: [OperationCaptureService],
multi: true,
},
// Ensure DataInitService is instantiated at bootstrap.
// Its constructor triggers reInit() -> hydrateStore() -> loadAllData into NgRx.
{
provide: APP_INITIALIZER,
useFactory: (_dataInit: DataInitService) => {
return () => {};
},
deps: [DataInitService],
multi: true,
},
// Initialize encryption password dialog opener for static form config functions
{
provide: APP_INITIALIZER,
useFactory: (_opener: EncryptionPasswordDialogOpenerService) => {
// Service constructor self-registers the module-level reference
return () => {};
},
deps: [EncryptionPasswordDialogOpenerService],
multi: true,
},
// Ensure PluginOAuthRedirectHandler is instantiated at bootstrap.
// Its constructor registers platform-specific listeners (postMessage / Electron IPC)
// that bridge OAuth redirect callbacks to PluginOAuthService.
{
provide: APP_INITIALIZER,
useFactory: (_handler: PluginOAuthRedirectHandler) => {
return () => {};
},
deps: [PluginOAuthRedirectHandler],
multi: true,
},
// Ensure OAuthCallbackHandlerService is instantiated at bootstrap on native platforms.
// Its constructor registers Capacitor's appUrlOpen listener that bridges
// both Dropbox and plugin OAuth redirect callbacks.
{
provide: APP_INITIALIZER,
useFactory: (_handler: OAuthCallbackHandlerService) => {
return () => {};
},
deps: [OAuthCallbackHandlerService],
multi: true,
},
// Note: ImmediateUploadService now initializes itself in constructor
// after DataInitStateService.isAllDataLoadedInitially$ fires to avoid
// race condition where upload attempts happen before sync config is loaded
],
}).then((appRef) => {
appInjector = appRef.injector;
// #6230: Expose store and HydrationStateService for e2e tests in dev mode only.
// Commented out because the tests that use it (Test A and B in
// e2e/tests/recurring/repeat-task-day-change-bug-6230.spec.ts) are also commented out.
// Uncomment both if investigating #6230 further. Also uncomment the Store import above.
// if (!environment.production && !environment.stage) {
// const storeRef = appRef.injector.get(Store);
// import('./app/op-log/apply/hydration-state.service').then((m) => {
// (window as any).__e2eTestHelpers = {
// store: storeRef,
// hydrationState: appRef.injector.get(m.HydrationStateService),
// };
// });
// }
// Dismiss native startup overlay after all data is loaded (Android only)
if (IS_ANDROID_WEB_VIEW) {
appRef.injector.get(DataInitStateService).isAllDataLoadedInitially$.subscribe(() => {
import('./app/core/startup-overlay/startup-overlay.service').then((m) => {
appRef.injector.get(m.StartupOverlayService).processAndDismiss();
});
});
}
// Initialize touch fix for Material menus
initializeMatMenuTouchFix();
// Register default locale immediately (statically imported, no network fetch)
registerLocaleData(DEFAULT_LOCALE_DATA, DEFAULT_LANGUAGE);
// Lazily load and register remaining locales during idle time
const registerRemainingLocales = (): void => {
Object.keys(LocaleImportFns).forEach((locale) => {
if (locale !== DEFAULT_LANGUAGE) {
LocaleImportFns[locale as keyof typeof LocaleImportFns]().then((m) => {
registerLocaleData(m.default, locale);
});
}
});
};
// Lazily load and register focus-mode effects during idle time.
// Safe to defer: focus-mode requires explicit user activation (clicking the
// focus button), which cannot happen before idle callback fires.
const registerLazyEffects = async (): Promise<void> => {
const { FocusModeEffects } =
await import('./app/features/focus-mode/store/focus-mode.effects');
const envInjector = appRef.injector.get(EnvironmentInjector);
createEnvironmentInjector(
[importProvidersFrom(EffectsModule.forFeature([FocusModeEffects]))],
envInjector,
);
};
// Lazily load store devtools only in dev mode
const registerStoreDevtools = async (): Promise<void> => {
if (environment.production || environment.stage) {
return;
}
const { StoreDevtoolsModule } = await import('@ngrx/store-devtools');
const envInjector = appRef.injector.get(EnvironmentInjector);
createEnvironmentInjector(
[
importProvidersFrom(
StoreDevtoolsModule.instrument({
maxAge: 15,
logOnly: false,
actionsBlocklist: ['[TimeTracking] Add time spent'],
}),
),
],
envInjector,
);
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => registerRemainingLocales());
requestIdleCallback(() =>
registerLazyEffects().catch((e) => Log.err('Failed to register lazy effects', e)),
);
requestIdleCallback(() =>
registerStoreDevtools().catch((e) => Log.err('Failed to register devtools', e)),
);
} else {
setTimeout(() => registerRemainingLocales(), 0);
setTimeout(
() =>
registerLazyEffects().catch((e) => Log.err('Failed to register lazy effects', e)),
0,
);
setTimeout(
() =>
registerStoreDevtools().catch((e) => Log.err('Failed to register devtools', e)),
0,
);
}
// TODO make asset caching work for electron
if (
'serviceWorker' in navigator &&
(environment.production || environment.stage) &&
!IS_ELECTRON &&
!IS_NATIVE_PLATFORM
) {
Log.log('Registering Service worker');
return navigator.serviceWorker.register('ngsw-worker.js').catch((err: unknown) => {
Log.log('Service Worker Registration Error');
Log.err(err);
});
} else if ('serviceWorker' in navigator && (IS_ELECTRON || IS_NATIVE_PLATFORM)) {
navigator.serviceWorker
.getRegistrations()
.then((registrations) => {
for (const registration of registrations) {
registration.unregister();
}
})
.catch((e) => {
Log.err('ERROR when unregistering service worker');
Log.err(e);
});
}
return undefined;
});
// fix mobile scrolling while dragging
window.addEventListener('touchmove', () => {});
if (!(environment.production || environment.stage) && IS_ANDROID_WEB_VIEW) {
setTimeout(() => {
androidInterface.showToast('Android DEV works');
Log.log(androidInterface);
}, 1000);
}
// CAPACITOR STUFF
// ---------------
// Android-specific: Handle back button
if (IS_ANDROID_WEB_VIEW) {
CapacitorApp.addListener('backButton', ({ canGoBack }) => {
if (!canGoBack) {
CapacitorApp.minimizeApp();
} else {
window.history.back();
}
});
}
// Flush pending operations from in-memory FIFO queue to IndexedDB.
// Called when the app goes to background to prevent data loss if the OS kills the app.
const flushPendingOperations = async (platform: string): Promise<void> => {
if (!appInjector) {
Log.log(`${platform} background: app not yet bootstrapped, skipping flush`);
return;
}
const flushService = appInjector.get(OperationWriteFlushService);
await flushService.flushPendingWrites();
Log.log(`${platform} background: operation flush complete`);
};
// Android: Flush pending operations to IndexedDB when app goes to background.
// Without this, operations in the in-memory FIFO queue are lost if the OS kills the app.
if (IS_ANDROID_WEB_VIEW) {
CapacitorApp.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
return;
}
const taskId = await BackgroundTask.beforeExit(async () => {
try {
await flushPendingOperations('Android');
} catch (e) {
Log.err('Android background: operation flush failed', e);
}
BackgroundTask.finish({ taskId });
});
});
}
// iOS: Flush pending operations to IndexedDB when app goes to background.
if (IS_IOS_NATIVE) {
CapacitorApp.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
return;
}
const taskId = await BackgroundTask.beforeExit(async () => {
try {
await flushPendingOperations('iOS');
} catch (e) {
Log.err('iOS background: operation flush failed', e);
}
BackgroundTask.finish({ taskId });
});
});
// Handle app URL open (for OAuth callbacks, deep links, etc.)
CapacitorApp.addListener('appUrlOpen', (event) => {
Log.log('iOS app URL open', event.url);
// Handle OAuth callbacks or deep links here
// The URL will be passed to the app when opened via custom scheme
});
}