diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c3cab1..20ac631e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +### V0.37.0 + +- Adds browser extension support +- Moves events onto top-level program and renames them. For example, the `local-change` is now `fileSystem:local-change`. +- Adds session create and destroy events + ### V0.36.3 Now parses DAG-JSON formatted CIDs. diff --git a/package.json b/package.json index 58e4eec8..0a916550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webnative", - "version": "0.36.3", + "version": "0.37.0", "description": "Webnative SDK", "keywords": [ "WebCrypto", diff --git a/src/common/version.ts b/src/common/version.ts index 2ad942a8..c54b6368 100644 --- a/src/common/version.ts +++ b/src/common/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "0.36.3" +export const VERSION = "0.37.0" export const WASM_WNFS_VERSION = "0.1.7" diff --git a/src/components/auth/implementation.ts b/src/components/auth/implementation.ts index 3d1a9b8c..1a880b28 100644 --- a/src/components/auth/implementation.ts +++ b/src/components/auth/implementation.ts @@ -10,7 +10,12 @@ export type Implementation = { type: string // `Session` producer - session: (components: C, authenticatedUsername: Maybe, config: Configuration, eventEmitters: { fileSystem: Events.Emitter }) => Promise> + session: ( + components: C, + authenticatedUsername: Maybe, + config: Configuration, + eventEmitters: { fileSystem: Events.Emitter; session: Events.Emitter> } + ) => Promise> // Account creation isUsernameAvailable: (username: string) => Promise diff --git a/src/components/auth/implementation/base.ts b/src/components/auth/implementation/base.ts index 80fdff2c..108a4760 100644 --- a/src/components/auth/implementation/base.ts +++ b/src/components/auth/implementation/base.ts @@ -3,6 +3,7 @@ import * as Reference from "../../reference/implementation.js" import * as Storage from "../../storage/implementation.js" import * as Did from "../../../did/index.js" +import * as Events from "../../../events.js" import * as SessionMod from "../../../session.js" import * as Ucan from "../../../ucan/index.js" @@ -108,16 +109,20 @@ export async function register( export async function session( components: Components, authedUsername: Maybe, - config: Configuration + config: Configuration, + eventEmitters: { session: Events.Emitter> } ): Promise> { if (authedUsername) { - return new Session({ + const session = new Session({ crypto: components.crypto, storage: components.storage, + eventEmitter: eventEmitters.session, type: TYPE, username: authedUsername }) + return session + } else { return null diff --git a/src/components/auth/implementation/wnfs.ts b/src/components/auth/implementation/wnfs.ts index 03c9b3aa..ac6d416c 100644 --- a/src/components/auth/implementation/wnfs.ts +++ b/src/components/auth/implementation/wnfs.ts @@ -89,7 +89,7 @@ export async function session( components: Components, authedUsername: Maybe, config: Configuration, - eventEmitters: { fileSystem: Events.Emitter } + eventEmitters: { fileSystem: Events.Emitter; session: Events.Emitter> } ): Promise> { if (authedUsername) { // Self-authorize a filesystem UCAN if needed @@ -140,14 +140,18 @@ export async function session( username: authedUsername, }) - // Fin - return new Session({ + + const session = new Session({ crypto: components.crypto, fs: fs, + eventEmitter: eventEmitters.session, storage: components.storage, type: Base.TYPE, username: authedUsername }) + + // Fin + return session } return null diff --git a/src/configuration.ts b/src/configuration.ts index 3f688cbb..d7ae9a1b 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -20,6 +20,13 @@ export type Configuration = { * Debugging settings. */ debugging?: { + /** + * Should I emit window post messages with session and filesystem information? + * + * @default true + */ + emitWindowPostMessages?: boolean + /** * Should I add programs to the global context while in debugging mode? * diff --git a/src/events.ts b/src/events.ts index 9389be4b..ed2d5170 100644 --- a/src/events.ts +++ b/src/events.ts @@ -13,13 +13,13 @@ export { EventEmitter, EventEmitter as Emitter } * alternatively you can use `addListener` and `removeListener`. * * ```ts - * program.fileSystem.on("local-change", ({ path, root }) => { + * program.on("fileSystem:local-change", ({ path, root }) => { * console.log("The file system has changed locally 🔔") * console.log("Changed path:", path) * console.log("New data root CID:", root) * }) * - * program.fileSystem.off("publish") + * program.off("fileSystem:publish") * ``` */ export type ListenTo = Pick< @@ -29,11 +29,20 @@ export type ListenTo = Pick< export type FileSystem = { - "local-change": { root: CID; path: DistinctivePath> } - "publish": { root: CID } + "fileSystem:local-change": { root: CID; path: DistinctivePath> } + "fileSystem:publish": { root: CID } } +export type Session = { + "session:create": { session: S } + "session:destroy": { username: string } +} + + +export type All = FileSystem & Session + + export function createEmitter(): EventEmitter { return new EventEmitter() } @@ -46,4 +55,23 @@ export function listenTo(emitter: EventEmitter): ListenTo(a: EventEmitter, b: EventEmitter): EventEmitter { + const merged = createEmitter() + const aEmit = a.emit + const bEmit = b.emit + + a.emit = (eventName: K, event: (A & B)[ K ]) => { + aEmit.call(a, eventName, event) + merged.emit(eventName, event) + } + + b.emit = (eventName: K, event: (A & B)[ K ]) => { + bEmit.call(b, eventName, event) + merged.emit(eventName, event) + } + + return merged } \ No newline at end of file diff --git a/src/extension/index.ts b/src/extension/index.ts new file mode 100644 index 00000000..219a7489 --- /dev/null +++ b/src/extension/index.ts @@ -0,0 +1,243 @@ +import type { AppInfo } from "../appInfo.js" +import type { CID } from "../common/cid.js" +import type { Crypto, Reference } from "../components.js" +import type { DistinctivePath, Partition } from "../path/index.js" +import type { Maybe } from "../common/types.js" +import type { Permissions } from "../permissions.js" +import type { Session } from "../session.js" + +import * as DID from "../did/index.js" +import * as Events from "../events.js" +import { VERSION } from "../index.js" + + +// CREATE + +export type Dependencies = { + crypto: Crypto.Implementation + reference: Reference.Implementation +} + +type Config = { + namespace: AppInfo | string + session: Maybe + capabilities?: Permissions + dependencies: Dependencies + eventEmitters: { + fileSystem: Events.Emitter + session: Events.Emitter> + } +} + +export async function create(config: Config): Promise<{ + connect: (extensionId: string) => void + disconnect: (extensionId: string) => void +}> { + let connection: Connection = { extensionId: null, connected: false } + let listeners: Listeners + + return { + connect: async (extensionId: string) => { + connection = await connect(extensionId, config) + + // The extension may call connect more than once, but + // listeners should only be added once + if (!listeners) listeners = listen(connection, config) + }, + disconnect: async (extensionId: string) => { + connection = await disconnect(extensionId, config) + stopListening(config, listeners) + } + } +} + + + +// CONNECTION + + +type Connection = { + extensionId: string | null + connected: boolean +} + +async function connect(extensionId: string, config: Config): Promise { + const state = await getState(config) + + globalThis.postMessage({ + id: extensionId, + type: "connect", + timestamp: Date.now(), + state + }) + + return { extensionId, connected: true } +} + +async function disconnect(extensionId: string, config: Config): Promise { + const state = await getState(config) + + globalThis.postMessage({ + id: extensionId, + type: "disconnect", + timestamp: Date.now(), + state + }) + + return { extensionId, connected: false } +} + + + +// LISTENERS + + +type Listeners = { + handleLocalChange: (params: { root: CID; path: DistinctivePath<[ Partition, ...string[] ]> }) => Promise + handlePublish: (params: { root: CID }) => Promise + handleSessionCreate: (params: { session: Session }) => Promise + handleSessionDestroy: (params: { username: string }) => Promise +} + +function listen(connection: Connection, config: Config): Listeners { + async function handleLocalChange(params: { root: CID; path: DistinctivePath<[ Partition, ...string[] ]> }) { + const { root, path } = params + const state = await getState(config) + + globalThis.postMessage({ + id: connection.extensionId, + type: "fileSystem", + timestamp: Date.now(), + state, + detail: { + type: "local-change", + root: root.toString(), + path + } + }) + } + + async function handlePublish(params: { root: CID }) { + const { root } = params + const state = await getState(config) + + globalThis.postMessage({ + id: connection.extensionId, + type: "fileSystem", + timestamp: Date.now(), + state, + detail: { + type: "publish", + root: root.toString() + } + }) + } + + async function handleSessionCreate(params: { session: Session }) { + const { session } = params + + config = { ...config, session } + const state = await getState(config) + + globalThis.postMessage({ + id: connection.extensionId, + type: "session", + timestamp: Date.now(), + state, + detail: { + type: "create", + username: session.username + } + }) + } + + async function handleSessionDestroy(params: { username: string }) { + const { username } = params + + config = { ...config, session: null } + const state = await getState(config) + + globalThis.postMessage({ + id: connection.extensionId, + type: "session", + timestamp: Date.now(), + state, + detail: { + type: "destroy", + username + } + }) + } + + config.eventEmitters.fileSystem.on("fileSystem:local-change", handleLocalChange) + config.eventEmitters.fileSystem.on("fileSystem:publish", handlePublish) + config.eventEmitters.session.on("session:create", handleSessionCreate) + config.eventEmitters.session.on("session:destroy", handleSessionDestroy) + + return { handleLocalChange, handlePublish, handleSessionCreate, handleSessionDestroy } +} + +function stopListening(config: Config, listeners: Listeners) { + if (listeners) { + config.eventEmitters.fileSystem.removeListener("fileSystem:local-change", listeners.handleLocalChange) + config.eventEmitters.fileSystem.removeListener("fileSystem:publish", listeners.handlePublish) + config.eventEmitters.session.removeListener("session:create", listeners.handleSessionCreate) + config.eventEmitters.session.removeListener("session:destroy", listeners.handleSessionDestroy) + } +} + + + +// STATE + + +type State = { + app: { + namespace: AppInfo | string + capabilities?: Permissions + } + fileSystem: { + dataRootCID: string | null + } + user: { + username: string | null + accountDID: string | null + agentDID: string + } + odd: { + version: string + } +} + +async function getState(config: Config): Promise { + const { capabilities, dependencies, namespace, session } = config + + const agentDID = await DID.agent(dependencies.crypto) + let accountDID = null + let username = null + let dataRootCID = null + + if (session && session.username) { + username = session.username + accountDID = await dependencies.reference.didRoot.lookup(username) + dataRootCID = await dependencies.reference.dataRoot.lookup(username) + } + + return { + app: { + namespace, + ...(capabilities ? { capabilities } : {}) + }, + fileSystem: { + dataRootCID: dataRootCID?.toString() ?? null + }, + user: { + username, + accountDID, + agentDID + }, + odd: { + version: VERSION + } + } +} \ No newline at end of file diff --git a/src/fs/filesystem.ts b/src/fs/filesystem.ts index d3fbeef0..51d2594d 100644 --- a/src/fs/filesystem.ts +++ b/src/fs/filesystem.ts @@ -128,7 +128,7 @@ export class FileSystem implements API { this._publishing = [ cid, true ] return this.dependencies.reference.dataRoot.update(cid, proof).then(() => { if (this._publishing && this._publishing[ 0 ] === cid) { - eventEmitter.emit("publish", { root: cid }) + eventEmitter.emit("fileSystem:publish", { root: cid }) this._publishing = false } }) @@ -772,7 +772,7 @@ export class FileSystem implements API { } this.eventEmitter.emit( - "local-change", + "fileSystem:local-change", { root: await this.root.put(), path } ) } diff --git a/src/index.ts b/src/index.ts index ccd11d08..06bb823e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import * as Crypto from "./components/crypto/implementation.js" import * as Depot from "./components/depot/implementation.js" import * as DID from "./did/local.js" import * as Events from "./events.js" +import * as Extension from "./extension/index.js" import * as FileSystemData from "./fs/data.js" import * as IpfsNode from "./components/depot/implementation/ipfs/node.js" import * as Manners from "./components/manners/implementation.js" @@ -121,7 +122,7 @@ export type AuthenticationStrategy = { } -export type Program = ShortHands & { +export type Program = ShortHands & Events.ListenTo> & { /** * Authentication strategy, use this interface to register an account and link devices. */ @@ -160,7 +161,7 @@ export type Program = ShortHands & { /** * Various file system methods. */ - fileSystem: FileSystemShortHands & Events.ListenTo + fileSystem: FileSystemShortHands /** * Existing session, if there is one. @@ -475,8 +476,10 @@ export async function assemble(config: Configuration, components: Components): P // Backwards compatibility (data) await ensureBackwardsCompatibility(components, config) - // Event emitter + // Event emitters const fsEvents = Events.createEmitter() + const sessionEvents = Events.createEmitter>() + const allEvents = Events.merge(fsEvents, sessionEvents) // Authenticated user const sessionInfo = await SessionMod.restore(components.storage) @@ -512,7 +515,7 @@ export async function assemble(config: Configuration, components: Components): P components, newSessionInfo.username, config, - { fileSystem: fsEvents } + { fileSystem: fsEvents, session: sessionEvents } ) } } @@ -580,6 +583,7 @@ export async function assemble(config: Configuration, components: Components): P crypto: components.crypto, storage: components.storage, type: CAPABILITIES_SESSION_TYPE, + eventEmitter: sessionEvents }) } } @@ -606,8 +610,6 @@ export async function assemble(config: Configuration, components: Components): P // File system fileSystem: { - ...Events.listenTo(fsEvents), - addPublicExchangeKey: (fs: FileSystem) => FileSystemData.addPublicExchangeKey(components.crypto, fs), addSampleData: (fs: FileSystem) => FileSystemData.addSampleData(fs), hasPublicExchangeKey: (fs: FileSystem) => FileSystemData.hasPublicExchangeKey(components.crypto, fs), @@ -619,6 +621,7 @@ export async function assemble(config: Configuration, components: Components): P // Create `Program` const program = { ...shorthands, + ...Events.listenTo(allEvents), configuration: { ...config }, auth, @@ -635,9 +638,37 @@ export async function assemble(config: Configuration, components: Components): P if (inject) { const container = globalThis as any - container.__webnative = container.__webnative || {} - container.__webnative.programs = container.__webnative.programs || {} - container.__webnative.programs[ namespace(config) ] = program + container.__odd = container.__odd || {} + container.__odd.programs = container.__odd.programs || {} + container.__odd.programs[ namespace(config) ] = program + } + + const emitMessages = config.debugging?.emitWindowPostMessages === undefined + ? true + : config.debugging?.emitWindowPostMessages + + if (emitMessages) { + const { connect, disconnect } = await Extension.create({ + namespace: config.namespace, + session, + capabilities: config.permissions, + dependencies: components, + eventEmitters: { + fileSystem: fsEvents, + session: sessionEvents + } + }) + + const container = globalThis as any + container.__odd = container.__odd || {} + container.__odd.extension = container.__odd.extension || {} + container.__odd.extension.connect = connect + container.__odd.extension.disconnect = disconnect + + // Notify extension that ODD is ready + globalThis.postMessage({ + id: "odd-devtools-ready-message", + }) } } diff --git a/src/session.ts b/src/session.ts index 3dd23764..a1bc3468 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,5 +1,7 @@ import * as Crypto from "./components/crypto/implementation.js" import * as Storage from "./components/storage/implementation.js" + +import * as Events from "./events.js" import * as TypeChecks from "./common/type-checks.js" import { Maybe } from "./common/types.js" @@ -14,6 +16,8 @@ export class Session { #crypto: Crypto.Implementation #storage: Storage.Implementation + #eventEmitter: Events.Emitter> + fs?: FileSystem type: string username: string @@ -22,6 +26,8 @@ export class Session { crypto: Crypto.Implementation storage: Storage.Implementation + eventEmitter: Events.Emitter> + fs?: FileSystem type: string username: string @@ -29,13 +35,19 @@ export class Session { this.#crypto = props.crypto this.#storage = props.storage + this.#eventEmitter = props.eventEmitter + this.fs = props.fs this.type = props.type this.username = props.username + + this.#eventEmitter.emit("session:create", { session: this }) } async destroy() { + this.#eventEmitter.emit("session:destroy", { username: this.username }) + await this.#storage.removeItem(this.#storage.KEYS.ACCOUNT_UCAN) await this.#storage.removeItem(this.#storage.KEYS.CID_LOG) await this.#storage.removeItem(this.#storage.KEYS.SESSION)