From 01316f0e971eb4fc5d47a51c9e0006f67aea05c8 Mon Sep 17 00:00:00 2001 From: sleepyfran Date: Tue, 27 Aug 2024 00:01:57 +0200 Subject: [PATCH] Implement app reinitialization With a few TODOs here and there, but hey, no more login-in! --- .../src/broadcast/media-provider.broadcast.ts | 20 +--- .../core/types/src/model/provider-metadata.ts | 28 +++++ packages/core/types/src/services/app-init.ts | 23 ++++ packages/core/types/src/services/index.ts | 2 + .../core/types/src/services/local-storage.ts | 51 +++++++++ .../browser-local-storage/index.ts | 33 ++++++ .../browser-local-storage/package.json | 15 +++ .../browser-local-storage/tsconfig.json | 7 ++ .../src/add-provider.machine.ts | 20 ++-- packages/services/app-init/index.ts | 1 + packages/services/app-init/package.json | 17 +++ packages/services/app-init/src/app-init.ts | 106 ++++++++++++++++++ packages/services/app-init/tsconfig.json | 7 ++ packages/services/bootstrap/package.json | 1 + packages/services/bootstrap/src/layers.ts | 2 + packages/web/package.json | 2 + packages/web/src/App.tsx | 39 +++++-- 17 files changed, 342 insertions(+), 32 deletions(-) create mode 100644 packages/core/types/src/services/app-init.ts create mode 100644 packages/core/types/src/services/local-storage.ts create mode 100644 packages/infrastructure/browser-local-storage/index.ts create mode 100644 packages/infrastructure/browser-local-storage/package.json create mode 100644 packages/infrastructure/browser-local-storage/tsconfig.json create mode 100644 packages/services/app-init/index.ts create mode 100644 packages/services/app-init/package.json create mode 100644 packages/services/app-init/src/app-init.ts create mode 100644 packages/services/app-init/tsconfig.json diff --git a/packages/core/types/src/broadcast/media-provider.broadcast.ts b/packages/core/types/src/broadcast/media-provider.broadcast.ts index 72ca96e..db9d360 100644 --- a/packages/core/types/src/broadcast/media-provider.broadcast.ts +++ b/packages/core/types/src/broadcast/media-provider.broadcast.ts @@ -1,25 +1,9 @@ import type { - AuthenticationInfo, - FolderMetadata, + ProviderStartArgs, ProviderMetadata, ProviderStatus, } from "../model"; -type FileBasedStartInput = { - _tag: "file-based"; - metadata: ProviderMetadata; - authInfo: AuthenticationInfo; - rootFolder: FolderMetadata; -}; - -type ApiBasedStartInput = { - _tag: "api-based"; - metadata: ProviderMetadata; - authInfo: AuthenticationInfo; -}; - -type StartInput = FileBasedStartInput | ApiBasedStartInput; - /** * Defines the schema for messages flowing from the main thread to the media * provider worker. @@ -30,7 +14,7 @@ type MainThreadActionsSchema = { * APIs with the given authentication information that was previously obtained * by the provider's auth process. */ - start: StartInput; + start: ProviderStartArgs; /** * Stops the media provider with the given name, if it is currently running. diff --git a/packages/core/types/src/model/provider-metadata.ts b/packages/core/types/src/model/provider-metadata.ts index 4b9568c..156f0a1 100644 --- a/packages/core/types/src/model/provider-metadata.ts +++ b/packages/core/types/src/model/provider-metadata.ts @@ -1,3 +1,6 @@ +import type { AuthenticationInfo } from "./authentication"; +import type { FolderMetadata } from "./file-system"; + /** * ID of a file-based provider. */ @@ -88,3 +91,28 @@ export type ProviderStatus = } | { _tag: "errored"; error: ProviderError } | { _tag: "stopped" }; + +/** + * Defines the parameters required to start a file-based provider. + */ +type FileBasedStartArgs = { + _tag: "file-based"; + metadata: ProviderMetadata; + authInfo: AuthenticationInfo; + rootFolder: FolderMetadata; +}; + +/** + * Defines the parameters required to start an API-based provider. + */ +type ApiBasedStartArgs = { + _tag: "api-based"; + metadata: ProviderMetadata; + authInfo: AuthenticationInfo; +}; + +/** + * Defines the parameters required to start a provider, which can be either file-based + * or API-based. + */ +export type ProviderStartArgs = FileBasedStartArgs | ApiBasedStartArgs; diff --git a/packages/core/types/src/services/app-init.ts b/packages/core/types/src/services/app-init.ts new file mode 100644 index 0000000..5c2fc16 --- /dev/null +++ b/packages/core/types/src/services/app-init.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect"; + +/** + * Service that encapsulates the orchestration for initializing the application + * during a cold boot. + */ +export type IAppInit = { + /** + * Initializes the application by performing reading the last known state of + * the providers from the storage and re-starting them with the last + * used credentials. If the credentials have expired, then the initialization + * of the provider will fail and the user will have to re-add the provider. + */ + readonly init: Effect.Effect; +}; + +/** + * Tag to identify the AppInit service. + */ +export class AppInit extends Effect.Tag("@echo/core-types/AppInit")< + AppInit, + IAppInit +>() {} diff --git a/packages/core/types/src/services/index.ts b/packages/core/types/src/services/index.ts index 1b6499a..5bce642 100644 --- a/packages/core/types/src/services/index.ts +++ b/packages/core/types/src/services/index.ts @@ -1,9 +1,11 @@ export * from "./active-media-provider-cache"; +export * from "./app-init"; export * from "./authentication"; export * from "./broadcast-channel"; export * from "./crypto"; export * from "./database"; export * from "./library"; +export * from "./local-storage"; export * from "./metadata-provider"; export * from "./media-player"; export * from "./media-provider"; diff --git a/packages/core/types/src/services/local-storage.ts b/packages/core/types/src/services/local-storage.ts new file mode 100644 index 0000000..32f73bc --- /dev/null +++ b/packages/core/types/src/services/local-storage.ts @@ -0,0 +1,51 @@ +import { Effect, Option } from "effect"; + +/** + * Defines all the possible namespaces that can be used to store data in the + * local storage. + */ +export type LocalStorageNamespace = "media-provider-start-args"; + +/** + * Service that allows to cache active media providers and observe changes to + * them. + */ +export type ILocalStorage = { + /** + * Sets the given value in the local storage under the specified namespace and + * key, overwriting any existing value. The value must be serializable, since + * it will always be stored as a string. + */ + readonly set: ( + namespace: LocalStorageNamespace, + key: string, + value: T, + ) => Effect.Effect; + + /** + * Attempts to retrieve and deserialize a value from the local storage under + * the specified namespace and key. If the value does not exist, `None` will + * be returned. + */ + readonly get: ( + namespace: LocalStorageNamespace, + key: string, + ) => Effect.Effect>; + + /** + * Deletes the value stored in the local storage under the specified namespace + * and key. If the value does not exist, this operation is a no-op. + */ + readonly remove: ( + namespace: LocalStorageNamespace, + key: string, + ) => Effect.Effect; +}; + +/** + * Tag to identify the LocalStorage service. + */ +export class LocalStorage extends Effect.Tag("@echo/core-types/LocalStorage")< + LocalStorage, + ILocalStorage +>() {} diff --git a/packages/infrastructure/browser-local-storage/index.ts b/packages/infrastructure/browser-local-storage/index.ts new file mode 100644 index 0000000..1473429 --- /dev/null +++ b/packages/infrastructure/browser-local-storage/index.ts @@ -0,0 +1,33 @@ +import { LocalStorage, type LocalStorageNamespace } from "@echo/core-types"; +import { Effect, Layer, Option } from "effect"; + +const createKey = (namespace: LocalStorageNamespace, key: string) => + `${namespace}:${key}`; + +const make = LocalStorage.of({ + set: (namespace, key, value) => + Effect.sync(() => { + localStorage.setItem(createKey(namespace, key), JSON.stringify(value)); + }), + + get: (namespace: LocalStorageNamespace, key: string) => + Effect.sync(() => { + const item = localStorage.getItem(createKey(namespace, key)); + + // TODO: Use Effect's schema here to ensure that we're actually properly parsing the value. + return Option.fromNullable(item).pipe( + Option.map((value) => JSON.parse(value) as unknown as T), + ); + }), + + remove: (namespace, key) => + Effect.sync(() => { + localStorage.removeItem(createKey(namespace, key)); + }), +}); + +/** + * Implementation of the local storage service that uses the browser's local + * storage to store data. + */ +export const BrowserLocalStorageLive = Layer.succeed(LocalStorage, make); diff --git a/packages/infrastructure/browser-local-storage/package.json b/packages/infrastructure/browser-local-storage/package.json new file mode 100644 index 0000000..fe5026d --- /dev/null +++ b/packages/infrastructure/browser-local-storage/package.json @@ -0,0 +1,15 @@ +{ + "name": "@echo/infrastructure-browser-local-storage", + "private": true, + "version": "1.0.0", + "description": "Contains the BrowserLocalStorage related infrastructure", + "main": "index.js", + "scripts": { + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@echo/core-types": "^1.0.0", + "effect": "^3.6.5" + } +} \ No newline at end of file diff --git a/packages/infrastructure/browser-local-storage/tsconfig.json b/packages/infrastructure/browser-local-storage/tsconfig.json new file mode 100644 index 0000000..6953ff5 --- /dev/null +++ b/packages/infrastructure/browser-local-storage/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "src", + "index.ts" + ] +} \ No newline at end of file diff --git a/packages/services/add-provider-workflow/src/add-provider.machine.ts b/packages/services/add-provider-workflow/src/add-provider.machine.ts index 718f57a..191480f 100644 --- a/packages/services/add-provider-workflow/src/add-provider.machine.ts +++ b/packages/services/add-provider-workflow/src/add-provider.machine.ts @@ -3,6 +3,7 @@ import * as Machine from "@effect/experimental/Machine"; import { ActiveMediaProviderCache, AddProviderWorkflow, + LocalStorage, MediaProviderMainThreadBroadcastChannel, type Authentication, type AuthenticationError, @@ -65,11 +66,10 @@ export const addProviderWorkflow = Machine.makeWith()( const state = previousState ?? { _tag: "Idle" }; const activeMediaProviderCache = yield* ActiveMediaProviderCache; - + const broadcastChannel = yield* MediaProviderMainThreadBroadcastChannel; const providerLazyLoader = yield* LazyLoadedProvider; const mediaPlayerLazyLoader = yield* LazyLoadedMediaPlayer; - - const broadcastChannel = yield* MediaProviderMainThreadBroadcastChannel; + const localStorage = yield* LocalStorage; return Machine.procedures.make(state).pipe( /* @@ -158,12 +158,18 @@ export const addProviderWorkflow = Machine.makeWith()( return [{}, state]; } - yield* broadcastChannel.send("start", { - _tag: "file-based", + const startArgs = { + _tag: "file-based" as const, metadata: state.providerMetadata, - rootFolder: request.rootFolder, authInfo: state.authInfo, - }); + rootFolder: request.rootFolder, + }; + yield* broadcastChannel.send("start", startArgs); + yield* localStorage.set( + "media-provider-start-args", + state.providerMetadata.id, + startArgs, + ); return [{}, { _tag: "Done" as const }]; }), diff --git a/packages/services/app-init/index.ts b/packages/services/app-init/index.ts new file mode 100644 index 0000000..7511f44 --- /dev/null +++ b/packages/services/app-init/index.ts @@ -0,0 +1 @@ +export { AppInitLive } from "./src/app-init"; diff --git a/packages/services/app-init/package.json b/packages/services/app-init/package.json new file mode 100644 index 0000000..caff862 --- /dev/null +++ b/packages/services/app-init/package.json @@ -0,0 +1,17 @@ +{ + "name": "@echo/services-app-init", + "private": true, + "version": "1.0.0", + "description": "Contains the implementation for the AppInit service", + "main": "index.js", + "scripts": { + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@echo/core-auth": "^1.0.0", + "@echo/core-types": "^1.0.0", + "@echo/services-bootstrap": "^1.0.0", + "effect": "^3.6.5" + } +} \ No newline at end of file diff --git a/packages/services/app-init/src/app-init.ts b/packages/services/app-init/src/app-init.ts new file mode 100644 index 0000000..82aa435 --- /dev/null +++ b/packages/services/app-init/src/app-init.ts @@ -0,0 +1,106 @@ +import { isValidToken } from "@echo/core-auth"; +import { + AppInit, + AvailableProviders, + LocalStorage, + MediaPlayerFactory, + MediaProviderFactory, + MediaProviderMainThreadBroadcastChannel, + type ProviderStartArgs, + type BroadcastChannel, + type ILocalStorage, + type MediaProviderBroadcastSchema, + type ProviderId, + ActiveMediaProviderCache, + type IActiveMediaProviderCache, +} from "@echo/core-types"; +import { + LazyLoadedMediaPlayer, + LazyLoadedProvider, +} from "@echo/services-bootstrap"; +import { Effect, Layer, Option } from "effect"; + +const make = Effect.gen(function* () { + const activeMediaProviderCache = yield* ActiveMediaProviderCache; + const broadcastChannel = yield* MediaProviderMainThreadBroadcastChannel; + const lazyLoadedProvider = yield* LazyLoadedProvider; + const lazyLoaderMediaPlayer = yield* LazyLoadedMediaPlayer; + const localStorage = yield* LocalStorage; + + return AppInit.of({ + init: Effect.gen(function* () { + const allProviderStates = yield* retrieveAllProviderArgs(localStorage); + + return yield* Effect.all( + allProviderStates.map((providerStartArgs) => + Effect.gen(function* () { + const retrievedMetadata = { + id: providerStartArgs.value.metadata.id, + type: providerStartArgs.value.metadata.type, + }; + + const providerFactory = + yield* lazyLoadedProvider.load(retrievedMetadata); + const mediaPlayerFactory = + yield* lazyLoaderMediaPlayer.load(retrievedMetadata); + + return yield* reinitializeProvider( + providerStartArgs.value, + providerFactory.createMediaProvider, + mediaPlayerFactory.createMediaPlayer, + broadcastChannel, + activeMediaProviderCache, + ); + }), + ), + ); + }), + }); +}); + +const retrieveAllProviderArgs = (localStorage: ILocalStorage) => + Effect.gen(function* () { + const allProviders = yield* Effect.all( + AvailableProviders.map((provider) => + retrieveProviderArgs(provider.id, localStorage), + ), + ); + return allProviders.filter(Option.isSome); + }); + +const retrieveProviderArgs = ( + providerId: ProviderId, + localStorage: ILocalStorage, +) => + localStorage.get("media-provider-start-args", providerId); + +const reinitializeProvider = ( + startArgs: ProviderStartArgs, + createMediaProvider: MediaProviderFactory["createMediaProvider"], + createMediaPlayer: MediaPlayerFactory["createMediaPlayer"], + broadcastChannel: BroadcastChannel< + MediaProviderBroadcastSchema["mainThread"] + >, + activeMediaProviderCache: IActiveMediaProviderCache, +) => + Effect.gen(function* () { + // TODO: This should attempt to refresh the token if it's expired. + // TODO: Instead of ignoring the initialization with a warning, we should notify the user. + if (!isValidToken(startArgs.authInfo)) { + yield* Effect.logWarning( + `The retrieved token for ${startArgs.metadata.id} is invalid, ignoring initialization.`, + ); + } + + const mediaProvider = createMediaProvider(startArgs.authInfo); + const mediaPlayer = yield* createMediaPlayer(startArgs.authInfo); + + yield* broadcastChannel.send("start", startArgs); + yield* activeMediaProviderCache.add( + startArgs.metadata, + mediaProvider, + mediaPlayer, + ); + }); + +export const AppInitLive = Layer.effect(AppInit, make); diff --git a/packages/services/app-init/tsconfig.json b/packages/services/app-init/tsconfig.json new file mode 100644 index 0000000..6953ff5 --- /dev/null +++ b/packages/services/app-init/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "src", + "index.ts" + ] +} \ No newline at end of file diff --git a/packages/services/bootstrap/package.json b/packages/services/bootstrap/package.json index f0b1393..c552caf 100644 --- a/packages/services/bootstrap/package.json +++ b/packages/services/bootstrap/package.json @@ -12,6 +12,7 @@ "@echo/core-types": "^1.0.0", "@echo/infrastructure-broadcast-channel": "^1.0.0", "@echo/infrastructure-browser-crypto": "^1.0.0", + "@echo/infrastructure-browser-local-storage": "^1.0.0", "@echo/infrastructure-dexie-database": "^1.0.0", "@echo/infrastructure-mmb-metadata-provider": "^1.0.0", "@echo/infrastructure-html-audio-media-player": "^1.0.0", diff --git a/packages/services/bootstrap/src/layers.ts b/packages/services/bootstrap/src/layers.ts index d681725..ab2fce1 100644 --- a/packages/services/bootstrap/src/layers.ts +++ b/packages/services/bootstrap/src/layers.ts @@ -11,6 +11,7 @@ import { AppConfigLive } from "./app-config"; import { LazyLoadedMediaPlayerLive } from "./loaders/player"; import { ActiveMediaProviderCacheLive } from "@echo/services-active-media-provider-cache"; import { MediaProviderStatusLive } from "@echo/services-media-provider-status"; +import { BrowserLocalStorageLive } from "@echo/infrastructure-browser-local-storage"; /** * Exports a layer that can provide all dependencies that are needed in the @@ -22,6 +23,7 @@ export const MainLive = ActiveMediaProviderCacheLive.pipe( Layer.provideMerge(MediaProviderWorkerBroadcastChannelLive), Layer.provideMerge(LazyLoadedProviderLive), Layer.provideMerge(LazyLoadedMediaPlayerLive), + Layer.provideMerge(BrowserLocalStorageLive), Layer.provideMerge(BrowserCryptoLive), Layer.provideMerge(DexieDatabaseLive), Layer.provideMerge(AppConfigLive), diff --git a/packages/web/package.json b/packages/web/package.json index 633ca5a..27c1878 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,6 +15,8 @@ "@echo/components-library": "^1.0.0", "@echo/components-provider-status": "^1.0.0", "@echo/core-types": "^1.0.0", + "@echo/services-app-init": "^1.0.0", + "@echo/services-bootstrap": "^1.0.0", "@echo/services-bootstrap-workers": "^1.0.0", "@effect-rx/rx": "0.33.8", "@effect-rx/rx-react": "0.30.11", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 3f5cc5d..55e387c 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,11 +1,36 @@ import { AddProvider } from "@echo/components-add-provider"; import { UserLibrary } from "@echo/components-library"; import { ProviderStatus } from "@echo/components-provider-status"; +import { AppInit } from "@echo/core-types"; +import { AppInitLive } from "@echo/services-app-init"; +import { MainLive } from "@echo/services-bootstrap"; +import { Rx } from "@effect-rx/rx"; +import { useRx } from "@effect-rx/rx-react"; +import { Layer, Match } from "effect"; +import { useEffect } from "react"; -export const App = () => ( -
- - - -
-); +const runtime = Rx.runtime(AppInitLive.pipe(Layer.provide(MainLive))); +const appInitRx = runtime.fn(() => AppInit.init); + +export const App = () => { + const [initStatus, init] = useRx(appInitRx); + + useEffect(init, [init]); + + return Match.value(initStatus).pipe( + Match.tag("Initial", () =>

Initializing Echo...

), + Match.tag("Success", () => ( +
+ + + +
+ )), + Match.tag("Failure", () => ( +

+ Ooops, something went wrong. Please report it! +

+ )), + Match.exhaustive, + ); +};