mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-20 18:08:55 +00:00
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.
491 lines
18 KiB
TypeScript
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
|
|
});
|
|
}
|