diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3f20ee763de06116b49e3d0692dec10c2822cb64..e1db8fbdd50c958f5701105d7aacc72f0a493127 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,9 +19,10 @@ before_script: stages: - test - build + - combine-artifacts - tag - doc-update - - combine_artefacts + - version-check go-test: stage: test @@ -50,26 +51,28 @@ build: script: - go mod vendor -v - mkdir -p release - - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -o release/xxdk.wasm main.go + - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o release/xxdk.wasm main.go - cp wasm_exec.js release/ artifacts: paths: - release/ - expire_in: 1 hour -emoji-update: +build-workers: stage: build except: - tags script: - go mod vendor -v - mkdir -p release - - go run -ldflags '-w -s' ./emoji/... -o emojiSet.json -v 2 - - cp emojiSet.json release/ + - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o release/xxdk-channelsIndexedDkWorker.wasm ./indexedDb/impl/channels/... + - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o release/xxdk-dmIndexedDkWorker.wasm ./indexedDb/impl/dm/... + - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o release/xxdk-logFileWorker.wasm ./logging/workerThread/... + - cp indexedDb/impl/channels/channelsIndexedDbWorker.js release/ + - cp indexedDb/impl/dm/dmIndexedDbWorker.js release/ + - cp logging/workerThread/logFileWorker.js release/ artifacts: paths: - release/ - expire_in: 1 hour tag: stage: build @@ -82,8 +85,8 @@ tag: - git tag $(sha256sum release/xxdk.wasm | awk '{ print $1 }') -f - git push origin_tags -f --tags -combine_artefacts: - stage: combine_artefacts +combine-artifacts: + stage: combine-artifacts except: - tags image: $DOCKER_IMAGE @@ -92,17 +95,22 @@ combine_artefacts: - 'PIPELINE_JOBS=$(curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/pipelines/$CI_PIPELINE_ID/jobs)' - echo $PIPELINE_JOBS - BUILD_JOB_JSON=$(echo $PIPELINE_JOBS | jq '.[] | select(.name=="build")') - - EMOJI_UPDATE_JOB_JSON=$(echo $PIPELINE_JOBS | jq '.[] | select(.name=="emoji-update")') + - BUILD_WORKERS_JOB_JSON=$(echo $PIPELINE_JOBS | jq '.[] | select(.name=="build-workers")') - BUILD_JOB_ID=$(echo $BUILD_JOB_JSON | jq -r '.["id"]') - - EMOJI_UPDATE_JOB_ID=$(echo $EMOJI_UPDATE_JOB_JSON | jq -r '.["id"]') + - BUILD_WORKERS_JOB_ID=$(echo $BUILD_WORKERS_JOB_JSON | jq -r '.["id"]') - rm -rf release - mkdir -p release - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/wasm_exec.js $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_JOB_ID/artifacts/release/wasm_exec.js' - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/xxdk.wasm $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_JOB_ID/artifacts/release/xxdk.wasm' - - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/emojiSet.json $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$EMOJI_UPDATE_JOB_ID/artifacts/release/emojiSet.json' + - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/xxdk-channelsIndexedDkWorker.wasm $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_WORKERS_JOB_ID/artifacts/release/xxdk-channelsIndexedDkWorker.wasm' + - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/xxdk-dmIndexedDkWorker.wasm $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_WORKERS_JOB_ID/artifacts/release/xxdk-dmIndexedDkWorker.wasm' + - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/xxdk-logFileWorker.wasm $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_WORKERS_JOB_ID/artifacts/release/xxdk-logFileWorker.wasm' + - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/channelsIndexedDbWorker.js $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_WORKERS_JOB_ID/artifacts/release/channelsIndexedDbWorker.js' + - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/dmIndexedDbWorker.js $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_WORKERS_JOB_ID/artifacts/release/dmIndexedDbWorker.js' + - 'curl --header "PRIVATE-TOKEN: $GITLAB_ACCESS_TOKEN" --output release/logFileWorker.js $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/jobs/$BUILD_WORKERS_JOB_ID/artifacts/release/logFileWorker.js' - ls release artifacts: @@ -112,18 +120,31 @@ combine_artefacts: # This pipeline job will attempt to have pkg.go.dev update docs for xxdk-wasm. # -# pkg.go.dev relies on the proxy.golang.org service (go module cache/proxy) to discover versions of -# Go modules to make docs of. The proxy keeps a list of all known versions of Go modules. The go -# mod proxy does cache pulls for about 30 minutes, so if quickly successive commits are done in -# master/release, this will fail to pull the latest client, and the docs will not update. +# pkg.go.dev relies on the proxy.golang.org service (go module cache/proxy) to +# discover versions of Go modules to make docs of. The proxy keeps a list of all +# known versions of Go modules. The go mod proxy does cache pulls for about 30 +# minutes, so if quickly successive commits are done in master/release, this +# will fail to pull the latest client, and the docs will not update. doc-update: stage: doc-update except: - tags image: $DOCKER_IMAGE script: - # We use GOPRIVATE blank because not want to directly pull client, we want to use the public cache. + # GOPRIVATE is cleared so that the public cache is pulled instead of directly pulling client. - GOOS=js GOARCH=wasm GOPRIVATE="" go install gitlab.com/elixxir/xxdk-wasm@$CI_COMMIT_SHA only: - release - master + +version-check: + stage: version-check + except: + - tags + only: + - master + image: $DOCKER_IMAGE + script: + - GITTAG=$(git describe --tags) + - CODEVERS=$(cat storage/version.go | grep "const SEMVER =" | cut -d ' ' -f4 | tr -d '"') + - if [[ $GITTAG != $CODEVERS ]]; then echo "VERSION NUMBER BAD $GITTAG != $CODEVERS"; exit -1; fi \ No newline at end of file diff --git a/Makefile b/Makefile index 4a6c40fd662d82ea2d2060f874c3a88ca266a0e3..bc170d109666ddd78cbfd9a9dc0388b2559b5504 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,13 @@ update_master: binary: GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o xxdk.wasm main.go +worker_binaries: + GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o xxdk-channelsIndexedDkWorker.wasm ./indexedDb/impl/channels/... + GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o xxdk-dmIndexedDkWorker.wasm ./indexedDb/impl/dm/... + GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o xxdk-logFileWorker.wasm ./logging/workerThread/... + +binaries: binary worker_binaries + wasm_tests: cp utils/utils_js.s utils/utils_js.s.bak > utils/utils_js.s diff --git a/indexedDb/impl/channels/callbacks.go b/indexedDb/impl/channels/callbacks.go new file mode 100644 index 0000000000000000000000000000000000000000..68cc92d71f962ab4adc5d734f598561c95b006ea --- /dev/null +++ b/indexedDb/impl/channels/callbacks.go @@ -0,0 +1,446 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package main + +import ( + "crypto/ed25519" + "encoding/json" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/v4/channels" + "gitlab.com/elixxir/client/v4/cmix/rounds" + cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" + cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/crypto/fastRNG" + "gitlab.com/elixxir/crypto/message" + wChannels "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/channels" + "gitlab.com/elixxir/xxdk-wasm/worker" + "gitlab.com/xx_network/crypto/csprng" + "gitlab.com/xx_network/primitives/id" + "time" +) + +var zeroUUID = []byte{0, 0, 0, 0, 0, 0, 0, 0} + +// manager handles the event model and the message callbacks, which is used to +// send information between the event model and the main thread. +type manager struct { + mh *worker.ThreadManager + model channels.EventModel +} + +// registerCallbacks registers all the reception callbacks to manage messages +// from the main thread for the channels.EventModel. +func (m *manager) registerCallbacks() { + m.mh.RegisterCallback(wChannels.NewWASMEventModelTag, m.newWASMEventModelCB) + m.mh.RegisterCallback(wChannels.JoinChannelTag, m.joinChannelCB) + m.mh.RegisterCallback(wChannels.LeaveChannelTag, m.leaveChannelCB) + m.mh.RegisterCallback(wChannels.ReceiveMessageTag, m.receiveMessageCB) + m.mh.RegisterCallback(wChannels.ReceiveReplyTag, m.receiveReplyCB) + m.mh.RegisterCallback(wChannels.ReceiveReactionTag, m.receiveReactionCB) + m.mh.RegisterCallback(wChannels.UpdateFromUUIDTag, m.updateFromUUIDCB) + m.mh.RegisterCallback(wChannels.UpdateFromMessageIDTag, m.updateFromMessageIDCB) + m.mh.RegisterCallback(wChannels.GetMessageTag, m.getMessageCB) + m.mh.RegisterCallback(wChannels.DeleteMessageTag, m.deleteMessageCB) + m.mh.RegisterCallback(wChannels.MuteUserTag, m.muteUserCB) +} + +// newWASMEventModelCB is the callback for NewWASMEventModel. Returns an empty +// slice on success or an error message on failure. +func (m *manager) newWASMEventModelCB(data []byte) ([]byte, error) { + var msg wChannels.NewWASMEventModelMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return []byte{}, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + // Create new encryption cipher + rng := fastRNG.NewStreamGenerator(12, 1024, csprng.NewSystemRNG) + encryption, err := cryptoChannel.NewCipherFromJSON( + []byte(msg.EncryptionJSON), rng.GetStream()) + if err != nil { + return []byte{}, errors.Errorf( + "failed to JSON unmarshal Cipher from main thread: %+v", err) + } + + m.model, err = NewWASMEventModel(msg.Path, encryption, + m.messageReceivedCallback, m.deletedMessageCallback, m.mutedUserCallback, + m.storeDatabaseName, m.storeEncryptionStatus) + if err != nil { + return []byte(err.Error()), nil + } + return []byte{}, nil +} + +// messageReceivedCallback sends calls to the channels.MessageReceivedCallback +// in the main thread. +// +// storeEncryptionStatus adhere to the channels.MessageReceivedCallback type. +func (m *manager) messageReceivedCallback( + uuid uint64, channelID *id.ID, update bool) { + // Package parameters for sending + msg := &wChannels.MessageReceivedCallbackMessage{ + UUID: uuid, + ChannelID: channelID, + Update: update, + } + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf("Could not JSON marshal %T: %+v", msg, err) + return + } + + // Send it to the main thread + m.mh.SendMessage(wChannels.MessageReceivedCallbackTag, data) +} + +// deletedMessageCallback sends calls to the channels.DeletedMessageCallback in +// the main thread. +// +// storeEncryptionStatus adhere to the channels.MessageReceivedCallback type. +func (m *manager) deletedMessageCallback(messageID message.ID) { + m.mh.SendMessage(wChannels.DeletedMessageCallbackTag, messageID.Marshal()) +} + +// mutedUserCallback sends calls to the channels.MutedUserCallback in the main +// thread. +// +// storeEncryptionStatus adhere to the channels.MessageReceivedCallback type. +func (m *manager) mutedUserCallback( + channelID *id.ID, pubKey ed25519.PublicKey, unmute bool) { + // Package parameters for sending + msg := &wChannels.MuteUserMessage{ + ChannelID: channelID, + PubKey: pubKey, + Unmute: unmute, + } + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf("Could not JSON marshal %T: %+v", msg, err) + return + } + + // Send it to the main thread + m.mh.SendMessage(wChannels.MutedUserCallbackTag, data) +} + +// storeDatabaseName sends the database name to the main thread and waits for +// the response. This function mocks the behavior of storage.StoreIndexedDb. +// +// storeDatabaseName adheres to the storeDatabaseNameFn type. +func (m *manager) storeDatabaseName(databaseName string) error { + // Register response callback with channel that will wait for the response + responseChan := make(chan []byte) + m.mh.RegisterCallback(wChannels.StoreDatabaseNameTag, + func(data []byte) ([]byte, error) { + responseChan <- data + return nil, nil + }) + + // Send encryption status to main thread + m.mh.SendMessage(wChannels.StoreDatabaseNameTag, []byte(databaseName)) + + // Wait for response + select { + case response := <-responseChan: + if len(response) > 0 { + return errors.New(string(response)) + } + case <-time.After(worker.ResponseTimeout): + return errors.Errorf("[WW] Timed out after %s waiting for response "+ + "about storing the database name in local storage in the main "+ + "thread", worker.ResponseTimeout) + } + + return nil +} + +// storeEncryptionStatus sends the database name and encryption status to the +// main thread and waits for the response. If the value has not been previously +// saved, it returns the saves encryption status. This function mocks the +// behavior of storage.StoreIndexedDbEncryptionStatus. +// +// storeEncryptionStatus adheres to the storeEncryptionStatusFn type. +func (m *manager) storeEncryptionStatus( + databaseName string, encryption bool) (bool, error) { + // Package parameters for sending + msg := &wChannels.EncryptionStatusMessage{ + DatabaseName: databaseName, + EncryptionStatus: encryption, + } + data, err := json.Marshal(msg) + if err != nil { + return false, err + } + + // Register response callback with channel that will wait for the response + responseChan := make(chan []byte) + m.mh.RegisterCallback(wChannels.EncryptionStatusTag, + func(data []byte) ([]byte, error) { + responseChan <- data + return nil, nil + }) + + // Send encryption status to main thread + m.mh.SendMessage(wChannels.EncryptionStatusTag, data) + + // Wait for response + var response wChannels.EncryptionStatusReply + select { + case responseData := <-responseChan: + if err = json.Unmarshal(responseData, &response); err != nil { + return false, err + } + case <-time.After(worker.ResponseTimeout): + return false, errors.Errorf("timed out after %s waiting for "+ + "response about the database encryption status from local "+ + "storage in the main thread", worker.ResponseTimeout) + } + + // If the response contain an error, return it + if response.Error != "" { + return false, errors.New(response.Error) + } + + // Return the encryption status + return response.EncryptionStatus, nil +} + +// joinChannelCB is the callback for wasmModel.JoinChannel. Always returns nil; +// meaning, no response is supplied (or expected). +func (m *manager) joinChannelCB(data []byte) ([]byte, error) { + var channel cryptoBroadcast.Channel + err := json.Unmarshal(data, &channel) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", channel, err) + } + + m.model.JoinChannel(&channel) + return nil, nil +} + +// leaveChannelCB is the callback for wasmModel.LeaveChannel. Always returns +// nil; meaning, no response is supplied (or expected). +func (m *manager) leaveChannelCB(data []byte) ([]byte, error) { + channelID, err := id.Unmarshal(data) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", channelID, err) + } + + m.model.LeaveChannel(channelID) + return nil, nil +} + +// receiveMessageCB is the callback for wasmModel.ReceiveMessage. Returns a UUID +// of 0 on error or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveMessageCB(data []byte) ([]byte, error) { + var msg channels.ModelMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return zeroUUID, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.ReceiveMessage(msg.ChannelID, msg.MessageID, msg.Nickname, + string(msg.Content), msg.PubKey, msg.DmToken, msg.CodesetVersion, + msg.Timestamp, msg.Lease, rounds.Round{ID: msg.Round}, msg.Type, + msg.Status, msg.Hidden) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return zeroUUID, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + return uuidData, nil +} + +// receiveReplyCB is the callback for wasmModel.ReceiveReply. Returns a UUID of +// 0 on error or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveReplyCB(data []byte) ([]byte, error) { + var msg wChannels.ReceiveReplyMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return zeroUUID, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.ReceiveReply(msg.ChannelID, msg.MessageID, msg.ReactionTo, + msg.Nickname, string(msg.Content), msg.PubKey, msg.DmToken, + msg.CodesetVersion, msg.Timestamp, msg.Lease, + rounds.Round{ID: msg.Round}, msg.Type, msg.Status, msg.Hidden) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return zeroUUID, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + return uuidData, nil +} + +// receiveReactionCB is the callback for wasmModel.ReceiveReaction. Returns a +// UUID of 0 on error or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveReactionCB(data []byte) ([]byte, error) { + var msg wChannels.ReceiveReplyMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return zeroUUID, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.ReceiveReaction(msg.ChannelID, msg.MessageID, + msg.ReactionTo, msg.Nickname, string(msg.Content), msg.PubKey, + msg.DmToken, msg.CodesetVersion, msg.Timestamp, msg.Lease, + rounds.Round{ID: msg.Round}, msg.Type, msg.Status, msg.Hidden) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return zeroUUID, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + return uuidData, nil +} + +// updateFromUUIDCB is the callback for wasmModel.UpdateFromUUID. Always returns +// nil; meaning, no response is supplied (or expected). +func (m *manager) updateFromUUIDCB(data []byte) ([]byte, error) { + var msg wChannels.MessageUpdateInfo + err := json.Unmarshal(data, &msg) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + var messageID *message.ID + var timestamp *time.Time + var round *rounds.Round + var pinned, hidden *bool + var status *channels.SentStatus + if msg.MessageIDSet { + messageID = &msg.MessageID + } + if msg.TimestampSet { + timestamp = &msg.Timestamp + } + if msg.RoundIDSet { + round = &rounds.Round{ID: msg.RoundID} + } + if msg.PinnedSet { + pinned = &msg.Pinned + } + if msg.HiddenSet { + hidden = &msg.Hidden + } + if msg.StatusSet { + status = &msg.Status + } + + m.model.UpdateFromUUID( + msg.UUID, messageID, timestamp, round, pinned, hidden, status) + return nil, nil +} + +// updateFromMessageIDCB is the callback for wasmModel.UpdateFromMessageID. +// Always returns nil; meaning, no response is supplied (or expected). +func (m *manager) updateFromMessageIDCB(data []byte) ([]byte, error) { + var msg wChannels.MessageUpdateInfo + err := json.Unmarshal(data, &msg) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + var timestamp *time.Time + var round *rounds.Round + var pinned, hidden *bool + var status *channels.SentStatus + if msg.TimestampSet { + timestamp = &msg.Timestamp + } + if msg.RoundIDSet { + round = &rounds.Round{ID: msg.RoundID} + } + if msg.PinnedSet { + pinned = &msg.Pinned + } + if msg.HiddenSet { + hidden = &msg.Hidden + } + if msg.StatusSet { + status = &msg.Status + } + + uuid := m.model.UpdateFromMessageID( + msg.MessageID, timestamp, round, pinned, hidden, status) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return nil, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + + return uuidData, nil +} + +// getMessageCB is the callback for wasmModel.GetMessage. Returns JSON +// marshalled channels.GetMessageMessage. If an error occurs, then Error will +// be set with the error message. Otherwise, Message will be set. Only one field +// will be set. +func (m *manager) getMessageCB(data []byte) ([]byte, error) { + messageID, err := message.UnmarshalID(data) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", messageID, err) + } + + reply := wChannels.GetMessageMessage{} + + msg, err := m.model.GetMessage(messageID) + if err != nil { + reply.Error = err.Error() + } else { + reply.Message = msg + } + + messageData, err := json.Marshal(reply) + if err != nil { + return nil, errors.Errorf("failed to JSON marshal %T from main thread "+ + "for GetMessage reply: %+v", reply, err) + } + return messageData, nil +} + +// deleteMessageCB is the callback for wasmModel.DeleteMessage. Always returns +// nil; meaning, no response is supplied (or expected). +func (m *manager) deleteMessageCB(data []byte) ([]byte, error) { + messageID, err := message.UnmarshalID(data) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", messageID, err) + } + + err = m.model.DeleteMessage(messageID) + if err != nil { + return []byte(err.Error()), nil + } + + return nil, nil +} + +// muteUserCB is the callback for wasmModel.MuteUser. Always returns nil; +// meaning, no response is supplied (or expected). +func (m *manager) muteUserCB(data []byte) ([]byte, error) { + var msg wChannels.MuteUserMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + m.model.MuteUser(msg.ChannelID, msg.PubKey, msg.Unmute) + + return nil, nil +} diff --git a/indexedDb/impl/channels/channelsIndexedDbWorker.js b/indexedDb/impl/channels/channelsIndexedDbWorker.js new file mode 100644 index 0000000000000000000000000000000000000000..9e69bdd70eddebc9f82b23d04d823221ad3c1622 --- /dev/null +++ b/indexedDb/impl/channels/channelsIndexedDbWorker.js @@ -0,0 +1,17 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +importScripts('wasm_exec.js'); + +const go = new Go(); +const binPath = 'xxdk-channelsIndexedDkWorker.wasm' +WebAssembly.instantiateStreaming(fetch(binPath), go.importObject).then((result) => { + go.run(result.instance); + LogLevel(1); +}).catch((err) => { + console.error(err); +}); \ No newline at end of file diff --git a/indexedDb/channels/implementation.go b/indexedDb/impl/channels/implementation.go similarity index 95% rename from indexedDb/channels/implementation.go rename to indexedDb/impl/channels/implementation.go index ee099fbfc1d8b55c9b575ce56ad3e0a8973685dd..9098fd666e3c19d59a7df58546da1ea0165783bb 100644 --- a/indexedDb/channels/implementation.go +++ b/indexedDb/impl/channels/implementation.go @@ -7,7 +7,7 @@ //go:build js && wasm -package channels +package main import ( "crypto/ed25519" @@ -18,8 +18,6 @@ import ( "syscall/js" "time" - "gitlab.com/elixxir/xxdk-wasm/indexedDb" - "github.com/hack-pad/go-indexeddb/idb" "github.com/pkg/errors" jww "github.com/spf13/jwalterweatherman" @@ -29,6 +27,8 @@ import ( cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" cryptoChannel "gitlab.com/elixxir/crypto/channel" "gitlab.com/elixxir/crypto/message" + "gitlab.com/elixxir/xxdk-wasm/indexedDb/impl" + wChannels "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/channels" "gitlab.com/elixxir/xxdk-wasm/utils" "gitlab.com/xx_network/primitives/id" ) @@ -39,9 +39,9 @@ import ( type wasmModel struct { db *idb.Database cipher cryptoChannel.Cipher - receivedMessageCB MessageReceivedCallback - deletedMessageCB DeletedMessageCallback - mutedUserCB MutedUserCallback + receivedMessageCB wChannels.MessageReceivedCallback + deletedMessageCB wChannels.DeletedMessageCallback + mutedUserCB wChannels.MutedUserCallback updateMux sync.Mutex } @@ -70,7 +70,7 @@ func (w *wasmModel) JoinChannel(channel *cryptoBroadcast.Channel) { return } - _, err = indexedDb.Put(w.db, channelsStoreName, channelObj) + _, err = impl.Put(w.db, channelsStoreName, channelObj) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, "Unable to put Channel: %+v", err)) @@ -82,8 +82,7 @@ func (w *wasmModel) LeaveChannel(channelID *id.ID) { parentErr := errors.New("failed to LeaveChannel") // Delete the channel from storage - err := indexedDb.Delete(w.db, channelsStoreName, - js.ValueOf(channelID.String())) + err := impl.Delete(w.db, channelsStoreName, js.ValueOf(channelID.String())) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, "Unable to delete Channel: %+v", err)) @@ -129,7 +128,7 @@ func (w *wasmModel) deleteMsgByChannel(channelID *id.ID) error { if err != nil { return errors.WithMessagef(parentErr, "Unable to open Cursor: %+v", err) } - ctx, cancel := indexedDb.NewContext() + ctx, cancel := impl.NewContext() err = cursorRequest.Iter(ctx, func(cursor *idb.CursorWithValue) error { _, err := cursor.Delete() @@ -269,7 +268,7 @@ func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, key := js.ValueOf(uuid) // Use the key to get the existing Message - currentMsg, err := indexedDb.Get(w.db, messageStoreName, key) + currentMsg, err := impl.Get(w.db, messageStoreName, key) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, "Unable to get message: %+v", err)) @@ -305,7 +304,7 @@ func (w *wasmModel) UpdateFromMessageID(messageID message.ID, defer w.updateMux.Unlock() msgIDStr := base64.StdEncoding.EncodeToString(messageID.Marshal()) - currentMsgObj, err := indexedDb.GetIndex(w.db, messageStoreName, + currentMsgObj, err := impl.GetIndex(w.db, messageStoreName, messageStoreMessageIndex, js.ValueOf(msgIDStr)) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, @@ -420,7 +419,7 @@ func (w *wasmModel) receiveHelper( } // Store message to database - result, err := indexedDb.Put(w.db, messageStoreName, messageObj) + result, err := impl.Put(w.db, messageStoreName, messageObj) if err != nil && !strings.Contains(err.Error(), "at least one key does not satisfy the uniqueness requirements") { // Only return non-unique constraint errors so that the case @@ -492,8 +491,8 @@ func (w *wasmModel) GetMessage( func (w *wasmModel) DeleteMessage(messageID message.ID) error { msgId := js.ValueOf(base64.StdEncoding.EncodeToString(messageID.Bytes())) - err := indexedDb.DeleteIndex(w.db, messageStoreName, - messageStoreMessageIndex, pkeyName, msgId) + err := impl.DeleteIndex( + w.db, messageStoreName, messageStoreMessageIndex, pkeyName, msgId) if err != nil { return err } @@ -512,7 +511,7 @@ func (w *wasmModel) MuteUser( // msgIDLookup gets the UUID of the Message with the given messageID. func (w *wasmModel) msgIDLookup(messageID message.ID) (*Message, error) { msgIDStr := js.ValueOf(base64.StdEncoding.EncodeToString(messageID.Bytes())) - resultObj, err := indexedDb.GetIndex(w.db, messageStoreName, + resultObj, err := impl.GetIndex(w.db, messageStoreName, messageStoreMessageIndex, msgIDStr) if err != nil { return nil, err diff --git a/indexedDb/channels/implementation_test.go b/indexedDb/impl/channels/implementation_test.go similarity index 85% rename from indexedDb/channels/implementation_test.go rename to indexedDb/impl/channels/implementation_test.go index 19b0f313dd4bc11325150838007dd689b873a031..20bb4ed7aa9041a1339b54b34b234022b97e9933 100644 --- a/indexedDb/channels/implementation_test.go +++ b/indexedDb/impl/channels/implementation_test.go @@ -7,29 +7,30 @@ //go:build js && wasm -package channels +package main import ( "crypto/ed25519" "encoding/json" "fmt" - "github.com/hack-pad/go-indexeddb/idb" - "gitlab.com/elixxir/crypto/message" - "gitlab.com/elixxir/xxdk-wasm/indexedDb" - "gitlab.com/elixxir/xxdk-wasm/storage" - "gitlab.com/xx_network/crypto/csprng" - "gitlab.com/xx_network/primitives/netTime" "os" "strconv" "testing" "time" + "github.com/hack-pad/go-indexeddb/idb" jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/v4/channels" "gitlab.com/elixxir/client/v4/cmix/rounds" cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/crypto/message" + "gitlab.com/elixxir/xxdk-wasm/indexedDb/impl" + "gitlab.com/elixxir/xxdk-wasm/storage" + "gitlab.com/xx_network/crypto/csprng" "gitlab.com/xx_network/primitives/id" + "gitlab.com/xx_network/primitives/netTime" ) func TestMain(m *testing.M) { @@ -40,6 +41,10 @@ func TestMain(m *testing.M) { func dummyReceivedMessageCB(uint64, *id.ID, bool) {} func dummyDeletedMessageCB(message.ID) {} func dummyMutedUserCB(*id.ID, ed25519.PublicKey, bool) {} +func dummyStoreDatabaseName(string) error { return nil } +func dummyStoreEncryptionStatus(_ string, encryptionStatus bool) (bool, error) { + return encryptionStatus, nil +} // Happy path, insert message and look it up func TestWasmModel_msgIDLookup(t *testing.T) { @@ -60,9 +65,10 @@ func TestWasmModel_msgIDLookup(t *testing.T) { testMsgId := message.DeriveChannelMessageID(&id.ID{1}, 0, []byte(testString)) eventModel, err2 := newWASMModel(testString, c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB, + dummyStoreDatabaseName, dummyStoreEncryptionStatus) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } testMsg := buildMessage([]byte(testString), testMsgId.Bytes(), nil, @@ -70,12 +76,12 @@ func TestWasmModel_msgIDLookup(t *testing.T) { netTime.Now(), time.Second, 0, 0, false, false, channels.Sent) _, err = eventModel.receiveHelper(testMsg, false) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } msg, err2 := eventModel.msgIDLookup(testMsgId) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } if msg.ID == 0 { t.Fatalf("Expected to get a UUID!") @@ -89,10 +95,11 @@ func TestWasmModel_DeleteMessage(t *testing.T) { storage.GetLocalStorage().Clear() testString := "TestWasmModel_DeleteMessage" testMsgId := message.DeriveChannelMessageID(&id.ID{1}, 0, []byte(testString)) - eventModel, err := newWASMModel(testString, nil, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + eventModel, err := newWASMModel(testString, nil, dummyReceivedMessageCB, + dummyDeletedMessageCB, dummyMutedUserCB, dummyStoreDatabaseName, + dummyStoreEncryptionStatus) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } // Insert a message @@ -101,13 +108,13 @@ func TestWasmModel_DeleteMessage(t *testing.T) { time.Second, 0, 0, false, false, channels.Sent) _, err = eventModel.receiveHelper(testMsg, false) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } // Check the resulting status - results, err := indexedDb.Dump(eventModel.db, messageStoreName) + results, err := impl.Dump(eventModel.db, messageStoreName) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } if len(results) != 1 { t.Fatalf("Expected 1 message to exist") @@ -116,13 +123,13 @@ func TestWasmModel_DeleteMessage(t *testing.T) { // Delete the message err = eventModel.DeleteMessage(testMsgId) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } // Check the resulting status - results, err = indexedDb.Dump(eventModel.db, messageStoreName) + results, err = impl.Dump(eventModel.db, messageStoreName) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } if len(results) != 0 { t.Fatalf("Expected no messages to exist") @@ -147,24 +154,25 @@ func Test_wasmModel_UpdateSentStatus(t *testing.T) { testMsgId := message.DeriveChannelMessageID( &id.ID{1}, 0, []byte(testString)) eventModel, err2 := newWASMModel(testString, c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB, + dummyStoreDatabaseName, dummyStoreEncryptionStatus) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err) } // Store a test message testMsg := buildMessage([]byte(testString), testMsgId.Bytes(), nil, - testString, []byte(testString), []byte{8, 6, 7, 5}, 0, 0, netTime.Now(), - time.Second, 0, 0, false, false, channels.Sent) - uuid, err := eventModel.receiveHelper(testMsg, false) - if err != nil { - t.Fatalf("%+v", err) + testString, []byte(testString), []byte{8, 6, 7, 5}, 0, 0, + netTime.Now(), time.Second, 0, 0, false, false, channels.Sent) + uuid, err2 := eventModel.receiveHelper(testMsg, false) + if err2 != nil { + t.Fatal(err2) } // Ensure one message is stored - results, err := indexedDb.Dump(eventModel.db, messageStoreName) - if err != nil { - t.Fatalf("%+v", err) + results, err2 := impl.Dump(eventModel.db, messageStoreName) + if err2 != nil { + t.Fatal(err2) } if len(results) != 1 { t.Fatalf("Expected 1 message to exist") @@ -176,17 +184,17 @@ func Test_wasmModel_UpdateSentStatus(t *testing.T) { uuid, nil, nil, nil, nil, nil, &expectedStatus) // Check the resulting status - results, err2 = indexedDb.Dump(eventModel.db, messageStoreName) - if err2 != nil { - t.Fatalf("%+v", err2) + results, err = impl.Dump(eventModel.db, messageStoreName) + if err != nil { + t.Fatal(err) } if len(results) != 1 { t.Fatalf("Expected 1 message to exist") } resultMsg := &Message{} - err2 = json.Unmarshal([]byte(results[0]), resultMsg) - if err2 != nil { - t.Fatalf("%+v", err2) + err = json.Unmarshal([]byte(results[0]), resultMsg) + if err != nil { + t.Fatal(err) } if resultMsg.Status != uint8(expectedStatus) { t.Fatalf("Unexpected Status: %v", resultMsg.Status) @@ -214,10 +222,11 @@ func Test_wasmModel_JoinChannel_LeaveChannel(t *testing.T) { } t.Run("Test_wasmModel_JoinChannel_LeaveChannel"+cs, func(t *testing.T) { storage.GetLocalStorage().Clear() - eventModel, err2 := newWASMModel("test", c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + eventModel, err2 := newWASMModel("test", c, dummyReceivedMessageCB, + dummyDeletedMessageCB, dummyMutedUserCB, dummyStoreDatabaseName, + dummyStoreEncryptionStatus) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } testChannel := &cryptoBroadcast.Channel{ @@ -234,17 +243,17 @@ func Test_wasmModel_JoinChannel_LeaveChannel(t *testing.T) { } eventModel.JoinChannel(testChannel) eventModel.JoinChannel(testChannel2) - results, err2 := indexedDb.Dump(eventModel.db, channelsStoreName) + results, err2 := impl.Dump(eventModel.db, channelsStoreName) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } if len(results) != 2 { t.Fatalf("Expected 2 channels to exist") } eventModel.LeaveChannel(testChannel.ReceptionID) - results, err2 = indexedDb.Dump(eventModel.db, channelsStoreName) - if err2 != nil { - t.Fatalf("%+v", err2) + results, err = impl.Dump(eventModel.db, channelsStoreName) + if err != nil { + t.Fatal(err) } if len(results) != 1 { t.Fatalf("Expected 1 channels to exist") @@ -269,9 +278,10 @@ func Test_wasmModel_UUIDTest(t *testing.T) { storage.GetLocalStorage().Clear() testString := "testHello" + cs eventModel, err2 := newWASMModel(testString, c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB, + dummyStoreDatabaseName, dummyStoreEncryptionStatus) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } uuids := make([]uint64, 10) @@ -316,9 +326,10 @@ func Test_wasmModel_DuplicateReceives(t *testing.T) { storage.GetLocalStorage().Clear() testString := "testHello" eventModel, err2 := newWASMModel(testString, c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB, + dummyStoreDatabaseName, dummyStoreEncryptionStatus) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } uuids := make([]uint64, 10) @@ -366,9 +377,10 @@ func Test_wasmModel_deleteMsgByChannel(t *testing.T) { totalMessages := 10 expectedMessages := 5 eventModel, err2 := newWASMModel(testString, c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB, + dummyStoreDatabaseName, dummyStoreEncryptionStatus) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } // Create a test channel id @@ -394,9 +406,9 @@ func Test_wasmModel_deleteMsgByChannel(t *testing.T) { } // Check pre-results - result, err2 := indexedDb.Dump(eventModel.db, messageStoreName) + result, err2 := impl.Dump(eventModel.db, messageStoreName) if err2 != nil { - t.Fatalf("%+v", err2) + t.Fatal(err2) } if len(result) != totalMessages { t.Errorf("Expected %d messages, got %d", totalMessages, len(result)) @@ -409,9 +421,9 @@ func Test_wasmModel_deleteMsgByChannel(t *testing.T) { } // Check final results - result, err = indexedDb.Dump(eventModel.db, messageStoreName) + result, err = impl.Dump(eventModel.db, messageStoreName) if err != nil { - t.Fatalf("%+v", err) + t.Fatal(err) } if len(result) != expectedMessages { t.Errorf("Expected %d messages, got %d", expectedMessages, len(result)) @@ -437,7 +449,8 @@ func TestWasmModel_receiveHelper_UniqueIndex(t *testing.T) { storage.GetLocalStorage().Clear() testString := fmt.Sprintf("test_receiveHelper_UniqueIndex_%d", i) eventModel, err2 := newWASMModel(testString, c, - dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB, + dummyStoreDatabaseName, dummyStoreEncryptionStatus) if err2 != nil { t.Fatal(err2) } @@ -467,15 +480,15 @@ func TestWasmModel_receiveHelper_UniqueIndex(t *testing.T) { testMsg := buildMessage([]byte(testString), testMsgId.Bytes(), nil, testString, []byte(testString), []byte{8, 6, 7, 5}, 0, 0, netTime.Now(), time.Second, 0, 0, false, false, channels.Sent) - uuid, err := eventModel.receiveHelper(testMsg, false) - if err != nil { - t.Fatal(err) + uuid, err2 := eventModel.receiveHelper(testMsg, false) + if err2 != nil { + t.Fatal(err2) } // The duplicate entry should return the same UUID - duplicateUuid, err := eventModel.receiveHelper(testMsg, false) - if err != nil { - t.Fatal(err) + duplicateUuid, err2 := eventModel.receiveHelper(testMsg, false) + if err2 != nil { + t.Fatal(err2) } if uuid != duplicateUuid { t.Fatalf("Expected UUID %d to match %d", uuid, duplicateUuid) @@ -487,9 +500,9 @@ func TestWasmModel_receiveHelper_UniqueIndex(t *testing.T) { testMsg = buildMessage([]byte(testString), testMsgId2.Bytes(), nil, testString, []byte(testString), []byte{8, 6, 7, 5}, 0, 0, netTime.Now(), time.Second, 0, 0, false, false, channels.Sent) - uuid2, err := eventModel.receiveHelper(testMsg, false) - if err != nil { - t.Fatal(err) + uuid2, err2 := eventModel.receiveHelper(testMsg, false) + if err2 != nil { + t.Fatal(err2) } if uuid2 == uuid { t.Fatalf("Expected UUID %d to NOT match %d", uuid, duplicateUuid) @@ -499,9 +512,9 @@ func TestWasmModel_receiveHelper_UniqueIndex(t *testing.T) { // message ID as the first testMsg.ID = uuid testMsg.MessageID = testMsgId.Bytes() - duplicateUuid2, err := eventModel.receiveHelper(testMsg, true) - if err != nil { - t.Fatal(err) + duplicateUuid2, err2 := eventModel.receiveHelper(testMsg, true) + if err2 != nil { + t.Fatal(err2) } if duplicateUuid2 != duplicateUuid { t.Fatalf("Expected UUID %d to match %d", uuid, duplicateUuid) diff --git a/indexedDb/channels/init.go b/indexedDb/impl/channels/init.go similarity index 56% rename from indexedDb/channels/init.go rename to indexedDb/impl/channels/init.go index 54867ea9d4668195b6bb97e7341f0366b4efcecd..45107fa2c437b82bce04a50fe71eebeaac0625ee 100644 --- a/indexedDb/channels/init.go +++ b/indexedDb/impl/channels/init.go @@ -7,20 +7,19 @@ //go:build js && wasm -package channels +package main import ( - "crypto/ed25519" + "syscall/js" + "github.com/hack-pad/go-indexeddb/idb" "github.com/pkg/errors" jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/v4/channels" cryptoChannel "gitlab.com/elixxir/crypto/channel" - "gitlab.com/elixxir/crypto/message" - "gitlab.com/elixxir/xxdk-wasm/indexedDb" - "gitlab.com/elixxir/xxdk-wasm/storage" - "gitlab.com/xx_network/primitives/id" - "syscall/js" + "gitlab.com/elixxir/xxdk-wasm/indexedDb/impl" + wChannels "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/channels" ) const ( @@ -33,57 +32,42 @@ const ( currentVersion uint = 1 ) -// MessageReceivedCallback is called any time a message is received or updated. -// -// update is true if the row is old and was edited. -type MessageReceivedCallback func(uuid uint64, channelID *id.ID, update bool) - -// DeletedMessageCallback is called any time a message is deleted. -type DeletedMessageCallback func(messageID message.ID) - -// MutedUserCallback is called any time a user is muted or unmuted. unmute is -// true if the user has been unmuted and false if they have been muted. -type MutedUserCallback func( - channelID *id.ID, pubKey ed25519.PublicKey, unmute bool) - -// NewWASMEventModelBuilder returns an EventModelBuilder which allows -// the channel manager to define the path but the callback is the same -// across the board. -func NewWASMEventModelBuilder(encryption cryptoChannel.Cipher, - messageReceivedCB MessageReceivedCallback, - deletedMessageCB DeletedMessageCallback, - mutedUserCB MutedUserCallback) channels.EventModelBuilder { - fn := func(path string) (channels.EventModel, error) { - return NewWASMEventModel( - path, encryption, messageReceivedCB, deletedMessageCB, mutedUserCB) - } - return fn -} +// storeDatabaseNameFn matches storage.StoreIndexedDb so that the data can be +// sent between the worker and main thread. +type storeDatabaseNameFn func(databaseName string) error + +// storeEncryptionStatusFn matches storage.StoreIndexedDbEncryptionStatus so +// that the data can be sent between the worker and main thread. +type storeEncryptionStatusFn func( + databaseName string, encryptionStatus bool) (bool, error) // NewWASMEventModel returns a [channels.EventModel] backed by a wasmModel. // The name should be a base64 encoding of the users public key. func NewWASMEventModel(path string, encryption cryptoChannel.Cipher, - messageReceivedCB MessageReceivedCallback, - deletedMessageCB DeletedMessageCallback, - mutedUserCB MutedUserCallback) (channels.EventModel, error) { + messageReceivedCB wChannels.MessageReceivedCallback, + deletedMessageCB wChannels.DeletedMessageCallback, + mutedUserCB wChannels.MutedUserCallback, + storeDatabaseName storeDatabaseNameFn, + storeEncryptionStatus storeEncryptionStatusFn) (channels.EventModel, error) { databaseName := path + databaseSuffix - return newWASMModel(databaseName, - encryption, messageReceivedCB, deletedMessageCB, mutedUserCB) + return newWASMModel(databaseName, encryption, messageReceivedCB, + deletedMessageCB, mutedUserCB, storeDatabaseName, storeEncryptionStatus) } // newWASMModel creates the given [idb.Database] and returns a wasmModel. func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, - messageReceivedCB MessageReceivedCallback, - deletedMessageCB DeletedMessageCallback, - mutedUserCB MutedUserCallback) (*wasmModel, error) { + messageReceivedCB wChannels.MessageReceivedCallback, + deletedMessageCB wChannels.DeletedMessageCallback, + mutedUserCB wChannels.MutedUserCallback, + storeDatabaseName storeDatabaseNameFn, + storeEncryptionStatus storeEncryptionStatusFn) (*wasmModel, error) { // Attempt to open database object - ctx, cancel := indexedDb.NewContext() + ctx, cancel := impl.NewContext() defer cancel() openRequest, err := idb.Global().Open(ctx, databaseName, currentVersion, func(db *idb.Database, oldVersion, newVersion uint) error { if oldVersion == newVersion { - jww.INFO.Printf("IndexDb version is current: v%d", - newVersion) + jww.INFO.Printf("IndexDb version is current: v%d", newVersion) return nil } @@ -111,10 +95,17 @@ func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, return nil, err } + // Get the database name and save it to storage + if dbName, err2 := db.Name(); err2 != nil { + return nil, err2 + } else if err = storeDatabaseName(dbName); err != nil { + return nil, err + } + // Save the encryption status to storage encryptionStatus := encryption != nil - loadedEncryptionStatus, err := storage.StoreIndexedDbEncryptionStatus( - databaseName, encryptionStatus) + loadedEncryptionStatus, err := + storeEncryptionStatus(databaseName, encryptionStatus) if err != nil { return nil, err } @@ -127,19 +118,6 @@ func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, jww.WARN.Printf("IndexedDb encryption disabled!") } - // Attempt to ensure the database has been properly initialized - openRequest, err = idb.Global().Open(ctx, databaseName, currentVersion, - func(db *idb.Database, oldVersion, newVersion uint) error { - return nil - }) - if err != nil { - return nil, err - } - // Wait for database open to finish - db, err = openRequest.Await(ctx) - if err != nil { - return nil, err - } wrapper := &wasmModel{ db: db, cipher: encryption, @@ -206,37 +184,5 @@ func v1Upgrade(db *idb.Database) error { return err } - // Get the database name and save it to storage - if databaseName, err := db.Name(); err != nil { - return err - } else if err = storage.StoreIndexedDb(databaseName); err != nil { - return err - } - - return nil -} - -// hackTestDb is a horrible function that exists as the result of an extremely -// long discussion about why initializing the IndexedDb sometimes silently -// fails. It ultimately tries to prevent an unrecoverable situation by actually -// inserting some nonsense data and then checking to see if it persists. -// If this function still exists in 2023, god help us all. Amen. -func (w *wasmModel) hackTestDb() error { - testMessage := &Message{ - ID: 0, - Nickname: "test", - MessageID: id.DummyUser.Marshal(), - } - msgId, helper := w.receiveHelper(testMessage, false) - if helper != nil { - return helper - } - result, err := indexedDb.Get(w.db, messageStoreName, js.ValueOf(msgId)) - if err != nil { - return err - } - if result.IsUndefined() { - return errors.Errorf("Failed to test db, record not present") - } return nil } diff --git a/indexedDb/impl/channels/main.go b/indexedDb/impl/channels/main.go new file mode 100644 index 0000000000000000000000000000000000000000..5290d0c89b6eedf92a1571aea04ff5b7fbfdc668 --- /dev/null +++ b/indexedDb/impl/channels/main.go @@ -0,0 +1,42 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package main + +import ( + "fmt" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/logging" + "gitlab.com/elixxir/xxdk-wasm/wasm" + "gitlab.com/elixxir/xxdk-wasm/worker" + "syscall/js" +) + +// SEMVER is the current semantic version of the xxDK channels web worker. +const SEMVER = "0.1.0" + +func init() { + // Set up Javascript console listener set at level INFO + ll := logging.NewJsConsoleLogListener(jww.LevelInfo) + logging.AddLogListener(ll.Listen) + jww.SetStdoutThreshold(jww.LevelFatal + 1) + jww.INFO.Printf("xxDK channels web worker version: v%s", SEMVER) +} + +func main() { + jww.INFO.Print("[WW] Starting xxDK WebAssembly Channels Database Worker.") + + js.Global().Set("LogLevel", js.FuncOf(wasm.LogLevel)) + + m := &manager{mh: worker.NewThreadManager("ChannelsIndexedDbWorker", true)} + m.registerCallbacks() + m.mh.SignalReady() + <-make(chan bool) + fmt.Println("[WW] Closing xxDK WebAssembly Channels Database Worker.") +} diff --git a/indexedDb/channels/model.go b/indexedDb/impl/channels/model.go similarity index 99% rename from indexedDb/channels/model.go rename to indexedDb/impl/channels/model.go index 078d6bd67f7c43d9e50373b7801f69937cfce2ac..d90d5d0ff932c922634cdc49276074173631aea1 100644 --- a/indexedDb/channels/model.go +++ b/indexedDb/impl/channels/model.go @@ -7,7 +7,7 @@ //go:build js && wasm -package channels +package main import ( "time" diff --git a/indexedDb/impl/dm/callbacks.go b/indexedDb/impl/dm/callbacks.go new file mode 100644 index 0000000000000000000000000000000000000000..e7ec65bc4b921a220b489d13aa8d4f893470f753 --- /dev/null +++ b/indexedDb/impl/dm/callbacks.go @@ -0,0 +1,279 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package main + +import ( + "crypto/ed25519" + "encoding/json" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/v4/dm" + cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/crypto/fastRNG" + wDm "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/dm" + "gitlab.com/elixxir/xxdk-wasm/worker" + "gitlab.com/xx_network/crypto/csprng" + "time" +) + +var zeroUUID = []byte{0, 0, 0, 0, 0, 0, 0, 0} + +// manager handles the event model and the message callbacks, which is used to +// send information between the event model and the main thread. +type manager struct { + mh *worker.ThreadManager + model dm.EventModel +} + +// registerCallbacks registers all the reception callbacks to manage messages +// from the main thread for the channels.EventModel. +func (m *manager) registerCallbacks() { + m.mh.RegisterCallback(wDm.NewWASMEventModelTag, m.newWASMEventModelCB) + m.mh.RegisterCallback(wDm.ReceiveTag, m.receiveCB) + m.mh.RegisterCallback(wDm.ReceiveTextTag, m.receiveTextCB) + m.mh.RegisterCallback(wDm.ReceiveReplyTag, m.receiveReplyCB) + m.mh.RegisterCallback(wDm.ReceiveReactionTag, m.receiveReactionCB) + m.mh.RegisterCallback(wDm.UpdateSentStatusTag, m.updateSentStatusCB) +} + +// newWASMEventModelCB is the callback for NewWASMEventModel. Returns an empty +// slice on success or an error message on failure. +func (m *manager) newWASMEventModelCB(data []byte) ([]byte, error) { + var msg wDm.NewWASMEventModelMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return []byte{}, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + // Create new encryption cipher + rng := fastRNG.NewStreamGenerator(12, 1024, csprng.NewSystemRNG) + encryption, err := cryptoChannel.NewCipherFromJSON( + []byte(msg.EncryptionJSON), rng.GetStream()) + if err != nil { + return []byte{}, errors.Errorf("failed to JSON unmarshal channel "+ + "cipher from main thread: %+v", err) + } + + m.model, err = NewWASMEventModel(msg.Path, encryption, + m.messageReceivedCallback, m.storeDatabaseName, m.storeEncryptionStatus) + if err != nil { + return []byte(err.Error()), nil + } + return []byte{}, nil +} + +// messageReceivedCallback sends calls to the MessageReceivedCallback in the +// main thread. +// +// messageReceivedCallback adhere to the MessageReceivedCallback type. +func (m *manager) messageReceivedCallback( + uuid uint64, pubKey ed25519.PublicKey, update bool) { + // Package parameters for sending + msg := &wDm.MessageReceivedCallbackMessage{ + UUID: uuid, + PubKey: pubKey, + Update: update, + } + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "Could not JSON marshal MessageReceivedCallbackMessage: %+v", err) + return + } + + // Send it to the main thread + m.mh.SendMessage(wDm.MessageReceivedCallbackTag, data) +} + +// storeDatabaseName sends the database name to the main thread and waits for +// the response. This function mocks the behavior of storage.StoreIndexedDb. +// +// storeDatabaseName adheres to the storeDatabaseNameFn type. +func (m *manager) storeDatabaseName(databaseName string) error { + // Register response callback with channel that will wait for the response + responseChan := make(chan []byte) + m.mh.RegisterCallback(wDm.StoreDatabaseNameTag, + func(data []byte) ([]byte, error) { + responseChan <- data + return nil, nil + }) + + // Send encryption status to main thread + m.mh.SendMessage(wDm.StoreDatabaseNameTag, []byte(databaseName)) + + // Wait for response + select { + case response := <-responseChan: + if len(response) > 0 { + return errors.New(string(response)) + } + case <-time.After(worker.ResponseTimeout): + return errors.Errorf("[WW] Timed out after %s waiting for response "+ + "about storing the database name in local storage in the main "+ + "thread", worker.ResponseTimeout) + } + + return nil +} + +// storeEncryptionStatus sends the database name and encryption status to the +// main thread and waits for the response. If the value has not been previously +// saved, it returns the saves encryption status. This function mocks the +// behavior of storage.StoreIndexedDbEncryptionStatus. +// +// storeEncryptionStatus adheres to the storeEncryptionStatusFn type. +func (m *manager) storeEncryptionStatus( + databaseName string, encryption bool) (bool, error) { + // Package parameters for sending + msg := &wDm.EncryptionStatusMessage{ + DatabaseName: databaseName, + EncryptionStatus: encryption, + } + data, err := json.Marshal(msg) + if err != nil { + return false, err + } + + // Register response callback with channel that will wait for the response + responseChan := make(chan []byte) + m.mh.RegisterCallback(wDm.EncryptionStatusTag, + func(data []byte) ([]byte, error) { + responseChan <- data + return nil, nil + }) + + // Send encryption status to main thread + m.mh.SendMessage(wDm.EncryptionStatusTag, data) + + // Wait for response + var response wDm.EncryptionStatusReply + select { + case responseData := <-responseChan: + if err = json.Unmarshal(responseData, &response); err != nil { + return false, err + } + case <-time.After(worker.ResponseTimeout): + return false, errors.Errorf("timed out after %s waiting for "+ + "response about the database encryption status from local "+ + "storage in the main thread", worker.ResponseTimeout) + } + + // If the response contain an error, return it + if response.Error != "" { + return false, errors.New(response.Error) + } + + // Return the encryption status + return response.EncryptionStatus, nil +} + +// receiveCB is the callback for wasmModel.Receive. Returns a UUID of 0 on error +// or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveCB(data []byte) ([]byte, error) { + var msg wDm.TransferMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return zeroUUID, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.Receive( + msg.MessageID, msg.Nickname, msg.Text, msg.PubKey, msg.DmToken, + msg.Codeset, msg.Timestamp, msg.Round, msg.MType, msg.Status) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return zeroUUID, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + return uuidData, nil +} + +// receiveTextCB is the callback for wasmModel.ReceiveText. Returns a UUID of 0 +// on error or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveTextCB(data []byte) ([]byte, error) { + var msg wDm.TransferMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return []byte{}, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.ReceiveText( + msg.MessageID, msg.Nickname, string(msg.Text), msg.PubKey, msg.DmToken, + msg.Codeset, msg.Timestamp, msg.Round, msg.Status) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return []byte{}, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + + return uuidData, nil +} + +// receiveReplyCB is the callback for wasmModel.ReceiveReply. Returns a UUID of +// 0 on error or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveReplyCB(data []byte) ([]byte, error) { + var msg wDm.TransferMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return zeroUUID, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.ReceiveReply(msg.MessageID, msg.ReactionTo, msg.Nickname, + string(msg.Text), msg.PubKey, msg.DmToken, msg.Codeset, msg.Timestamp, + msg.Round, msg.Status) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return zeroUUID, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + + return uuidData, nil +} + +// receiveReactionCB is the callback for wasmModel.ReceiveReaction. Returns a +// UUID of 0 on error or the JSON marshalled UUID (uint64) on success. +func (m *manager) receiveReactionCB(data []byte) ([]byte, error) { + var msg wDm.TransferMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return zeroUUID, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + uuid := m.model.ReceiveReaction(msg.MessageID, msg.ReactionTo, msg.Nickname, + string(msg.Text), msg.PubKey, msg.DmToken, msg.Codeset, msg.Timestamp, + msg.Round, msg.Status) + + uuidData, err := json.Marshal(uuid) + if err != nil { + return zeroUUID, errors.Errorf("failed to JSON marshal UUID : %+v", err) + } + + return uuidData, nil +} + +// updateSentStatusCB is the callback for wasmModel.UpdateSentStatus. Always +// returns nil; meaning, no response is supplied (or expected). +func (m *manager) updateSentStatusCB(data []byte) ([]byte, error) { + var msg wDm.TransferMessage + err := json.Unmarshal(data, &msg) + if err != nil { + return nil, errors.Errorf( + "failed to JSON unmarshal %T from main thread: %+v", msg, err) + } + + m.model.UpdateSentStatus( + msg.UUID, msg.MessageID, msg.Timestamp, msg.Round, msg.Status) + + return nil, nil +} diff --git a/indexedDb/impl/dm/dmIndexedDbWorker.js b/indexedDb/impl/dm/dmIndexedDbWorker.js new file mode 100644 index 0000000000000000000000000000000000000000..e199a7bb812b9ff119b7f130f41d3bb555247302 --- /dev/null +++ b/indexedDb/impl/dm/dmIndexedDbWorker.js @@ -0,0 +1,17 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +importScripts('wasm_exec.js'); + +const go = new Go(); +const binPath = 'xxdk-dmIndexedDkWorker.wasm' +WebAssembly.instantiateStreaming(fetch(binPath), go.importObject).then((result) => { + go.run(result.instance); + LogLevel(1); +}).catch((err) => { + console.error(err); +}); \ No newline at end of file diff --git a/indexedDb/dm/implementation.go b/indexedDb/impl/dm/implementation.go similarity index 85% rename from indexedDb/dm/implementation.go rename to indexedDb/impl/dm/implementation.go index b5c023d8f2ad920783533527608ce36d9d8c0ea9..8280d7b5138c00eab29b150ecc1fd749b6d60b13 100644 --- a/indexedDb/dm/implementation.go +++ b/indexedDb/impl/dm/implementation.go @@ -7,7 +7,7 @@ //go:build js && wasm -package channelEventModel +package main import ( "crypto/ed25519" @@ -21,18 +21,17 @@ import ( jww "github.com/spf13/jwalterweatherman" "gitlab.com/elixxir/client/v4/cmix/rounds" "gitlab.com/elixxir/client/v4/dm" - "gitlab.com/elixxir/xxdk-wasm/indexedDb" "gitlab.com/elixxir/xxdk-wasm/utils" "gitlab.com/xx_network/primitives/id" "github.com/hack-pad/go-indexeddb/idb" cryptoChannel "gitlab.com/elixxir/crypto/channel" "gitlab.com/elixxir/crypto/message" + "gitlab.com/elixxir/xxdk-wasm/indexedDb/impl" ) -// wasmModel implements [dm.Receiver] interface, which uses the channels -// system passed an object that adheres to in order to get events on the -// channel. +// wasmModel implements dm.EventModel interface, which uses the channels system +// passed an object that adheres to in order to get events on the channel. type wasmModel struct { db *idb.Database cipher cryptoChannel.Cipher @@ -66,7 +65,7 @@ func (w *wasmModel) joinConversation(nickname string, "Unable to marshal Conversation: %+v", err) } - _, err = indexedDb.Put(w.db, conversationStoreName, convoObj) + _, err = impl.Put(w.db, conversationStoreName, convoObj) if err != nil { return errors.WithMessagef(parentErr, "Unable to put Conversation: %+v", err) @@ -74,15 +73,15 @@ func (w *wasmModel) joinConversation(nickname string, return nil } -// buildMessage is a private helper that converts typical [dm.Receiver] -// inputs into a basic Message structure for insertion into storage. +// buildMessage is a private helper that converts typical dm.EventModel inputs +// into a basic Message structure for insertion into storage. // // NOTE: ID is not set inside this function because we want to use the // autoincrement key by default. If you are trying to overwrite an existing // message, then you need to set it manually yourself. -func buildMessage(messageID, parentID []byte, text []byte, - pubKey ed25519.PublicKey, timestamp time.Time, round id.Round, - mType dm.MessageType, status dm.Status) *Message { +func buildMessage(messageID, parentID, text []byte, pubKey ed25519.PublicKey, + timestamp time.Time, round id.Round, mType dm.MessageType, + status dm.Status) *Message { return &Message{ MessageID: messageID, ConversationPubKey: pubKey, @@ -96,16 +95,14 @@ func buildMessage(messageID, parentID []byte, text []byte, } func (w *wasmModel) Receive(messageID message.ID, nickname string, text []byte, - pubKey ed25519.PublicKey, dmToken uint32, codeset uint8, - timestamp time.Time, round rounds.Round, mType dm.MessageType, - status dm.Status) uint64 { + pubKey ed25519.PublicKey, dmToken uint32, codeset uint8, timestamp time.Time, + round rounds.Round, mType dm.MessageType, status dm.Status) uint64 { parentErr := errors.New("failed to Receive") // If there is no extant Conversation, create one. - _, err := indexedDb.Get( - w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) + _, err := impl.Get(w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) if err != nil { - if strings.Contains(err.Error(), indexedDb.ErrDoesNotExist) { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { err = w.joinConversation(nickname, pubKey, dmToken, codeset) if err != nil { jww.ERROR.Printf("%+v", err) @@ -128,8 +125,8 @@ func (w *wasmModel) Receive(messageID message.ID, nickname string, text []byte, } } - msgToInsert := buildMessage(messageID.Bytes(), nil, text, - pubKey, timestamp, round.ID, mType, status) + msgToInsert := buildMessage(messageID.Bytes(), nil, text, pubKey, timestamp, + round.ID, mType, status) uuid, err := w.receiveHelper(msgToInsert, false) if err != nil { jww.ERROR.Printf("Failed to receive Message: %+v", err) @@ -145,10 +142,9 @@ func (w *wasmModel) ReceiveText(messageID message.ID, nickname, text string, parentErr := errors.New("failed to ReceiveText") // If there is no extant Conversation, create one. - _, err := indexedDb.Get( - w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) + _, err := impl.Get(w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) if err != nil { - if strings.Contains(err.Error(), indexedDb.ErrDoesNotExist) { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { err = w.joinConversation(nickname, pubKey, dmToken, codeset) if err != nil { jww.ERROR.Printf("%+v", err) @@ -190,10 +186,9 @@ func (w *wasmModel) ReceiveReply(messageID, reactionTo message.ID, nickname, parentErr := errors.New("failed to ReceiveReply") // If there is no extant Conversation, create one. - _, err := indexedDb.Get( - w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) + _, err := impl.Get(w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) if err != nil { - if strings.Contains(err.Error(), indexedDb.ErrDoesNotExist) { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { err = w.joinConversation(nickname, pubKey, dmToken, codeset) if err != nil { jww.ERROR.Printf("%+v", err) @@ -235,10 +230,9 @@ func (w *wasmModel) ReceiveReaction(messageID, _ message.ID, nickname, parentErr := errors.New("failed to ReceiveText") // If there is no extant Conversation, create one. - _, err := indexedDb.Get( - w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) + _, err := impl.Get(w.db, conversationStoreName, utils.CopyBytesToJS(pubKey)) if err != nil { - if strings.Contains(err.Error(), indexedDb.ErrDoesNotExist) { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { err = w.joinConversation(nickname, pubKey, dmToken, codeset) if err != nil { jww.ERROR.Printf("%+v", err) @@ -288,7 +282,7 @@ func (w *wasmModel) UpdateSentStatus(uuid uint64, messageID message.ID, key := js.ValueOf(uuid) // Use the key to get the existing Message - currentMsg, err := indexedDb.Get(w.db, messageStoreName, key) + currentMsg, err := impl.Get(w.db, messageStoreName, key) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, "Unable to get message: %+v", err)) @@ -345,7 +339,7 @@ func (w *wasmModel) receiveHelper( } // Store message to database - result, err := indexedDb.Put(w.db, messageStoreName, messageObj) + result, err := impl.Put(w.db, messageStoreName, messageObj) if err != nil { return 0, errors.Errorf("Unable to put Message: %+v", err) } @@ -369,7 +363,7 @@ func (w *wasmModel) receiveHelper( // msgIDLookup gets the UUID of the Message with the given messageID. func (w *wasmModel) msgIDLookup(messageID message.ID) (uint64, error) { - resultObj, err := indexedDb.GetIndex(w.db, messageStoreName, + resultObj, err := impl.GetIndex(w.db, messageStoreName, messageStoreMessageIndex, utils.CopyBytesToJS(messageID.Marshal())) if err != nil { return 0, err diff --git a/indexedDb/dm/init.go b/indexedDb/impl/dm/init.go similarity index 78% rename from indexedDb/dm/init.go rename to indexedDb/impl/dm/init.go index 29b80911804004539beb210dc85533afc7c98f31..ec952044166a19f31fd13cec48f270a801bd8013 100644 --- a/indexedDb/dm/init.go +++ b/indexedDb/impl/dm/init.go @@ -7,10 +7,11 @@ //go:build js && wasm -package channelEventModel +package main import ( "crypto/ed25519" + "gitlab.com/elixxir/xxdk-wasm/indexedDb/impl" "syscall/js" "github.com/hack-pad/go-indexeddb/idb" @@ -18,8 +19,6 @@ import ( jww "github.com/spf13/jwalterweatherman" "gitlab.com/elixxir/client/v4/dm" cryptoChannel "gitlab.com/elixxir/crypto/channel" - "gitlab.com/elixxir/xxdk-wasm/indexedDb" - "gitlab.com/elixxir/xxdk-wasm/storage" ) const ( @@ -38,25 +37,36 @@ const ( type MessageReceivedCallback func( uuid uint64, pubKey ed25519.PublicKey, update bool) +// storeDatabaseNameFn matches storage.StoreIndexedDb so that the data can be +// sent between the worker and main thread. +type storeDatabaseNameFn func(databaseName string) error + +// storeEncryptionStatusFn matches storage.StoreIndexedDbEncryptionStatus so +// that the data can be sent between the worker and main thread. +type storeEncryptionStatusFn func( + databaseName string, encryptionStatus bool) (bool, error) + // NewWASMEventModel returns a [channels.EventModel] backed by a wasmModel. // The name should be a base64 encoding of the users public key. func NewWASMEventModel(path string, encryption cryptoChannel.Cipher, - cb MessageReceivedCallback) (dm.EventModel, error) { + cb MessageReceivedCallback, storeDatabaseName storeDatabaseNameFn, + storeEncryptionStatus storeEncryptionStatusFn) (dm.EventModel, error) { databaseName := path + databaseSuffix - return newWASMModel(databaseName, encryption, cb) + return newWASMModel( + databaseName, encryption, cb, storeDatabaseName, storeEncryptionStatus) } // newWASMModel creates the given [idb.Database] and returns a wasmModel. func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, - cb MessageReceivedCallback) (*wasmModel, error) { + cb MessageReceivedCallback, storeDatabaseName storeDatabaseNameFn, + storeEncryptionStatus storeEncryptionStatusFn) (*wasmModel, error) { // Attempt to open database object - ctx, cancel := indexedDb.NewContext() + ctx, cancel := impl.NewContext() defer cancel() openRequest, err := idb.Global().Open(ctx, databaseName, currentVersion, func(db *idb.Database, oldVersion, newVersion uint) error { if oldVersion == newVersion { - jww.INFO.Printf("IndexDb version is current: v%d", - newVersion) + jww.INFO.Printf("IndexDb version is current: v%d", newVersion) return nil } @@ -84,10 +94,17 @@ func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, return nil, err } + // Get the database name and save it to storage + if dbName, err2 := db.Name(); err2 != nil { + return nil, err2 + } else if err = storeDatabaseName(dbName); err != nil { + return nil, err + } + // Save the encryption status to storage encryptionStatus := encryption != nil - loadedEncryptionStatus, err := storage.StoreIndexedDbEncryptionStatus( - databaseName, encryptionStatus) + loadedEncryptionStatus, err := + storeEncryptionStatus(databaseName, encryptionStatus) if err != nil { return nil, err } @@ -100,19 +117,6 @@ func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, jww.WARN.Printf("IndexedDb encryption disabled!") } - // Attempt to ensure the database has been properly initialized - openRequest, err = idb.Global().Open(ctx, databaseName, currentVersion, - func(db *idb.Database, oldVersion, newVersion uint) error { - return nil - }) - if err != nil { - return nil, err - } - // Wait for database open to finish - db, err = openRequest.Await(ctx) - if err != nil { - return nil, err - } wrapper := &wasmModel{db: db, receivedMessageCB: cb, cipher: encryption} return wrapper, nil @@ -172,12 +176,5 @@ func v1Upgrade(db *idb.Database) error { return err } - // Get the database name and save it to storage - if databaseName, err := db.Name(); err != nil { - return err - } else if err = storage.StoreIndexedDb(databaseName); err != nil { - return err - } - return nil } diff --git a/indexedDb/impl/dm/main.go b/indexedDb/impl/dm/main.go new file mode 100644 index 0000000000000000000000000000000000000000..20b20a0856c78ac753798c9fd4a692e4a88e4852 --- /dev/null +++ b/indexedDb/impl/dm/main.go @@ -0,0 +1,42 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package main + +import ( + "fmt" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/logging" + "gitlab.com/elixxir/xxdk-wasm/wasm" + "gitlab.com/elixxir/xxdk-wasm/worker" + "syscall/js" +) + +// SEMVER is the current semantic version of the xxDK DM web worker. +const SEMVER = "0.1.0" + +func init() { + // Set up Javascript console listener set at level INFO + ll := logging.NewJsConsoleLogListener(jww.LevelInfo) + logging.AddLogListener(ll.Listen) + jww.SetStdoutThreshold(jww.LevelFatal + 1) + jww.INFO.Printf("xxDK DM web worker version: v%s", SEMVER) +} + +func main() { + jww.INFO.Print("[WW] Starting xxDK WebAssembly DM Database Worker.") + + js.Global().Set("LogLevel", js.FuncOf(wasm.LogLevel)) + + m := &manager{mh: worker.NewThreadManager("DmIndexedDbWorker", true)} + m.registerCallbacks() + m.mh.SignalReady() + <-make(chan bool) + fmt.Println("[WW] Closing xxDK WebAssembly Channels Database Worker.") +} diff --git a/indexedDb/dm/model.go b/indexedDb/impl/dm/model.go similarity index 98% rename from indexedDb/dm/model.go rename to indexedDb/impl/dm/model.go index bb6f34588aa2976c1689b629c6df31219de6b585..9b0b99e85bd5c97c389a1ac015819026b1d46b94 100644 --- a/indexedDb/dm/model.go +++ b/indexedDb/impl/dm/model.go @@ -7,7 +7,7 @@ //go:build js && wasm -package channelEventModel +package main import ( "time" diff --git a/indexedDb/utils.go b/indexedDb/impl/utils.go similarity index 99% rename from indexedDb/utils.go rename to indexedDb/impl/utils.go index a5bef78685ef34452aca396e1aea3f5206cf4ab6..b68746ebd6eb101d8f077b6179f1f6d41bfb04a6 100644 --- a/indexedDb/utils.go +++ b/indexedDb/impl/utils.go @@ -10,7 +10,7 @@ // This file contains several generic IndexedDB helper functions that // may be useful for any IndexedDB implementations. -package indexedDb +package impl import ( "context" @@ -26,6 +26,7 @@ const ( // dbTimeout is the global timeout for operations with the storage // [context.Context]. dbTimeout = time.Second + // ErrDoesNotExist is an error string for got undefined on Get operations. ErrDoesNotExist = "result is undefined" ) diff --git a/indexedDb/utils_test.go b/indexedDb/impl/utils_test.go similarity index 99% rename from indexedDb/utils_test.go rename to indexedDb/impl/utils_test.go index 1c657ebd5dbb4607c977323dc8883042989f05f9..6b6da6ff36ee8be5412146443093ee7d85c07c4f 100644 --- a/indexedDb/utils_test.go +++ b/indexedDb/impl/utils_test.go @@ -7,7 +7,7 @@ //go:build js && wasm -package indexedDb +package impl import ( "github.com/hack-pad/go-indexeddb/idb" diff --git a/indexedDb/worker/channels/implementation.go b/indexedDb/worker/channels/implementation.go new file mode 100644 index 0000000000000000000000000000000000000000..4d51b97e6a2941e07849c0e2e405539ccc86a8f7 --- /dev/null +++ b/indexedDb/worker/channels/implementation.go @@ -0,0 +1,451 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package channels + +import ( + "crypto/ed25519" + "encoding/json" + "time" + + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + + "gitlab.com/elixxir/client/v4/channels" + "gitlab.com/elixxir/client/v4/cmix/rounds" + cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" + "gitlab.com/elixxir/crypto/message" + "gitlab.com/elixxir/xxdk-wasm/worker" + "gitlab.com/xx_network/primitives/id" +) + +// wasmModel implements [channels.EventModel] interface, which uses the channels +// system passed an object that adheres to in order to get events on the +// channel. +type wasmModel struct { + wm *worker.Manager +} + +// JoinChannel is called whenever a channel is joined locally. +func (w *wasmModel) JoinChannel(channel *cryptoBroadcast.Channel) { + data, err := json.Marshal(channel) + if err != nil { + jww.ERROR.Printf( + "[CH] Could not JSON marshal broadcast.Channel: %+v", err) + return + } + + w.wm.SendMessage(JoinChannelTag, data, nil) +} + +// LeaveChannel is called whenever a channel is left locally. +func (w *wasmModel) LeaveChannel(channelID *id.ID) { + w.wm.SendMessage(LeaveChannelTag, channelID.Marshal(), nil) +} + +// ReceiveMessage is called whenever a message is received on a given channel. +// +// It may be called multiple times on the same message; it is incumbent on the +// user of the API to filter such called by message ID. +func (w *wasmModel) ReceiveMessage(channelID *id.ID, messageID message.ID, + nickname, text string, pubKey ed25519.PublicKey, dmToken uint32, + codeset uint8, timestamp time.Time, lease time.Duration, round rounds.Round, + mType channels.MessageType, status channels.SentStatus, hidden bool) uint64 { + msg := channels.ModelMessage{ + Nickname: nickname, + MessageID: messageID, + ChannelID: channelID, + Timestamp: timestamp, + Lease: lease, + Status: status, + Hidden: hidden, + Pinned: false, + Content: []byte(text), + Type: mType, + Round: round.ID, + PubKey: pubKey, + CodesetVersion: codeset, + DmToken: dmToken, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "[CH] Could not JSON marshal payload for ReceiveMessage: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wm.SendMessage(ReceiveMessageTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf("[CH] Could not JSON unmarshal response to "+ + "ReceiveMessage: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("[CH] Timed out after %s waiting for response from "+ + "the worker about ReceiveMessage", worker.ResponseTimeout) + } + + return 0 +} + +// ReceiveReplyMessage is JSON marshalled and sent to the worker for +// [wasmModel.ReceiveReply]. +type ReceiveReplyMessage struct { + ReactionTo message.ID `json:"replyTo"` + channels.ModelMessage `json:"message"` +} + +// ReceiveReply is called whenever a message is received that is a reply on a +// given channel. It may be called multiple times on the same message; it is +// incumbent on the user of the API to filter such called by message ID. +// +// Messages may arrive our of order, so a reply, in theory, can arrive before +// the initial message. As a result, it may be important to buffer replies. +func (w *wasmModel) ReceiveReply(channelID *id.ID, messageID, + replyTo message.ID, nickname, text string, pubKey ed25519.PublicKey, + dmToken uint32, codeset uint8, timestamp time.Time, lease time.Duration, + round rounds.Round, mType channels.MessageType, status channels.SentStatus, + hidden bool) uint64 { + msg := ReceiveReplyMessage{ + ReactionTo: replyTo, + ModelMessage: channels.ModelMessage{ + Nickname: nickname, + MessageID: messageID, + ChannelID: channelID, + Timestamp: timestamp, + Lease: lease, + Status: status, + Hidden: hidden, + Pinned: false, + Content: []byte(text), + Type: mType, + Round: round.ID, + PubKey: pubKey, + CodesetVersion: codeset, + DmToken: dmToken, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "[CH] Could not JSON marshal payload for ReceiveReply: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wm.SendMessage(ReceiveReplyTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf("[CH] Could not JSON unmarshal response to "+ + "ReceiveReply: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("[CH] Timed out after %s waiting for response from "+ + "the worker about ReceiveReply", worker.ResponseTimeout) + } + + return 0 +} + +// ReceiveReaction is called whenever a reaction to a message is received on a +// given channel. It may be called multiple times on the same reaction; it is +// incumbent on the user of the API to filter such called by message ID. +// +// Messages may arrive our of order, so a reply, in theory, can arrive before +// the initial message. As a result, it may be important to buffer reactions. +func (w *wasmModel) ReceiveReaction(channelID *id.ID, messageID, + reactionTo message.ID, nickname, reaction string, pubKey ed25519.PublicKey, + dmToken uint32, codeset uint8, timestamp time.Time, lease time.Duration, + round rounds.Round, mType channels.MessageType, status channels.SentStatus, + hidden bool) uint64 { + + msg := ReceiveReplyMessage{ + ReactionTo: reactionTo, + ModelMessage: channels.ModelMessage{ + Nickname: nickname, + MessageID: messageID, + ChannelID: channelID, + Timestamp: timestamp, + Lease: lease, + Status: status, + Hidden: hidden, + Pinned: false, + Content: []byte(reaction), + Type: mType, + Round: round.ID, + PubKey: pubKey, + CodesetVersion: codeset, + DmToken: dmToken, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "[CH] Could not JSON marshal payload for ReceiveReaction: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wm.SendMessage(ReceiveReactionTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf("[CH] Could not JSON unmarshal response to "+ + "ReceiveReaction: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("[CH] Timed out after %s waiting for response from "+ + "the worker about ReceiveReply", worker.ResponseTimeout) + } + + return 0 +} + +// MessageUpdateInfo is JSON marshalled and sent to the worker for +// [wasmModel.UpdateFromMessageID] and [wasmModel.UpdateFromUUID]. +type MessageUpdateInfo struct { + UUID uint64 `json:"uuid"` + + MessageID message.ID `json:"messageID"` + MessageIDSet bool `json:"messageIDSet"` + + Timestamp time.Time `json:"timestamp"` + TimestampSet bool `json:"timestampSet"` + + RoundID id.Round `json:"round"` + RoundIDSet bool `json:"roundIDSet"` + + Pinned bool `json:"pinned"` + PinnedSet bool `json:"pinnedSet"` + + Hidden bool `json:"hidden"` + HiddenSet bool `json:"hiddenSet"` + + Status channels.SentStatus `json:"status"` + StatusSet bool `json:"statusSet"` +} + +// UpdateFromUUID is called whenever a message at the UUID is modified. +// +// messageID, timestamp, round, pinned, and hidden are all nillable and may be +// updated based upon the UUID at a later date. If a nil value is passed, then +// make no update. +func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, + timestamp *time.Time, round *rounds.Round, pinned, hidden *bool, + status *channels.SentStatus) { + msg := MessageUpdateInfo{UUID: uuid} + if messageID != nil { + msg.MessageID = *messageID + msg.MessageIDSet = true + } + if timestamp != nil { + msg.Timestamp = *timestamp + msg.TimestampSet = true + } + if round != nil { + msg.RoundID = round.ID + msg.RoundIDSet = true + } + if pinned != nil { + msg.Pinned = *pinned + msg.PinnedSet = true + } + if hidden != nil { + msg.Hidden = *hidden + msg.HiddenSet = true + } + if status != nil { + msg.Status = *status + msg.StatusSet = true + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "[CH] Could not JSON marshal payload for UpdateFromUUID: %+v", err) + return + } + + w.wm.SendMessage(UpdateFromUUIDTag, data, nil) +} + +// UpdateFromMessageID is called whenever a message with the message ID is +// modified. +// +// The API needs to return the UUID of the modified message that can be +// referenced at a later time. +// +// timestamp, round, pinned, and hidden are all nillable and may be updated +// based upon the UUID at a later date. If a nil value is passed, then make +// no update. +func (w *wasmModel) UpdateFromMessageID(messageID message.ID, + timestamp *time.Time, round *rounds.Round, pinned, hidden *bool, + status *channels.SentStatus) uint64 { + + msg := MessageUpdateInfo{MessageID: messageID, MessageIDSet: true} + if timestamp != nil { + msg.Timestamp = *timestamp + msg.TimestampSet = true + } + if round != nil { + msg.RoundID = round.ID + msg.RoundIDSet = true + } + if pinned != nil { + msg.Pinned = *pinned + msg.PinnedSet = true + } + if hidden != nil { + msg.Hidden = *hidden + msg.HiddenSet = true + } + if status != nil { + msg.Status = *status + msg.StatusSet = true + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf("[CH] Could not JSON marshal payload for "+ + "UpdateFromMessageID: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wm.SendMessage(UpdateFromMessageIDTag, data, + func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf("[CH] Could not JSON unmarshal response to "+ + "UpdateFromMessageID: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("[CH] Timed out after %s waiting for response from "+ + "the worker about UpdateFromMessageID", worker.ResponseTimeout) + } + + return 0 +} + +// GetMessageMessage is JSON marshalled and sent to the worker for +// [wasmModel.GetMessage]. +type GetMessageMessage struct { + Message channels.ModelMessage `json:"message"` + Error string `json:"error"` +} + +// GetMessage returns the message with the given [channel.MessageID]. +func (w *wasmModel) GetMessage( + messageID message.ID) (channels.ModelMessage, error) { + msgChan := make(chan GetMessageMessage) + w.wm.SendMessage(GetMessageTag, messageID.Marshal(), + func(data []byte) { + var msg GetMessageMessage + err := json.Unmarshal(data, &msg) + if err != nil { + jww.ERROR.Printf("[CH] Could not JSON unmarshal response to "+ + "GetMessage: %+v", err) + } + msgChan <- msg + }) + + select { + case msg := <-msgChan: + if msg.Error != "" { + return channels.ModelMessage{}, errors.New(msg.Error) + } + return msg.Message, nil + case <-time.After(worker.ResponseTimeout): + return channels.ModelMessage{}, errors.Errorf("timed out after %s "+ + "waiting for response from the worker about GetMessage", + worker.ResponseTimeout) + } +} + +// DeleteMessage removes a message with the given messageID from storage. +func (w *wasmModel) DeleteMessage(messageID message.ID) error { + errChan := make(chan error) + w.wm.SendMessage(DeleteMessageTag, messageID.Marshal(), + func(data []byte) { + if data != nil { + errChan <- errors.New(string(data)) + } else { + errChan <- nil + } + }) + + select { + case err := <-errChan: + return err + case <-time.After(worker.ResponseTimeout): + return errors.Errorf("timed out after %s waiting for response from "+ + "the worker about DeleteMessage", worker.ResponseTimeout) + } +} + +// MuteUserMessage is JSON marshalled and sent to the worker for +// [wasmModel.MuteUser]. +type MuteUserMessage struct { + ChannelID *id.ID `json:"channelID"` + PubKey ed25519.PublicKey `json:"pubKey"` + Unmute bool `json:"unmute"` +} + +// MuteUser is called whenever a user is muted or unmuted. +func (w *wasmModel) MuteUser( + channelID *id.ID, pubKey ed25519.PublicKey, unmute bool) { + msg := MuteUserMessage{ + ChannelID: channelID, + PubKey: pubKey, + Unmute: unmute, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf("[CH] Could not marshal MuteUserMessage: %+v", err) + return + } + + w.wm.SendMessage(MuteUserTag, data, nil) +} diff --git a/indexedDb/worker/channels/init.go b/indexedDb/worker/channels/init.go new file mode 100644 index 0000000000000000000000000000000000000000..04b7a8f4392ac78051bd9cce001be45a98477dca --- /dev/null +++ b/indexedDb/worker/channels/init.go @@ -0,0 +1,242 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package channels + +import ( + "crypto/ed25519" + "encoding/json" + "github.com/pkg/errors" + "time" + + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/v4/channels" + + cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/crypto/message" + "gitlab.com/elixxir/xxdk-wasm/storage" + "gitlab.com/elixxir/xxdk-wasm/worker" + "gitlab.com/xx_network/primitives/id" +) + +// MessageReceivedCallback is called any time a message is received or updated. +// +// update is true if the row is old and was edited. +type MessageReceivedCallback func(uuid uint64, channelID *id.ID, update bool) + +// DeletedMessageCallback is called any time a message is deleted. +type DeletedMessageCallback func(messageID message.ID) + +// MutedUserCallback is called any time a user is muted or unmuted. unmute is +// true if the user has been unmuted and false if they have been muted. +type MutedUserCallback func( + channelID *id.ID, pubKey ed25519.PublicKey, unmute bool) + +// NewWASMEventModelBuilder returns an EventModelBuilder which allows +// the channel manager to define the path but the callback is the same +// across the board. +func NewWASMEventModelBuilder(wasmJsPath string, + encryption cryptoChannel.Cipher, messageReceivedCB MessageReceivedCallback, + deletedMessageCB DeletedMessageCallback, + mutedUserCB MutedUserCallback) channels.EventModelBuilder { + fn := func(path string) (channels.EventModel, error) { + return NewWASMEventModel(path, wasmJsPath, encryption, + messageReceivedCB, deletedMessageCB, mutedUserCB) + } + return fn +} + +// NewWASMEventModelMessage is JSON marshalled and sent to the worker for +// [NewWASMEventModel]. +type NewWASMEventModelMessage struct { + Path string `json:"path"` + EncryptionJSON string `json:"encryptionJSON"` +} + +// NewWASMEventModel returns a [channels.EventModel] backed by a wasmModel. +// The name should be a base64 encoding of the users public key. +func NewWASMEventModel(path, wasmJsPath string, encryption cryptoChannel.Cipher, + messageReceivedCB MessageReceivedCallback, + deletedMessageCB DeletedMessageCallback, mutedUserCB MutedUserCallback) ( + channels.EventModel, error) { + + wm, err := worker.NewManager(wasmJsPath, "channelsIndexedDb", true) + if err != nil { + return nil, err + } + + // Register handler to manage messages for the MessageReceivedCallback + wm.RegisterCallback(MessageReceivedCallbackTag, + messageReceivedCallbackHandler(messageReceivedCB)) + + // Register handler to manage messages for the DeletedMessageCallback + wm.RegisterCallback(DeletedMessageCallbackTag, + deletedMessageCallbackHandler(deletedMessageCB)) + + // Register handler to manage messages for the MutedUserCallback + wm.RegisterCallback(MutedUserCallbackTag, + mutedUserCallbackHandler(mutedUserCB)) + + // Register handler to manage checking encryption status from local storage + wm.RegisterCallback(EncryptionStatusTag, checkDbEncryptionStatusHandler(wm)) + + // Register handler to manage the storage of the database name + wm.RegisterCallback(StoreDatabaseNameTag, storeDatabaseNameHandler(wm)) + + encryptionJSON, err := json.Marshal(encryption) + if err != nil { + return nil, err + } + + msg := NewWASMEventModelMessage{ + Path: path, + EncryptionJSON: string(encryptionJSON), + } + + payload, err := json.Marshal(msg) + if err != nil { + return nil, err + } + + errChan := make(chan string) + wm.SendMessage(NewWASMEventModelTag, payload, + func(data []byte) { errChan <- string(data) }) + + select { + case workerErr := <-errChan: + if workerErr != "" { + return nil, errors.New(workerErr) + } + case <-time.After(worker.ResponseTimeout): + return nil, errors.Errorf("timed out after %s waiting for indexedDB "+ + "database in worker to initialize", worker.ResponseTimeout) + } + + return &wasmModel{wm}, nil +} + +// MessageReceivedCallbackMessage is JSON marshalled and received from the +// worker for the [MessageReceivedCallback] callback. +type MessageReceivedCallbackMessage struct { + UUID uint64 `json:"uuid"` + ChannelID *id.ID `json:"channelID"` + Update bool `json:"update"` +} + +// messageReceivedCallbackHandler returns a handler to manage messages for the +// MessageReceivedCallback. +func messageReceivedCallbackHandler(cb MessageReceivedCallback) func(data []byte) { + return func(data []byte) { + var msg MessageReceivedCallbackMessage + err := json.Unmarshal(data, &msg) + if err != nil { + jww.ERROR.Printf( + "Failed to JSON unmarshal %T from worker: %+v", msg, err) + return + } + + cb(msg.UUID, msg.ChannelID, msg.Update) + } +} + +// deletedMessageCallbackHandler returns a handler to manage messages for the +// DeletedMessageCallback. +func deletedMessageCallbackHandler(cb DeletedMessageCallback) func(data []byte) { + return func(data []byte) { + messageID, err := message.UnmarshalID(data) + if err != nil { + jww.ERROR.Printf( + "Failed to JSON unmarshal message ID from worker: %+v", err) + } + + cb(messageID) + } +} + +// mutedUserCallbackHandler returns a handler to manage messages for the +// MutedUserCallback. +func mutedUserCallbackHandler(cb MutedUserCallback) func(data []byte) { + return func(data []byte) { + var msg MuteUserMessage + err := json.Unmarshal(data, &msg) + if err != nil { + jww.ERROR.Printf( + "Failed to JSON unmarshal %T from worker: %+v", msg, err) + return + } + + cb(msg.ChannelID, msg.PubKey, msg.Unmute) + } +} + +// EncryptionStatusMessage is JSON marshalled and received from the worker when +// the database checks if it is encrypted. +type EncryptionStatusMessage struct { + DatabaseName string `json:"databaseName"` + EncryptionStatus bool `json:"encryptionStatus"` +} + +// EncryptionStatusReply is JSON marshalled and sent to the worker is response +// to the [EncryptionStatusMessage]. +type EncryptionStatusReply struct { + EncryptionStatus bool `json:"encryptionStatus"` + Error string `json:"error"` +} + +// checkDbEncryptionStatusHandler returns a handler to manage checking +// encryption status from local storage. +func checkDbEncryptionStatusHandler( + wh *worker.Manager) func(data []byte) { + return func(data []byte) { + // Unmarshal received message + var msg EncryptionStatusMessage + err := json.Unmarshal(data, &msg) + if err != nil { + jww.ERROR.Printf("Failed to JSON unmarshal "+ + "EncryptionStatusMessage message from worker: %+v", err) + return + } + + // Pass message values to storage + loadedEncryptionStatus, err := storage.StoreIndexedDbEncryptionStatus( + msg.DatabaseName, msg.EncryptionStatus) + var reply EncryptionStatusReply + if err != nil { + reply.Error = err.Error() + } else { + reply.EncryptionStatus = loadedEncryptionStatus + } + + // Return response + statusData, err := json.Marshal(reply) + if err != nil { + jww.ERROR.Printf( + "Failed to JSON marshal EncryptionStatusReply: %+v", err) + return + } + + wh.SendMessage(EncryptionStatusTag, statusData, nil) + } +} + +// storeDatabaseNameHandler returns a handler that stores the database name to +// storage when it is received from the worker. +func storeDatabaseNameHandler( + wh *worker.Manager) func(data []byte) { + return func(data []byte) { + var returnData []byte + + // Get the database name and save it to storage + if err := storage.StoreIndexedDb(string(data)); err != nil { + returnData = []byte(err.Error()) + } + + wh.SendMessage(StoreDatabaseNameTag, returnData, nil) + } +} diff --git a/indexedDb/worker/channels/tags.go b/indexedDb/worker/channels/tags.go new file mode 100644 index 0000000000000000000000000000000000000000..d2ae61a9f6b65a9bf2a02483806e89fbc14edb9d --- /dev/null +++ b/indexedDb/worker/channels/tags.go @@ -0,0 +1,34 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package channels + +import "gitlab.com/elixxir/xxdk-wasm/worker" + +// List of tags that can be used when sending a message or registering a handler +// to receive a message. +const ( + NewWASMEventModelTag worker.Tag = "NewWASMEventModel" + MessageReceivedCallbackTag worker.Tag = "MessageReceivedCallback" + DeletedMessageCallbackTag worker.Tag = "DeletedMessageCallback" + MutedUserCallbackTag worker.Tag = "MutedUserCallback" + EncryptionStatusTag worker.Tag = "EncryptionStatus" + StoreDatabaseNameTag worker.Tag = "StoreDatabaseName" + + JoinChannelTag worker.Tag = "JoinChannel" + LeaveChannelTag worker.Tag = "LeaveChannel" + ReceiveMessageTag worker.Tag = "ReceiveMessage" + ReceiveReplyTag worker.Tag = "ReceiveReply" + ReceiveReactionTag worker.Tag = "ReceiveReaction" + UpdateFromUUIDTag worker.Tag = "UpdateFromUUID" + UpdateFromMessageIDTag worker.Tag = "UpdateFromMessageID" + GetMessageTag worker.Tag = "GetMessage" + DeleteMessageTag worker.Tag = "DeleteMessage" + MuteUserTag worker.Tag = "MuteUser" +) diff --git a/indexedDb/worker/dm/implementation.go b/indexedDb/worker/dm/implementation.go new file mode 100644 index 0000000000000000000000000000000000000000..2b37cef9c459769ba1ab08a8529781cf87e46740 --- /dev/null +++ b/indexedDb/worker/dm/implementation.go @@ -0,0 +1,246 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package channelEventModel + +import ( + "crypto/ed25519" + "encoding/json" + "time" + + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/v4/cmix/rounds" + "gitlab.com/elixxir/client/v4/dm" + "gitlab.com/elixxir/crypto/message" + "gitlab.com/elixxir/xxdk-wasm/worker" +) + +// wasmModel implements dm.EventModel interface, which uses the channels system +// passed an object that adheres to in order to get events on the channel. +type wasmModel struct { + wh *worker.Manager +} + +// TransferMessage is JSON marshalled and sent to the worker. +type TransferMessage struct { + UUID uint64 `json:"uuid"` + MessageID message.ID `json:"messageID"` + ReactionTo message.ID `json:"reactionTo"` + Nickname string `json:"nickname"` + Text []byte `json:"text"` + PubKey ed25519.PublicKey `json:"pubKey"` + DmToken uint32 `json:"dmToken"` + Codeset uint8 `json:"codeset"` + Timestamp time.Time `json:"timestamp"` + Round rounds.Round `json:"round"` + MType dm.MessageType `json:"mType"` + Status dm.Status `json:"status"` +} + +func (w *wasmModel) Receive(messageID message.ID, nickname string, text []byte, + pubKey ed25519.PublicKey, dmToken uint32, codeset uint8, timestamp time.Time, + round rounds.Round, mType dm.MessageType, status dm.Status) uint64 { + msg := TransferMessage{ + MessageID: messageID, + Nickname: nickname, + Text: text, + PubKey: pubKey, + DmToken: dmToken, + Codeset: codeset, + Timestamp: timestamp, + Round: round, + MType: mType, + Status: status, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "Could not JSON marshal payload for TransferMessage: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wh.SendMessage(ReceiveTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf( + "Could not JSON unmarshal response to Receive: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("Timed out after %s waiting for response from the "+ + "worker about Receive", worker.ResponseTimeout) + } + + return 0 +} + +func (w *wasmModel) ReceiveText(messageID message.ID, nickname, text string, + pubKey ed25519.PublicKey, dmToken uint32, codeset uint8, + timestamp time.Time, round rounds.Round, status dm.Status) uint64 { + msg := TransferMessage{ + MessageID: messageID, + Nickname: nickname, + Text: []byte(text), + PubKey: pubKey, + DmToken: dmToken, + Codeset: codeset, + Timestamp: timestamp, + Round: round, + Status: status, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "Could not JSON marshal payload for TransferMessage: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wh.SendMessage(ReceiveTextTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf( + "Could not JSON unmarshal response to ReceiveText: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("Timed out after %s waiting for response from the "+ + "worker about ReceiveText", worker.ResponseTimeout) + } + + return 0 +} + +func (w *wasmModel) ReceiveReply(messageID, reactionTo message.ID, nickname, + text string, pubKey ed25519.PublicKey, dmToken uint32, codeset uint8, + timestamp time.Time, round rounds.Round, status dm.Status) uint64 { + msg := TransferMessage{ + MessageID: messageID, + ReactionTo: reactionTo, + Nickname: nickname, + Text: []byte(text), + PubKey: pubKey, + DmToken: dmToken, + Codeset: codeset, + Timestamp: timestamp, + Round: round, + Status: status, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "Could not JSON marshal payload for TransferMessage: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wh.SendMessage(ReceiveReplyTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf( + "Could not JSON unmarshal response to ReceiveReply: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("Timed out after %s waiting for response from the "+ + "worker about ReceiveReply", worker.ResponseTimeout) + } + + return 0 +} + +func (w *wasmModel) ReceiveReaction(messageID, reactionTo message.ID, nickname, + reaction string, pubKey ed25519.PublicKey, dmToken uint32, codeset uint8, + timestamp time.Time, round rounds.Round, status dm.Status) uint64 { + msg := TransferMessage{ + MessageID: messageID, + ReactionTo: reactionTo, + Nickname: nickname, + Text: []byte(reaction), + PubKey: pubKey, + DmToken: dmToken, + Codeset: codeset, + Timestamp: timestamp, + Round: round, + Status: status, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "Could not JSON marshal payload for TransferMessage: %+v", err) + return 0 + } + + uuidChan := make(chan uint64) + w.wh.SendMessage(ReceiveReactionTag, data, func(data []byte) { + var uuid uint64 + err = json.Unmarshal(data, &uuid) + if err != nil { + jww.ERROR.Printf( + "Could not JSON unmarshal response to ReceiveReaction: %+v", err) + uuidChan <- 0 + } + uuidChan <- uuid + }) + + select { + case uuid := <-uuidChan: + return uuid + case <-time.After(worker.ResponseTimeout): + jww.ERROR.Printf("Timed out after %s waiting for response from the "+ + "worker about ReceiveReaction", worker.ResponseTimeout) + } + + return 0 +} + +func (w *wasmModel) UpdateSentStatus(uuid uint64, messageID message.ID, + timestamp time.Time, round rounds.Round, status dm.Status) { + msg := TransferMessage{ + UUID: uuid, + MessageID: messageID, + Timestamp: timestamp, + Round: round, + Status: status, + } + + data, err := json.Marshal(msg) + if err != nil { + jww.ERROR.Printf( + "Could not JSON marshal payload for TransferMessage: %+v", err) + } + + w.wh.SendMessage(UpdateSentStatusTag, data, nil) +} diff --git a/indexedDb/worker/dm/init.go b/indexedDb/worker/dm/init.go new file mode 100644 index 0000000000000000000000000000000000000000..145edf935ba9804dd315c2196d301bb60581ecfb --- /dev/null +++ b/indexedDb/worker/dm/init.go @@ -0,0 +1,176 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package channelEventModel + +import ( + "crypto/ed25519" + "encoding/json" + "time" + + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + + "gitlab.com/elixxir/client/v4/dm" + cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/xxdk-wasm/storage" + "gitlab.com/elixxir/xxdk-wasm/worker" +) + +// MessageReceivedCallback is called any time a message is received or updated. +// +// update is true if the row is old and was edited. +type MessageReceivedCallback func( + uuid uint64, pubKey ed25519.PublicKey, update bool) + +// NewWASMEventModelMessage is JSON marshalled and sent to the worker for +// [NewWASMEventModel]. +type NewWASMEventModelMessage struct { + Path string `json:"path"` + EncryptionJSON string `json:"encryptionJSON"` +} + +// NewWASMEventModel returns a [channels.EventModel] backed by a wasmModel. +// The name should be a base64 encoding of the users public key. +func NewWASMEventModel(path, wasmJsPath string, encryption cryptoChannel.Cipher, + cb MessageReceivedCallback) (dm.EventModel, error) { + + wh, err := worker.NewManager(wasmJsPath, "dmIndexedDb", true) + if err != nil { + return nil, err + } + + // Register handler to manage messages for the MessageReceivedCallback + wh.RegisterCallback( + MessageReceivedCallbackTag, messageReceivedCallbackHandler(cb)) + + // Register handler to manage checking encryption status from local storage + wh.RegisterCallback(EncryptionStatusTag, checkDbEncryptionStatusHandler(wh)) + + // Register handler to manage the storage of the database name + wh.RegisterCallback(StoreDatabaseNameTag, storeDatabaseNameHandler(wh)) + + encryptionJSON, err := json.Marshal(encryption) + if err != nil { + return nil, err + } + + msg := NewWASMEventModelMessage{ + Path: path, + EncryptionJSON: string(encryptionJSON), + } + + payload, err := json.Marshal(msg) + if err != nil { + return nil, err + } + + errChan := make(chan string) + wh.SendMessage(NewWASMEventModelTag, payload, + func(data []byte) { errChan <- string(data) }) + + select { + case workerErr := <-errChan: + if workerErr != "" { + return nil, errors.New(workerErr) + } + case <-time.After(worker.ResponseTimeout): + return nil, errors.Errorf("timed out after %s waiting for indexedDB "+ + "database in worker to initialize", worker.ResponseTimeout) + } + + return &wasmModel{wh}, nil +} + +// MessageReceivedCallbackMessage is JSON marshalled and received from the +// worker for the [MessageReceivedCallback] callback. +type MessageReceivedCallbackMessage struct { + UUID uint64 `json:"uuid"` + PubKey ed25519.PublicKey `json:"pubKey"` + Update bool `json:"update"` +} + +// messageReceivedCallbackHandler returns a handler to manage messages for the +// MessageReceivedCallback. +func messageReceivedCallbackHandler(cb MessageReceivedCallback) func(data []byte) { + return func(data []byte) { + var msg MessageReceivedCallbackMessage + err := json.Unmarshal(data, &msg) + if err != nil { + jww.ERROR.Printf("Failed to JSON unmarshal "+ + "MessageReceivedCallback message from worker: %+v", err) + return + } + cb(msg.UUID, msg.PubKey, msg.Update) + } +} + +// EncryptionStatusMessage is JSON marshalled and received from the worker when +// the database checks if it is encrypted. +type EncryptionStatusMessage struct { + DatabaseName string `json:"databaseName"` + EncryptionStatus bool `json:"encryptionStatus"` +} + +// EncryptionStatusReply is JSON marshalled and sent to the worker is response +// to the [EncryptionStatusMessage]. +type EncryptionStatusReply struct { + EncryptionStatus bool `json:"encryptionStatus"` + Error string `json:"error"` +} + +// checkDbEncryptionStatusHandler returns a handler to manage checking +// encryption status from local storage. +func checkDbEncryptionStatusHandler(wh *worker.Manager) func(data []byte) { + return func(data []byte) { + // Unmarshal received message + var msg EncryptionStatusMessage + err := json.Unmarshal(data, &msg) + if err != nil { + jww.ERROR.Printf("Failed to JSON unmarshal "+ + "EncryptionStatusMessage message from worker: %+v", err) + return + } + + // Pass message values to storage + loadedEncryptionStatus, err := storage.StoreIndexedDbEncryptionStatus( + msg.DatabaseName, msg.EncryptionStatus) + var reply EncryptionStatusReply + if err != nil { + reply.Error = err.Error() + } else { + reply.EncryptionStatus = loadedEncryptionStatus + } + + // Return response + statusData, err := json.Marshal(reply) + if err != nil { + jww.ERROR.Printf( + "Failed to JSON marshal EncryptionStatusReply: %+v", err) + return + } + + wh.SendMessage(EncryptionStatusTag, statusData, nil) + } +} + +// storeDatabaseNameHandler returns a handler that stores the database name to +// storage when it is received from the worker. +func storeDatabaseNameHandler(wh *worker.Manager) func(data []byte) { + return func(data []byte) { + var returnData []byte + + // Get the database name and save it to storage + if err := storage.StoreIndexedDb(string(data)); err != nil { + returnData = []byte(err.Error()) + } + + wh.SendMessage(StoreDatabaseNameTag, returnData, nil) + } +} diff --git a/indexedDb/worker/dm/tags.go b/indexedDb/worker/dm/tags.go new file mode 100644 index 0000000000000000000000000000000000000000..afe29a33f3acc01470a35f84be495f2878bb194f --- /dev/null +++ b/indexedDb/worker/dm/tags.go @@ -0,0 +1,27 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package channelEventModel + +import "gitlab.com/elixxir/xxdk-wasm/worker" + +// List of tags that can be used when sending a message or registering a handler +// to receive a message. +const ( + NewWASMEventModelTag worker.Tag = "NewWASMEventModel" + MessageReceivedCallbackTag worker.Tag = "MessageReceivedCallback" + EncryptionStatusTag worker.Tag = "EncryptionStatus" + StoreDatabaseNameTag worker.Tag = "StoreDatabaseName" + + ReceiveReplyTag worker.Tag = "ReceiveReply" + ReceiveReactionTag worker.Tag = "ReceiveReaction" + ReceiveTag worker.Tag = "Receive" + ReceiveTextTag worker.Tag = "ReceiveText" + UpdateSentStatusTag worker.Tag = "UpdateSentStatusTag" +) diff --git a/logging/console.go b/logging/console.go new file mode 100644 index 0000000000000000000000000000000000000000..8c2edd821210d338f73ff18bfabedfc2f319d7d9 --- /dev/null +++ b/logging/console.go @@ -0,0 +1,97 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package logging + +import ( + jww "github.com/spf13/jwalterweatherman" + "io" + "syscall/js" +) + +var consoleObj = js.Global().Get("console") + +// Console contains the Javascript console object, which provides access to the +// browser's debugging console. This structure is defined for only a single +// method on the console object. For example, if the method is set to debug, +// then all calls to console.Write will print a debug message to the Javascript +// console. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/console +type Console struct { + method string + js.Value +} + +// Write writes the data to the Javascript console with preset method. Returns +// the number of bytes written. +func (c *Console) Write(p []byte) (n int, err error) { + c.Call(c.method, string(p)) + return len(p), nil +} + +// JsConsoleLogListener redirects log output to the Javascript console using the +// correct console method. +type JsConsoleLogListener struct { + jww.Threshold + js.Value + + trace *Console + debug *Console + info *Console + error *Console + warn *Console + critical *Console + fatal *Console + def *Console +} + +// NewJsConsoleLogListener initialises a new log listener that listener for the +// specific threshold and prints the logs to the Javascript console. +func NewJsConsoleLogListener(threshold jww.Threshold) *JsConsoleLogListener { + return &JsConsoleLogListener{ + Threshold: threshold, + Value: consoleObj, + trace: &Console{"debug", consoleObj}, + debug: &Console{"log", consoleObj}, + info: &Console{"info", consoleObj}, + warn: &Console{"warn", consoleObj}, + error: &Console{"error", consoleObj}, + critical: &Console{"error", consoleObj}, + fatal: &Console{"error", consoleObj}, + def: &Console{"log", consoleObj}, + } +} + +// Listen is called for every logging event. This function adheres to the +// [jwalterweatherman.LogListener] type. +func (ll *JsConsoleLogListener) Listen(t jww.Threshold) io.Writer { + if t < ll.Threshold { + return nil + } + + switch t { + case jww.LevelTrace: + return ll.trace + case jww.LevelDebug: + return ll.debug + case jww.LevelInfo: + return ll.info + case jww.LevelWarn: + return ll.warn + case jww.LevelError: + return ll.error + case jww.LevelCritical: + return ll.critical + case jww.LevelFatal: + return ll.fatal + default: + return ll.def + } +} diff --git a/logging/jwwListeners.go b/logging/jwwListeners.go new file mode 100644 index 0000000000000000000000000000000000000000..7d83f3d5bcc69190c505dfd0d0676fbe6104d1d0 --- /dev/null +++ b/logging/jwwListeners.go @@ -0,0 +1,78 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +package logging + +import ( + jww "github.com/spf13/jwalterweatherman" + "sync" +) + +// logListeners contains a map of all registered log listeners keyed on a unique +// ID that can be used to remove the listener once it has been added. This +// global keeps track of all listeners that are registered to jwalterweatherman +// logging. +var logListeners = newLogListenerList() + +type logListenerList struct { + listeners map[uint64]jww.LogListener + currentID uint64 + sync.Mutex +} + +func newLogListenerList() logListenerList { + return logListenerList{ + listeners: make(map[uint64]jww.LogListener), + currentID: 0, + } +} + +// AddLogListener registers the log listener with jwalterweatherman. Returns a +// unique ID that can be used to remove the listener. +func AddLogListener(ll jww.LogListener) uint64 { + logListeners.Lock() + defer logListeners.Unlock() + + id := logListeners.addLogListener(ll) + jww.SetLogListeners(logListeners.mapToSlice()...) + return id +} + +// RemoveLogListener unregisters the log listener with the ID from +// jwalterweatherman. +func RemoveLogListener(id uint64) { + logListeners.Lock() + defer logListeners.Unlock() + + logListeners.removeLogListener(id) + jww.SetLogListeners(logListeners.mapToSlice()...) + +} + +// addLogListener adds the listener to the list and returns its unique ID. +func (lll *logListenerList) addLogListener(ll jww.LogListener) uint64 { + id := lll.currentID + lll.currentID++ + lll.listeners[id] = ll + + return id +} + +// removeLogListener removes the listener with the specified ID from the list. +func (lll *logListenerList) removeLogListener(id uint64) { + delete(lll.listeners, id) +} + +// mapToSlice converts the map of listeners to a slice of listeners so that it +// can be registered with jwalterweatherman.SetLogListeners. +func (lll *logListenerList) mapToSlice() []jww.LogListener { + listeners := make([]jww.LogListener, 0, len(lll.listeners)) + for _, l := range lll.listeners { + listeners = append(listeners, l) + } + return listeners +} diff --git a/logging/logLevel.go b/logging/logLevel.go new file mode 100644 index 0000000000000000000000000000000000000000..895857475c4c647d7625b43644e6c4f32be98155 --- /dev/null +++ b/logging/logLevel.go @@ -0,0 +1,89 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package logging + +import ( + "fmt" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/utils" + "log" + "syscall/js" +) + +// LogLevel sets level of logging. All logs at the set level and below will be +// displayed (e.g., when log level is ERROR, only ERROR, CRITICAL, and FATAL +// messages will be printed). +// +// The default log level without updates is INFO. +func LogLevel(threshold jww.Threshold) error { + if threshold < jww.LevelTrace || threshold > jww.LevelFatal { + return errors.Errorf("log level is not valid: log level: %d", threshold) + } + + jww.SetLogThreshold(threshold) + jww.SetFlags(log.LstdFlags | log.Lmicroseconds) + + ll := NewJsConsoleLogListener(threshold) + AddLogListener(ll.Listen) + jww.SetStdoutThreshold(jww.LevelFatal + 1) + + msg := fmt.Sprintf("Log level set to: %s", threshold) + switch threshold { + case jww.LevelTrace: + fallthrough + case jww.LevelDebug: + fallthrough + case jww.LevelInfo: + jww.INFO.Print(msg) + case jww.LevelWarn: + jww.WARN.Print(msg) + case jww.LevelError: + jww.ERROR.Print(msg) + case jww.LevelCritical: + jww.CRITICAL.Print(msg) + case jww.LevelFatal: + jww.FATAL.Print(msg) + } + + return nil +} + +// LogLevelJS sets level of logging. All logs at the set level and below will be +// displayed (e.g., when log level is ERROR, only ERROR, CRITICAL, and FATAL +// messages will be printed). +// +// Log level options: +// +// TRACE - 0 +// DEBUG - 1 +// INFO - 2 +// WARN - 3 +// ERROR - 4 +// CRITICAL - 5 +// FATAL - 6 +// +// The default log level without updates is INFO. +// +// Parameters: +// - args[0] - Log level (int). +// +// Returns: +// - Throws TypeError if the log level is invalid. +func LogLevelJS(_ js.Value, args []js.Value) any { + threshold := jww.Threshold(args[0].Int()) + err := LogLevel(threshold) + if err != nil { + utils.Throw(utils.TypeError, err) + return nil + } + + return nil +} diff --git a/logging/logger.go b/logging/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..7da185f34ff81c31475dec3531c407bd53344114 --- /dev/null +++ b/logging/logger.go @@ -0,0 +1,537 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package logging + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "github.com/armon/circbuf" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/utils" + "gitlab.com/elixxir/xxdk-wasm/worker" + "io" + "strconv" + "sync/atomic" + "syscall/js" + "time" +) + +const ( + // DefaultInitThreshold is the log threshold used for the initial log before + // any logging options is set. + DefaultInitThreshold = jww.LevelTrace + + // logListenerChanSize is the size of the listener channel that stores log + // messages before they are written. + logListenerChanSize = 1500 +) + +// List of tags that can be used when sending a message or registering a handler +// to receive a message. +const ( + NewLogFileTag worker.Tag = "NewLogFile" + WriteLogTag worker.Tag = "WriteLog" + GetFileTag worker.Tag = "GetFile" + GetFileExtTag worker.Tag = "GetFileExt" + SizeTag worker.Tag = "Size" +) + +// logger is the global that all jwalterweatherman logging is sent to. +var logger *Logger + +// Logger manages the recording of jwalterweatherman logs. It can write logs to +// a local, in-memory buffer or to an external worker. +type Logger struct { + threshold jww.Threshold + maxLogFileSize int + logListenerID uint64 + + listenChan chan []byte + mode atomic.Uint32 + processQuit chan struct{} + + cb *circbuf.Buffer + wm *worker.Manager +} + +// InitLogger initializes the logger. Include this in the init function in main. +func InitLogger() *Logger { + logger = NewLogger() + return logger +} + +// GetLogger returns the Logger object, used to manager where logging is +// recorded. +func GetLogger() *Logger { + return logger +} + +// NewLogger creates a new Logger that begins storing the first +// DefaultInitThreshold log entries. If either the log file or log worker is +// enabled, then these logs are redirected to the set destination. If the +// channel fills up with no log recorder enabled, then the listener is disabled. +func NewLogger() *Logger { + lf := newLogger() + + // Add the log listener + lf.logListenerID = AddLogListener(lf.Listen) + + jww.INFO.Printf("[LOG] Enabled initial log file listener in %s with ID %d "+ + "at threshold %s that can store %d entries", + lf.getMode(), lf.logListenerID, lf.Threshold(), cap(lf.listenChan)) + + return lf +} + +// newLogger initialises a Logger without adding it as a log listener. +func newLogger() *Logger { + lf := &Logger{ + threshold: DefaultInitThreshold, + listenChan: make(chan []byte, logListenerChanSize), + mode: atomic.Uint32{}, + processQuit: make(chan struct{}), + } + lf.setMode(initMode) + + return lf +} + +// LogToFile starts logging to a local, in-memory log file. +func (l *Logger) LogToFile(threshold jww.Threshold, maxLogFileSize int) error { + err := l.prepare(threshold, maxLogFileSize, fileMode) + if err != nil { + return err + } + + b, err := circbuf.NewBuffer(int64(maxLogFileSize)) + if err != nil { + return err + } + l.cb = b + + sendLog := func(p []byte) { + if n, err2 := l.cb.Write(p); err2 != nil { + jww.ERROR.Printf( + "[LOG] Error writing log to circular buffer: %+v", err2) + } else if n != len(p) { + jww.ERROR.Printf( + "[LOG] Wrote %d bytes when %d bytes expected", n, len(p)) + } + } + go l.processLog(workerMode, sendLog, l.processQuit) + + return nil +} + +// LogToFileWorker starts a new worker that begins listening for logs and +// writing them to file. This function blocks until the worker has started. +func (l *Logger) LogToFileWorker(threshold jww.Threshold, maxLogFileSize int, + wasmJsPath, workerName string) error { + err := l.prepare(threshold, maxLogFileSize, workerMode) + if err != nil { + return err + } + + // Create new worker manager, which will start the worker and wait until + // communication has been established + wm, err := worker.NewManager(wasmJsPath, workerName, false) + if err != nil { + return err + } + l.wm = wm + + // Register the callback used by the Javascript to request the log file. + // This prevents an error print when GetFileExtTag is not registered. + l.wm.RegisterCallback(GetFileExtTag, func([]byte) { + jww.DEBUG.Print("[LOG] Received file requested from external " + + "Javascript. Ignoring file.") + }) + + data, err := json.Marshal(l.maxLogFileSize) + if err != nil { + return err + } + + // Send message to initialize the log file listener + errChan := make(chan error) + l.wm.SendMessage(NewLogFileTag, data, func(data []byte) { + if len(data) > 0 { + errChan <- errors.New(string(data)) + } else { + errChan <- nil + } + }) + + // Wait for worker to respond + select { + case err = <-errChan: + if err != nil { + return err + } + case <-time.After(worker.ResponseTimeout): + return errors.Errorf("timed out after %s waiting for new log "+ + "file in worker to initialize", worker.ResponseTimeout) + } + + jww.INFO.Printf("[LOG] Initialized log to file web worker %s.", workerName) + + sendLog := func(p []byte) { l.wm.SendMessage(WriteLogTag, p, nil) } + go l.processLog(workerMode, sendLog, l.processQuit) + + return nil +} + +// processLog processes the log messages sent to the listener channel and sends +// them to the appropriate recorder. +func (l *Logger) processLog(m mode, sendLog func(p []byte), quit chan struct{}) { + jww.INFO.Printf("[LOG] Starting log file processing thread in %s.", m) + + for { + select { + case <-quit: + jww.INFO.Printf("[LOG] Stopping log file processing thread.") + return + case p := <-l.listenChan: + go sendLog(p) + } + } +} + +// prepare sets the threshold, maxLogFileSize, and mode of the logger and +// prints a log message indicating this information. +func (l *Logger) prepare( + threshold jww.Threshold, maxLogFileSize int, m mode) error { + if m := l.getMode(); m != initMode { + return errors.Errorf("log already set to %s", m) + } else if threshold < jww.LevelTrace || threshold > jww.LevelFatal { + return errors.Errorf("log level of %d is invalid", threshold) + } + + l.threshold = threshold + l.maxLogFileSize = maxLogFileSize + l.setMode(m) + + msg := fmt.Sprintf("[LOG] Outputting log to file in %s of max size %d "+ + "with level %s", m, l.MaxSize(), l.Threshold()) + switch l.Threshold() { + case jww.LevelTrace: + fallthrough + case jww.LevelDebug: + fallthrough + case jww.LevelInfo: + jww.INFO.Print(msg) + case jww.LevelWarn: + jww.WARN.Print(msg) + case jww.LevelError: + jww.ERROR.Print(msg) + case jww.LevelCritical: + jww.CRITICAL.Print(msg) + case jww.LevelFatal: + jww.FATAL.Print(msg) + } + + return nil +} + +// StopLogging stops the logging of log messages and disables the log listener. +// If the log worker is running, it is terminated. Once logging is stopped, it +// cannot be resumed the log file cannot be recovered. +func (l *Logger) StopLogging() { + jww.DEBUG.Printf("[LOG] Removing log listener with ID %d", l.logListenerID) + RemoveLogListener(l.logListenerID) + + switch l.getMode() { + case workerMode: + go l.wm.Terminate() + jww.DEBUG.Printf("[LOG] Terminated log worker.") + case fileMode: + jww.DEBUG.Printf("[LOG] Reset circular buffer.") + l.cb.Reset() + } + + select { + case l.processQuit <- struct{}{}: + jww.DEBUG.Printf("[LOG] Sent quit channel to log process.") + default: + jww.DEBUG.Printf("[LOG] Failed to stop log processes.") + } +} + +// GetFile returns the entire log file. +// +// If the log file is listening locally, it returns it from the local buffer. If +// it is listening from the worker, it blocks until the file is returned. +func (l *Logger) GetFile() []byte { + switch l.getMode() { + case fileMode: + return l.cb.Bytes() + case workerMode: + fileChan := make(chan []byte) + l.wm.SendMessage(GetFileTag, nil, func(data []byte) { fileChan <- data }) + + select { + case file := <-fileChan: + return file + case <-time.After(worker.ResponseTimeout): + jww.FATAL.Panicf("[LOG] Timed out after %s waiting for log "+ + "file from worker", worker.ResponseTimeout) + return nil + } + default: + return nil + } +} + +// Threshold returns the log level threshold used in the file. +func (l *Logger) Threshold() jww.Threshold { + return l.threshold +} + +// MaxSize returns the max size, in bytes, that the log file is allowed to be. +func (l *Logger) MaxSize() int { + return l.maxLogFileSize +} + +// Size returns the current size, in bytes, written to the log file. +// +// If the log file is listening locally, it returns it from the local buffer. If +// it is listening from the worker, it blocks until the size is returned. +func (l *Logger) Size() int { + switch l.getMode() { + case fileMode: + return int(l.cb.Size()) + case workerMode: + sizeChan := make(chan []byte) + l.wm.SendMessage(SizeTag, nil, func(data []byte) { sizeChan <- data }) + + select { + case data := <-sizeChan: + return int(jww.Threshold(binary.LittleEndian.Uint64(data))) + case <-time.After(worker.ResponseTimeout): + jww.FATAL.Panicf("[LOG] Timed out after %s waiting for log "+ + "file size from worker", worker.ResponseTimeout) + return 0 + } + default: + return 0 + } +} + +//////////////////////////////////////////////////////////////////////////////// +// JWW Listener // +//////////////////////////////////////////////////////////////////////////////// + +// Listen is called for every logging event. This function adheres to the +// [jwalterweatherman.LogListener] type. +func (l *Logger) Listen(t jww.Threshold) io.Writer { + if t < l.threshold { + return nil + } + + return l +} + +// Write sends the bytes to the listener channel. It always returns the length +// of p and a nil error. This function adheres to the io.Writer interface. +func (l *Logger) Write(p []byte) (n int, err error) { + select { + case l.listenChan <- append([]byte{}, p...): + default: + jww.ERROR.Printf( + "[LOG] Logger channel filled. Log file recording stopping.") + l.StopLogging() + return 0, errors.Errorf( + "Logger channel filled. Log file recording stopping.") + } + return len(p), nil +} + +//////////////////////////////////////////////////////////////////////////////// +// Log File Mode // +//////////////////////////////////////////////////////////////////////////////// + +// mode represents the state of the Logger. +type mode uint32 + +const ( + initMode mode = iota + fileMode + workerMode +) + +func (l *Logger) setMode(m mode) { l.mode.Store(uint32(m)) } +func (l *Logger) getMode() mode { return mode(l.mode.Load()) } + +// String returns a human-readable representation of the mode for logging and +// debugging. This function adheres to the fmt.Stringer interface. +func (m mode) String() string { + switch m { + case initMode: + return "uninitialized mode" + case fileMode: + return "file mode" + case workerMode: + return "worker mode" + default: + return "invalid mode: " + strconv.Itoa(int(m)) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Javascript Bindings // +//////////////////////////////////////////////////////////////////////////////// + +// GetLoggerJS returns the Logger object, used to manager where logging is +// recorded and accessing the log file. +// +// Returns: +// - A Javascript representation of the [Logger] object. +func GetLoggerJS(js.Value, []js.Value) any { + return newLoggerJS(GetLogger()) +} + +// newLoggerJS creates a new Javascript compatible object (map[string]any) that +// matches the [Logger] structure. +func newLoggerJS(lfw *Logger) map[string]any { + logFileWorker := map[string]any{ + "LogToFile": js.FuncOf(lfw.LogToFileJS), + "LogToFileWorker": js.FuncOf(lfw.LogToFileWorkerJS), + "StopLogging": js.FuncOf(lfw.StopLoggingJS), + "GetFile": js.FuncOf(lfw.GetFileJS), + "Threshold": js.FuncOf(lfw.ThresholdJS), + "MaxSize": js.FuncOf(lfw.MaxSizeJS), + "Size": js.FuncOf(lfw.SizeJS), + "Worker": js.FuncOf(lfw.WorkerJS), + } + + return logFileWorker +} + +// LogToFileJS starts logging to a local, in-memory log file. +// +// Parameters: +// - args[0] - Log level (int). +// - args[1] - Max log file size, in bytes (int). +// +// Returns: +// - Throws a TypeError if starting the log file fails. +func (l *Logger) LogToFileJS(_ js.Value, args []js.Value) any { + threshold := jww.Threshold(args[0].Int()) + maxLogFileSize := args[1].Int() + + err := l.LogToFile(threshold, maxLogFileSize) + if err != nil { + utils.Throw(utils.TypeError, err) + return nil + } + + return nil +} + +// LogToFileWorkerJS starts a new worker that begins listening for logs and +// writing them to file. This function blocks until the worker has started. +// +// Parameters: +// - args[0] - Log level (int). +// - args[1] - Max log file size, in bytes (int). +// - args[2] - Path to Javascript start file for the worker WASM (string). +// - args[3] - Name of the worker (used in logs) (string). +// +// Returns a promise: +// - Resolves to nothing on success (void). +// - Rejected with an error if starting the worker fails. +func (l *Logger) LogToFileWorkerJS(_ js.Value, args []js.Value) any { + threshold := jww.Threshold(args[0].Int()) + maxLogFileSize := args[1].Int() + wasmJsPath := args[2].String() + workerName := args[3].String() + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + err := l.LogToFileWorker( + threshold, maxLogFileSize, wasmJsPath, workerName) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve() + } + } + + return utils.CreatePromise(promiseFn) +} + +// StopLoggingJS stops the logging of log messages and disables the log +// listener. If the log worker is running, it is terminated. Once logging is +// stopped, it cannot be resumed the log file cannot be recovered. +func (l *Logger) StopLoggingJS(js.Value, []js.Value) any { + l.StopLogging() + + return nil +} + +// GetFileJS returns the entire log file. +// +// If the log file is listening locally, it returns it from the local buffer. If +// it is listening from the worker, it blocks until the file is returned. +// +// Returns a promise: +// - Resolves to the log file contents (string). +func (l *Logger) GetFileJS(js.Value, []js.Value) any { + promiseFn := func(resolve, _ func(args ...any) js.Value) { + resolve(string(l.GetFile())) + } + + return utils.CreatePromise(promiseFn) +} + +// ThresholdJS returns the log level threshold used in the file. +// +// Returns: +// - Log level (int). +func (l *Logger) ThresholdJS(js.Value, []js.Value) any { + return int(l.Threshold()) +} + +// MaxSizeJS returns the max size, in bytes, that the log file is allowed to be. +// +// Returns: +// - Max file size (int). +func (l *Logger) MaxSizeJS(js.Value, []js.Value) any { + return l.MaxSize() +} + +// SizeJS returns the current size, in bytes, written to the log file. +// +// If the log file is listening locally, it returns it from the local buffer. If +// it is listening from the worker, it blocks until the size is returned. +// +// Returns a promise: +// - Resolves to the current file size (int). +func (l *Logger) SizeJS(js.Value, []js.Value) any { + promiseFn := func(resolve, _ func(args ...any) js.Value) { + resolve(l.Size()) + } + + return utils.CreatePromise(promiseFn) +} + +// WorkerJS returns the web worker object. +// +// Returns: +// - Javascript worker object. If the worker has not been initialized, it +// returns null. +func (l *Logger) WorkerJS(js.Value, []js.Value) any { + if l.getMode() == workerMode { + return l.wm.GetWorker() + } + + return js.Null() +} diff --git a/logging/logger_test.go b/logging/logger_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0b5267be9cabaf995ddc8fcdba4b5d3ec8eea133 --- /dev/null +++ b/logging/logger_test.go @@ -0,0 +1,172 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package logging + +import ( + "bytes" + "fmt" + jww "github.com/spf13/jwalterweatherman" + "testing" +) + +// Tests InitLogger +func TestInitLogger(t *testing.T) { +} + +// Tests GetLogger +func TestGetLogger(t *testing.T) { +} + +// Tests NewLogger +func TestNewLogger(t *testing.T) { +} + +// Tests Logger.LogToFile +func TestLogger_LogToFile(t *testing.T) { + jww.SetStdoutThreshold(jww.LevelTrace) + l := NewLogger() + + err := l.LogToFile(jww.LevelTrace, 50000000) + if err != nil { + t.Fatalf("Failed to LogToFile: %+v", err) + } + + jww.INFO.Printf("test") + + file := l.cb.Bytes() + fmt.Printf("file:----------------------------\n%s\n---------------------------------\n", file) +} + +// Tests Logger.LogToFileWorker +func TestLogger_LogToFileWorker(t *testing.T) { +} + +// Tests Logger.processLog +func TestLogger_processLog(t *testing.T) { +} + +// Tests Logger.prepare +func TestLogger_prepare(t *testing.T) { +} + +// Tests Logger.StopLogging +func TestLogger_StopLogging(t *testing.T) { +} + +// Tests Logger.GetFile +func TestLogger_GetFile(t *testing.T) { +} + +// Tests Logger.Threshold +func TestLogger_Threshold(t *testing.T) { +} + +// Tests Logger.MaxSize +func TestLogger_MaxSize(t *testing.T) { +} + +// Tests Logger.Size +func TestLogger_Size(t *testing.T) { +} + +// Tests Logger.Listen +func TestLogger_Listen(t *testing.T) { + + // l := newLogger() + +} + +// Tests that Logger.Write can fill the listenChan channel completely and that +// all messages are received in the order they were added. +func TestLogger_Write(t *testing.T) { + l := newLogger() + expectedLogs := make([][]byte, logListenerChanSize) + + for i := range expectedLogs { + p := []byte( + fmt.Sprintf("Log message %d of %d.", i+1, logListenerChanSize)) + expectedLogs[i] = p + n, err := l.Listen(jww.LevelError).Write(p) + if err != nil { + t.Errorf("Received impossible error (%d): %+v", i, err) + } else if n != len(p) { + t.Errorf("Received incorrect bytes written (%d)."+ + "\nexpected: %d\nreceived: %d", i, len(p), n) + } + } + + for i, expected := range expectedLogs { + select { + case received := <-l.listenChan: + if !bytes.Equal(expected, received) { + t.Errorf("Received unexpected meessage (%d)."+ + "\nexpected: %q\nreceived: %q", i, expected, received) + } + default: + t.Errorf("Failed to read from channel.") + } + } +} + +// Error path: Tests that Logger.Write returns an error when the listener +// channel is full. +func TestLogger_Write_ChannelFilledError(t *testing.T) { + l := newLogger() + expectedLogs := make([][]byte, logListenerChanSize) + + for i := range expectedLogs { + p := []byte( + fmt.Sprintf("Log message %d of %d.", i+1, logListenerChanSize)) + expectedLogs[i] = p + n, err := l.Listen(jww.LevelError).Write(p) + if err != nil { + t.Errorf("Received impossible error (%d): %+v", i, err) + } else if n != len(p) { + t.Errorf("Received incorrect bytes written (%d)."+ + "\nexpected: %d\nreceived: %d", i, len(p), n) + } + } + + _, err := l.Write([]byte("test")) + if err == nil { + t.Error("Failed to receive error when the chanel should be full.") + } +} + +// Tests that Logger.getMode gets the same value set with Logger.setMode. +func TestLogger_setMode_getMode(t *testing.T) { + l := newLogger() + + for i, m := range []mode{initMode, fileMode, workerMode, 12} { + l.setMode(m) + received := l.getMode() + if m != received { + t.Errorf("Received wrong mode (%d).\nexpected: %s\nreceived: %s", + i, m, received) + } + } + +} + +// Unit test of mode.String. +func Test_mode_String(t *testing.T) { + for m, expected := range map[mode]string{ + initMode: "uninitialized mode", + fileMode: "file mode", + workerMode: "worker mode", + 12: "invalid mode: 12", + } { + s := m.String() + if s != expected { + t.Errorf("Wrong string for mode %d.\nexpected: %s\nreceived: %s", + m, expected, s) + } + } +} diff --git a/logging/workerThread/logFileWorker.js b/logging/workerThread/logFileWorker.js new file mode 100644 index 0000000000000000000000000000000000000000..159bfaa0d919a4f0cb2758af48d80c65891e7820 --- /dev/null +++ b/logging/workerThread/logFileWorker.js @@ -0,0 +1,17 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +importScripts('wasm_exec.js'); + +const go = new Go(); +const binPath = 'xxdk-logFileWorker.wasm' +WebAssembly.instantiateStreaming(fetch(binPath), go.importObject).then((result) => { + go.run(result.instance); + LogLevel(1); +}).catch((err) => { + console.error(err); +}); \ No newline at end of file diff --git a/logging/workerThread/main.go b/logging/workerThread/main.go new file mode 100644 index 0000000000000000000000000000000000000000..91b059f3da1195b0ff244027608a0f8a98482fed --- /dev/null +++ b/logging/workerThread/main.go @@ -0,0 +1,110 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package main + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "github.com/armon/circbuf" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/logging" + "gitlab.com/elixxir/xxdk-wasm/worker" + "syscall/js" +) + +// SEMVER is the current semantic version of the xxDK Logger web worker. +const SEMVER = "0.1.0" + +func init() { + // Set up Javascript console listener set at level INFO + ll := logging.NewJsConsoleLogListener(jww.LevelDebug) + logging.AddLogListener(ll.Listen) + jww.SetStdoutThreshold(jww.LevelFatal + 1) + jww.INFO.Printf("xxDK Logger web worker version: v%s", SEMVER) +} + +// workerLogFile manages communication with the main thread and writing incoming +// logging messages to the log file. +type workerLogFile struct { + wtm *worker.ThreadManager + b *circbuf.Buffer +} + +func main() { + jww.INFO.Print("[LOG] Starting xxDK WebAssembly Logger Worker.") + + js.Global().Set("LogLevel", js.FuncOf(logging.LogLevelJS)) + + wlf := workerLogFile{wtm: worker.NewThreadManager("Logger", false)} + + wlf.registerCallbacks() + + wlf.wtm.SignalReady() + <-make(chan bool) + fmt.Println("[WW] Closing xxDK WebAssembly Log Worker.") +} + +// registerCallbacks registers all the necessary callbacks for the main thread +// to get the file and file metadata. +func (wlf *workerLogFile) registerCallbacks() { + // Callback for logging.LogToFileWorker + wlf.wtm.RegisterCallback(logging.NewLogFileTag, + func(data []byte) ([]byte, error) { + var maxLogFileSize int64 + err := json.Unmarshal(data, &maxLogFileSize) + if err != nil { + return []byte(err.Error()), err + } + + wlf.b, err = circbuf.NewBuffer(maxLogFileSize) + if err != nil { + return []byte(err.Error()), err + } + + jww.DEBUG.Printf("[LOG] Created new worker log file of size %d", + maxLogFileSize) + + return []byte{}, nil + }) + + // Callback for Logging.GetFile + wlf.wtm.RegisterCallback(logging.WriteLogTag, + func(data []byte) ([]byte, error) { + n, err := wlf.b.Write(data) + if err != nil { + return nil, err + } else if n != len(data) { + return nil, errors.Errorf( + "wrote %d bytes; expected %d bytes", n, len(data)) + } + + return nil, nil + }, + ) + + // Callback for Logging.GetFile + wlf.wtm.RegisterCallback(logging.GetFileTag, func([]byte) ([]byte, error) { + return wlf.b.Bytes(), nil + }) + + // Callback for Logging.GetFile + wlf.wtm.RegisterCallback(logging.GetFileExtTag, func([]byte) ([]byte, error) { + return wlf.b.Bytes(), nil + }) + + // Callback for Logging.Size + wlf.wtm.RegisterCallback(logging.SizeTag, func([]byte) ([]byte, error) { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(wlf.b.TotalWritten())) + return b, nil + }) +} diff --git a/main.go b/main.go index fe7680c76b0dbb14c74200a3cca495e5fa582e15..4755223854f7ef0370086bf0b541964639104037 100644 --- a/main.go +++ b/main.go @@ -10,22 +10,24 @@ package main import ( - "fmt" + "gitlab.com/elixxir/xxdk-wasm/logging" "os" "syscall/js" jww "github.com/spf13/jwalterweatherman" - "gitlab.com/elixxir/client/v4/bindings" "gitlab.com/elixxir/xxdk-wasm/storage" "gitlab.com/elixxir/xxdk-wasm/utils" "gitlab.com/elixxir/xxdk-wasm/wasm" ) func init() { + // Start logger first to capture all logging events + logging.InitLogger() + // Overwrites setting the log level to INFO done in bindings so that the // Javascript console can be used - ll := wasm.NewJsConsoleLogListener(jww.LevelInfo) - jww.SetLogListeners(ll.Listen) + ll := logging.NewJsConsoleLogListener(jww.LevelInfo) + logging.AddLogListener(ll.Listen) jww.SetStdoutThreshold(jww.LevelFatal + 1) // Check that the WASM binary version is correct @@ -36,8 +38,10 @@ func init() { } func main() { - fmt.Println("Starting xxDK WebAssembly bindings.") - fmt.Printf("Client version %s\n", bindings.GetVersion()) + jww.INFO.Printf("Starting xxDK WebAssembly bindings.") + + // logging/worker.go + js.Global().Set("GetLogger", js.FuncOf(logging.GetLoggerJS)) // storage/password.go js.Global().Set("GetOrInitPassword", js.FuncOf(storage.GetOrInitPassword)) @@ -147,7 +151,6 @@ func main() { // wasm/logging.go js.Global().Set("LogLevel", js.FuncOf(wasm.LogLevel)) - js.Global().Set("LogToFile", js.FuncOf(wasm.LogToFile)) js.Global().Set("RegisterLogWriter", js.FuncOf(wasm.RegisterLogWriter)) js.Global().Set("EnableGrpcLogs", js.FuncOf(wasm.EnableGrpcLogs)) diff --git a/storage/indexedDbEncryptionTrack.go b/storage/indexedDbEncryptionTrack.go index 6e52a2648d3ea076578e9697fa3e9b591edf043f..d568a63787490ea0979a189191272da70d957e05 100644 --- a/storage/indexedDbEncryptionTrack.go +++ b/storage/indexedDbEncryptionTrack.go @@ -18,16 +18,17 @@ import ( const databaseEncryptionToggleKey = "xxdkWasmDatabaseEncryptionToggle/" // StoreIndexedDbEncryptionStatus stores the encryption status if it has not -// been previously saved. If it has, it returns its value. +// been previously saved. If it has, then it returns its value. func StoreIndexedDbEncryptionStatus( - databaseName string, encryption bool) (bool, error) { + databaseName string, encryptionStatus bool) ( + loadedEncryptionStatus bool, err error) { data, err := GetLocalStorage().GetItem( databaseEncryptionToggleKey + databaseName) if err != nil { if errors.Is(err, os.ErrNotExist) { GetLocalStorage().SetItem( databaseEncryptionToggleKey+databaseName, []byte{1}) - return encryption, nil + return encryptionStatus, nil } else { return false, err } diff --git a/storage/indexedDbEncryptionTrack_test.go b/storage/indexedDbEncryptionTrack_test.go index ec754f87a940e14f6802e347f86f44fa437f4ff9..490d720be4616eba5118af797f0fbdaf1e606daf 100644 --- a/storage/indexedDbEncryptionTrack_test.go +++ b/storage/indexedDbEncryptionTrack_test.go @@ -18,23 +18,23 @@ import ( func TestStoreIndexedDbEncryptionStatus(t *testing.T) { databaseName := "databaseA" - encrypted, err := StoreIndexedDbEncryptionStatus(databaseName, true) + encryptionStatus, err := StoreIndexedDbEncryptionStatus(databaseName, true) if err != nil { t.Errorf("Failed to store/get encryption status: %+v", err) } - if encrypted != true { + if encryptionStatus != true { t.Errorf("Incorrect encryption values.\nexpected: %t\nreceived: %t", - true, encrypted) + true, encryptionStatus) } - encrypted, err = StoreIndexedDbEncryptionStatus(databaseName, false) + encryptionStatus, err = StoreIndexedDbEncryptionStatus(databaseName, false) if err != nil { t.Errorf("Failed to store/get encryption status: %+v", err) } - if encrypted != true { + if encryptionStatus != true { t.Errorf("Incorrect encryption values.\nexpected: %t\nreceived: %t", - true, encrypted) + true, encryptionStatus) } } diff --git a/utils/convert.go b/utils/convert.go index a5c79e309529089ebb918fc77f701f7a633c8d05..b1f2cd10172bca7e88ff6c77931c754ab1b7f1d8 100644 --- a/utils/convert.go +++ b/utils/convert.go @@ -49,3 +49,14 @@ func JsonToJS(inputJson []byte) (js.Value, error) { return js.ValueOf(jsObj), nil } + +// JsErrorToJson converts the Javascript error to JSON. This should be used for +// all Javascript error objects instead of JsonToJS. +func JsErrorToJson(value js.Value) string { + if value.IsUndefined() { + return "null" + } + + properties := Object.Call("getOwnPropertyNames", value) + return JSON.Call("stringify", value, properties).String() +} diff --git a/utils/convert_test.go b/utils/convert_test.go index 74bd9ca6c68e5e95639f413f2da0ab58b5faed31..508c27f783f1365f28b4b7b78b7389fc4d3ff1a6 100644 --- a/utils/convert_test.go +++ b/utils/convert_test.go @@ -241,3 +241,65 @@ func TestJsonToJSJsToJson(t *testing.T) { "\nexpected: %s\nreceived: %s", jsonData, jsJson) } } + +// Tests that JsErrorToJson can convert a Javascript object to JSON that matches +// the output of json.Marshal on the Go version of the same object. +func TestJsErrorToJson(t *testing.T) { + testObj := map[string]any{ + "nil": nil, + "bool": true, + "int": 1, + "float": 1.5, + "string": "I am string", + "array": []any{1, 2, 3}, + "object": map[string]any{"int": 5}, + } + + expected, err := json.Marshal(testObj) + if err != nil { + t.Errorf("Failed to JSON marshal test object: %+v", err) + } + + jsJson := JsErrorToJson(js.ValueOf(testObj)) + + // Javascript does not return the JSON object fields sorted so the letters + // of each Javascript string are sorted and compared + er := []rune(string(expected)) + sort.SliceStable(er, func(i, j int) bool { return er[i] < er[j] }) + jj := []rune(jsJson) + sort.SliceStable(jj, func(i, j int) bool { return jj[i] < jj[j] }) + + if string(er) != string(jj) { + t.Errorf("Recieved incorrect JSON from Javascript object."+ + "\nexpected: %s\nreceived: %s", expected, jsJson) + } +} + +// Tests that JsErrorToJson return a null object when the Javascript object is +// undefined. +func TestJsErrorToJson_Undefined(t *testing.T) { + expected, err := json.Marshal(nil) + if err != nil { + t.Errorf("Failed to JSON marshal test object: %+v", err) + } + + jsJson := JsErrorToJson(js.Undefined()) + + if string(expected) != jsJson { + t.Errorf("Recieved incorrect JSON from Javascript object."+ + "\nexpected: %s\nreceived: %s", expected, jsJson) + } +} + +// Tests that JsErrorToJson returns a JSON object containing the original error +// string. +func TestJsErrorToJson_ErrorObject(t *testing.T) { + expected := "An error" + jsErr := Error.New(expected) + jsJson := JsErrorToJson(jsErr) + + if !strings.Contains(jsJson, expected) { + t.Errorf("Recieved incorrect JSON from Javascript error."+ + "\nexpected: %s\nreceived: %s", expected, jsJson) + } +} diff --git a/wasm/channels.go b/wasm/channels.go index 2248b1b31d768b84982d893654e7d1b6f959cd5b..43ae72bdf4ad9b01441c20c800ca9bafbdcf7620 100644 --- a/wasm/channels.go +++ b/wasm/channels.go @@ -16,7 +16,7 @@ import ( "errors" "gitlab.com/elixxir/client/v4/channels" "gitlab.com/elixxir/crypto/message" - channelsDb "gitlab.com/elixxir/xxdk-wasm/indexedDb/channels" + channelsDb "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/channels" "gitlab.com/xx_network/primitives/id" "sync" "syscall/js" @@ -319,9 +319,10 @@ func LoadChannelsManager(_ js.Value, args []js.Value) any { // Parameters: // - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved // using [Cmix.GetID]. -// - args[1] - Bytes of a private identity ([channel.PrivateIdentity]) that is +// - args[1] - Path to Javascript file that starts the worker (string). +// - args[2] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[2] - The received message callback, which is called everytime a +// - args[3] - The received message callback, which is called everytime a // message is added or changed in the database. It is a function that takes // in the same parameters as [indexedDb.MessageReceivedCallback]. On the // Javascript side, the UUID is returned as an int and the channelID as a @@ -329,15 +330,15 @@ func LoadChannelsManager(_ js.Value, args []js.Value) any { // the UUID. The channel ID is provided so that the recipient can filter if // they want to the processes the update now or not. An "update" bool is // present which tells you if the row is new or if it is an edited old row. -// - args[3] - The deleted message callback, which is called everytime a +// - args[4] - The deleted message callback, which is called everytime a // message is deleted from the database. It is a function that takes in the // same parameters as [indexedDb.DeletedMessageCallback]. On the Javascript // side, the message ID is returned as a Uint8Array. -// - args[4] - The muted user callback, which is called everytime a user is +// - args[5] - The muted user callback, which is called everytime a user is // muted or unmuted. It is a function that takes in the same parameters as // [indexedDb.MutedUserCallback]. On the Javascript side, the channel ID and // user public key are returned as Uint8Array. -// - args[5] - ID of [ChannelDbCipher] object in tracker (int). Create this +// - args[6] - ID of [ChannelDbCipher] object in tracker (int). Create this // object with [NewChannelsDatabaseCipher] and get its id with // [ChannelDbCipher.GetID]. // @@ -347,18 +348,19 @@ func LoadChannelsManager(_ js.Value, args []js.Value) any { // - Throws a TypeError if the cipher ID does not correspond to a cipher. func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { cmixID := args[0].Int() - privateIdentity := utils.CopyBytesToGo(args[1]) - messageReceivedCB := args[2] - deletedMessageCB := args[3] - mutedUserCB := args[4] - cipherID := args[5].Int() + wasmJsPath := args[1].String() + privateIdentity := utils.CopyBytesToGo(args[2]) + messageReceivedCB := args[3] + deletedMessageCB := args[4] + mutedUserCB := args[5] + cipherID := args[6].Int() cipher, err := bindings.GetChannelDbCipherTrackerFromID(cipherID) if err != nil { utils.Throw(utils.TypeError, err) } - return newChannelsManagerWithIndexedDb(cmixID, privateIdentity, + return newChannelsManagerWithIndexedDb(cmixID, wasmJsPath, privateIdentity, messageReceivedCB, deletedMessageCB, mutedUserCB, cipher) } @@ -377,9 +379,10 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // Parameters: // - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved // using [Cmix.GetID]. -// - args[1] - Bytes of a private identity ([channel.PrivateIdentity]) that is +// - args[1] - Path to Javascript file that starts the worker (string). +// - args[2] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[2] - The received message callback, which is called everytime a +// - args[3] - The received message callback, which is called everytime a // message is added or changed in the database. It is a function that takes // in the same parameters as [indexedDb.MessageReceivedCallback]. On the // Javascript side, the UUID is returned as an int and the channelID as a @@ -387,11 +390,11 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // the UUID. The channel ID is provided so that the recipient can filter if // they want to the processes the update now or not. An "update" bool is // present which tells you if the row is new or if it is an edited old row. -// - args[3] - The deleted message callback, which is called everytime a +// - args[4] - The deleted message callback, which is called everytime a // message is deleted from the database. It is a function that takes in the // same parameters as [indexedDb.DeletedMessageCallback]. On the Javascript // side, the message ID is returned as a Uint8Array. -// - args[4] - The muted user callback, which is called everytime a user is +// - args[5] - The muted user callback, which is called everytime a user is // muted or unmuted. It is a function that takes in the same parameters as // [indexedDb.MutedUserCallback]. On the Javascript side, the channel ID and // user public key are returned as Uint8Array. @@ -399,19 +402,21 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // Returns a promise: // - Resolves to a Javascript representation of the [ChannelsManager] object. // - Rejected with an error if loading indexedDb or the manager fails. +// FIXME: package names in comments for indexedDb func NewChannelsManagerWithIndexedDbUnsafe(_ js.Value, args []js.Value) any { cmixID := args[0].Int() - privateIdentity := utils.CopyBytesToGo(args[1]) - messageReceivedCB := args[2] - deletedMessageCB := args[3] - mutedUserCB := args[4] + wasmJsPath := args[1].String() + privateIdentity := utils.CopyBytesToGo(args[2]) + messageReceivedCB := args[3] + deletedMessageCB := args[4] + mutedUserCB := args[5] - return newChannelsManagerWithIndexedDb(cmixID, privateIdentity, + return newChannelsManagerWithIndexedDb(cmixID, wasmJsPath, privateIdentity, messageReceivedCB, deletedMessageCB, mutedUserCB, nil) } -func newChannelsManagerWithIndexedDb(cmixID int, privateIdentity []byte, - messageReceivedCB, deletedMessageCB, mutedUserCB js.Value, +func newChannelsManagerWithIndexedDb(cmixID int, wasmJsPath string, + privateIdentity []byte, messageReceivedCB, deletedMessageCB, mutedUserCB js.Value, cipher *bindings.ChannelDbCipher) any { messageReceived := func(uuid uint64, channelID *id.ID, update bool) { @@ -428,7 +433,7 @@ func newChannelsManagerWithIndexedDb(cmixID int, privateIdentity []byte, } model := channelsDb.NewWASMEventModelBuilder( - cipher, messageReceived, deletedMessage, mutedUser) + wasmJsPath, cipher, messageReceived, deletedMessage, mutedUser) promiseFn := func(resolve, reject func(args ...any) js.Value) { cm, err := bindings.NewChannelsManagerGoEventModel( @@ -454,9 +459,10 @@ func newChannelsManagerWithIndexedDb(cmixID int, privateIdentity []byte, // Parameters: // - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved // using [Cmix.GetID]. -// - args[1] - The storage tag associated with the previously created channel +// - args[1] - Path to Javascript file that starts the worker (string). +// - args[2] - The storage tag associated with the previously created channel // manager and retrieved with [ChannelsManager.GetStorageTag] (string). -// - args[2] - The received message callback, which is called everytime a +// - args[3] - The received message callback, which is called everytime a // message is added or changed in the database. It is a function that takes // in the same parameters as [indexedDb.MessageReceivedCallback]. On the // Javascript side, the UUID is returned as an int and the channelID as a @@ -464,15 +470,15 @@ func newChannelsManagerWithIndexedDb(cmixID int, privateIdentity []byte, // the UUID. The channel ID is provided so that the recipient can filter if // they want to the processes the update now or not. An "update" bool is // present which tells you if the row is new or if it is an edited old row. -// - args[3] - The deleted message callback, which is called everytime a +// - args[4] - The deleted message callback, which is called everytime a // message is deleted from the database. It is a function that takes in the // same parameters as [indexedDb.DeletedMessageCallback]. On the Javascript // side, the message ID is returned as a Uint8Array. -// - args[4] - The muted user callback, which is called everytime a user is +// - args[5] - The muted user callback, which is called everytime a user is // muted or unmuted. It is a function that takes in the same parameters as // [indexedDb.MutedUserCallback]. On the Javascript side, the channel ID and // user public key are returned as Uint8Array. -// - args[5] - ID of [ChannelDbCipher] object in tracker (int). Create this +// - args[6] - ID of [ChannelDbCipher] object in tracker (int). Create this // object with [NewChannelsDatabaseCipher] and get its id with // [ChannelDbCipher.GetID]. // @@ -482,18 +488,19 @@ func newChannelsManagerWithIndexedDb(cmixID int, privateIdentity []byte, // - Throws a TypeError if the cipher ID does not correspond to a cipher. func LoadChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { cmixID := args[0].Int() - storageTag := args[1].String() - messageReceivedCB := args[2] - deletedMessageCB := args[3] - mutedUserCB := args[4] - cipherID := args[5].Int() + wasmJsPath := args[1].String() + storageTag := args[2].String() + messageReceivedCB := args[3] + deletedMessageCB := args[4] + mutedUserCB := args[5] + cipherID := args[6].Int() cipher, err := bindings.GetChannelDbCipherTrackerFromID(cipherID) if err != nil { utils.Throw(utils.TypeError, err) } - return loadChannelsManagerWithIndexedDb(cmixID, storageTag, + return loadChannelsManagerWithIndexedDb(cmixID, wasmJsPath, storageTag, messageReceivedCB, deletedMessageCB, mutedUserCB, cipher) } @@ -510,9 +517,10 @@ func LoadChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // Parameters: // - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved // using [Cmix.GetID]. -// - args[1] - The storage tag associated with the previously created channel +// - args[1] - Path to Javascript file that starts the worker (string). +// - args[2] - The storage tag associated with the previously created channel // manager and retrieved with [ChannelsManager.GetStorageTag] (string). -// - args[2] - The received message callback, which is called everytime a +// - args[3] - The received message callback, which is called everytime a // message is added or changed in the database. It is a function that takes // in the same parameters as [indexedDb.MessageReceivedCallback]. On the // Javascript side, the UUID is returned as an int and the channelID as a @@ -520,11 +528,11 @@ func LoadChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // the UUID. The channel ID is provided so that the recipient can filter if // they want to the processes the update now or not. An "update" bool is // present which tells you if the row is new or if it is an edited old row. -// - args[3] - The deleted message callback, which is called everytime a +// - args[4] - The deleted message callback, which is called everytime a // message is deleted from the database. It is a function that takes in the // same parameters as [indexedDb.DeletedMessageCallback]. On the Javascript // side, the message ID is returned as a Uint8Array. -// - args[4] - The muted user callback, which is called everytime a user is +// - args[5] - The muted user callback, which is called everytime a user is // muted or unmuted. It is a function that takes in the same parameters as // [indexedDb.MutedUserCallback]. On the Javascript side, the channel ID and // user public key are returned as Uint8Array. @@ -534,16 +542,17 @@ func LoadChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // - Rejected with an error if loading indexedDb or the manager fails. func LoadChannelsManagerWithIndexedDbUnsafe(_ js.Value, args []js.Value) any { cmixID := args[0].Int() - storageTag := args[1].String() - messageReceivedCB := args[2] + wasmJsPath := args[1].String() + storageTag := args[2].String() + messageReceivedCB := args[3] deletedMessageCB := args[3] mutedUserCB := args[4] - return loadChannelsManagerWithIndexedDb(cmixID, storageTag, + return loadChannelsManagerWithIndexedDb(cmixID, wasmJsPath, storageTag, messageReceivedCB, deletedMessageCB, mutedUserCB, nil) } -func loadChannelsManagerWithIndexedDb(cmixID int, storageTag string, +func loadChannelsManagerWithIndexedDb(cmixID int, wasmJsPath, storageTag string, messageReceivedCB, deletedMessageCB, mutedUserCB js.Value, cipher *bindings.ChannelDbCipher) any { @@ -561,7 +570,7 @@ func loadChannelsManagerWithIndexedDb(cmixID int, storageTag string, } model := channelsDb.NewWASMEventModelBuilder( - cipher, messageReceived, deletedMessage, mutedUser) + wasmJsPath, cipher, messageReceived, deletedMessage, mutedUser) promiseFn := func(resolve, reject func(args ...any) js.Value) { cm, err := bindings.LoadChannelsManagerGoEventModel( diff --git a/wasm/dm.go b/wasm/dm.go index 678ca74f529f1ccce1a0a8cac63242b5073ed835..04696435bce630ae9148db8bab580301dfdedb5f 100644 --- a/wasm/dm.go +++ b/wasm/dm.go @@ -13,7 +13,7 @@ import ( "crypto/ed25519" "syscall/js" - indexDB "gitlab.com/elixxir/xxdk-wasm/indexedDb/dm" + indexDB "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/dm" "encoding/base64" @@ -129,7 +129,7 @@ func NewDMClient(_ js.Value, args []js.Value) any { } // NewDMClientWithIndexedDb creates a new [DMClient] from a new -// private identity ([channel.PrivateIdentity]) and using indexedDb as a backend +// private identity ([channel.PrivateIdentity]) and using indexedDbWorker as a backend // to manage the event model. // // This is for creating a manager for an identity for the first time. For @@ -137,33 +137,35 @@ func NewDMClient(_ js.Value, args []js.Value) any { // reload this channel manager, use [LoadDMClientWithIndexedDb], passing // in the storage tag retrieved by [DMClient.GetStorageTag]. // -// This function initialises an indexedDb database. +// This function initialises an indexedDbWorker database. // // Parameters: // - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved // using [Cmix.GetID]. -// - args[1] - Bytes of a private identity ([channel.PrivateIdentity]) that is +// - args[1] - Path to Javascript file that starts the worker (string). +// - args[2] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[2] - Function that takes in the same parameters as -// [indexedDb.MessageReceivedCallback]. On the Javascript side, the UUID is +// - args[3] - Function that takes in the same parameters as +// [indexedDbWorker.MessageReceivedCallback]. On the Javascript side, the UUID is // returned as an int and the channelID as a Uint8Array. The row in the // database that was updated can be found using the UUID. The channel ID is // provided so that the recipient can filter if they want to the processes // the update now or not. An "update" bool is present which tells you if the // row is new or if it is an edited old row. -// - args[3] - ID of [ChannelDbCipher] object in tracker (int). Create this +// - args[4] - ID of [ChannelDbCipher] object in tracker (int). Create this // object with [NewChannelsDatabaseCipher] and get its id with // [ChannelDbCipher.GetID]. // // Returns a promise: // - Resolves to a Javascript representation of the [DMClient] object. -// - Rejected with an error if loading indexedDb or the manager fails. +// - Rejected with an error if loading indexedDbWorker or the manager fails. // - Throws a TypeError if the cipher ID does not correspond to a cipher. func NewDMClientWithIndexedDb(_ js.Value, args []js.Value) any { cmixID := args[0].Int() - privateIdentity := utils.CopyBytesToGo(args[1]) - messageReceivedCB := args[2] - cipherID := args[3].Int() + wasmJsPath := args[1].String() + privateIdentity := utils.CopyBytesToGo(args[2]) + messageReceivedCB := args[3] + cipherID := args[4].Int() cipher, err := bindings.GetChannelDbCipherTrackerFromID(cipherID) if err != nil { @@ -171,11 +173,11 @@ func NewDMClientWithIndexedDb(_ js.Value, args []js.Value) any { } return newDMClientWithIndexedDb( - cmixID, privateIdentity, messageReceivedCB, cipher) + cmixID, wasmJsPath, privateIdentity, messageReceivedCB, cipher) } // NewDMClientWithIndexedDbUnsafe creates a new [DMClient] from a -// new private identity ([channel.PrivateIdentity]) and using indexedDb as a +// new private identity ([channel.PrivateIdentity]) and using indexedDbWorker as a // backend to manage the event model. However, the data is written in plain text // and not encrypted. It is recommended that you do not use this in production. // @@ -184,15 +186,16 @@ func NewDMClientWithIndexedDb(_ js.Value, args []js.Value) any { // reload this channel manager, use [LoadDMClientWithIndexedDbUnsafe], // passing in the storage tag retrieved by [DMClient.GetStorageTag]. // -// This function initialises an indexedDb database. +// This function initialises an indexedDbWorker database. // // Parameters: // - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved // using [Cmix.GetID]. -// - args[1] - Bytes of a private identity ([channel.PrivateIdentity]) that is +// - args[1] - Path to Javascript file that starts the worker (string). +// - args[2] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[2] - Function that takes in the same parameters as -// [indexedDb.MessageReceivedCallback]. On the Javascript side, the UUID is +// - args[3] - Function that takes in the same parameters as +// [indexedDbWorker.MessageReceivedCallback]. On the Javascript side, the UUID is // returned as an int and the channelID as a Uint8Array. The row in the // database that was updated can be found using the UUID. The channel ID is // provided so that the recipient can filter if they want to the processes @@ -201,18 +204,19 @@ func NewDMClientWithIndexedDb(_ js.Value, args []js.Value) any { // // Returns a promise: // - Resolves to a Javascript representation of the [DMClient] object. -// - Rejected with an error if loading indexedDb or the manager fails. +// - Rejected with an error if loading indexedDbWorker or the manager fails. func NewDMClientWithIndexedDbUnsafe(_ js.Value, args []js.Value) any { cmixID := args[0].Int() - privateIdentity := utils.CopyBytesToGo(args[1]) - messageReceivedCB := args[2] + wasmJsPath := args[1].String() + privateIdentity := utils.CopyBytesToGo(args[2]) + messageReceivedCB := args[3] return newDMClientWithIndexedDb( - cmixID, privateIdentity, messageReceivedCB, nil) + cmixID, wasmJsPath, privateIdentity, messageReceivedCB, nil) } -func newDMClientWithIndexedDb(cmixID int, privateIdentity []byte, - cb js.Value, cipher *bindings.ChannelDbCipher) any { +func newDMClientWithIndexedDb(cmixID int, wasmJsPath string, + privateIdentity []byte, cb js.Value, cipher *bindings.ChannelDbCipher) any { messageReceivedCB := func(uuid uint64, pubKey ed25519.PublicKey, update bool) { @@ -226,8 +230,8 @@ func newDMClientWithIndexedDb(cmixID int, privateIdentity []byte, reject(utils.JsTrace(err)) } dmPath := base64.RawStdEncoding.EncodeToString(pi.PubKey[:]) - model, err := indexDB.NewWASMEventModel(dmPath, cipher, - messageReceivedCB) + model, err := indexDB.NewWASMEventModel( + dmPath, wasmJsPath, cipher, messageReceivedCB) if err != nil { reject(utils.JsTrace(err)) } diff --git a/wasm/logging.go b/wasm/logging.go index 46358b226f0549e1f05f3bb74c08f66a81a8198e..8199edc02a829a8e8b81b59dbfc73cddb4d46857 100644 --- a/wasm/logging.go +++ b/wasm/logging.go @@ -10,21 +10,11 @@ package wasm import ( - "fmt" - "github.com/armon/circbuf" - "github.com/pkg/errors" - jww "github.com/spf13/jwalterweatherman" "gitlab.com/elixxir/client/v4/bindings" - "gitlab.com/elixxir/xxdk-wasm/utils" - "io" - "log" + "gitlab.com/elixxir/xxdk-wasm/logging" "syscall/js" ) -// logListeners is a list of all registered log listeners. This is used to add -// additional log listener without overwriting previously registered listeners. -var logListeners []jww.LogListener - // LogLevel sets level of logging. All logs at the set level and below will be // displayed (e.g., when log level is ERROR, only ERROR, CRITICAL, and FATAL // messages will be printed). @@ -46,91 +36,8 @@ var logListeners []jww.LogListener // // Returns: // - Throws TypeError if the log level is invalid. -func LogLevel(_ js.Value, args []js.Value) any { - threshold := jww.Threshold(args[0].Int()) - if threshold < jww.LevelTrace || threshold > jww.LevelFatal { - err := errors.Errorf("log level is not valid: log level: %d", threshold) - utils.Throw(utils.TypeError, err) - return nil - } - - jww.SetLogThreshold(threshold) - jww.SetFlags(log.LstdFlags | log.Lmicroseconds) - - ll := NewJsConsoleLogListener(threshold) - logListeners = append(logListeners, ll.Listen) - jww.SetLogListeners(logListeners...) - jww.SetStdoutThreshold(jww.LevelFatal + 1) - - msg := fmt.Sprintf("Log level set to: %s", threshold) - switch threshold { - case jww.LevelTrace: - fallthrough - case jww.LevelDebug: - fallthrough - case jww.LevelInfo: - jww.INFO.Print(msg) - case jww.LevelWarn: - jww.WARN.Print(msg) - case jww.LevelError: - jww.ERROR.Print(msg) - case jww.LevelCritical: - jww.CRITICAL.Print(msg) - case jww.LevelFatal: - jww.FATAL.Print(msg) - } - - return nil -} - -// LogToFile enables logging to a file that can be downloaded. -// -// Parameters: -// - args[0] - Log level (int). -// - args[1] - Log file name (string). -// - args[2] - Max log file size, in bytes (int). -// -// Returns: -// - A Javascript representation of the [LogFile] object, which allows -// accessing the contents of the log file and other metadata. -func LogToFile(_ js.Value, args []js.Value) any { - threshold := jww.Threshold(args[0].Int()) - if threshold < jww.LevelTrace || threshold > jww.LevelFatal { - err := errors.Errorf("log level is not valid: log level: %d", threshold) - utils.Throw(utils.TypeError, err) - return nil - } - - // Create new log file output - ll, err := NewLogFile(args[1].String(), threshold, args[2].Int()) - if err != nil { - utils.Throw(utils.TypeError, err) - return nil - } - - logListeners = append(logListeners, ll.Listen) - jww.SetLogListeners(logListeners...) - - msg := fmt.Sprintf("Outputting log to file %s of max size %d with level %s", - ll.name, ll.b.Size(), threshold) - switch threshold { - case jww.LevelTrace: - fallthrough - case jww.LevelDebug: - fallthrough - case jww.LevelInfo: - jww.INFO.Print(msg) - case jww.LevelWarn: - jww.WARN.Print(msg) - case jww.LevelError: - jww.ERROR.Print(msg) - case jww.LevelCritical: - jww.CRITICAL.Print(msg) - case jww.LevelFatal: - jww.FATAL.Print(msg) - } - - return newLogFileJS(ll) +func LogLevel(this js.Value, args []js.Value) any { + return logging.LogLevelJS(this, args) } // logWriter wraps Javascript callbacks to adhere to the [bindings.LogWriter] @@ -164,174 +71,3 @@ func EnableGrpcLogs(_ js.Value, args []js.Value) any { bindings.EnableGrpcLogs(&logWriter{args[0].Invoke}) return nil } - -//////////////////////////////////////////////////////////////////////////////// -// Javascript Console Log Listener // -//////////////////////////////////////////////////////////////////////////////// - -// console contains the Javascript console object, which provides access to the -// browser's debugging console. This structure detects logging types and prints -// it using the correct logging method. -type console struct { - call string - js.Value -} - -// Write writes the data to the Javascript console at the level specified by the -// call. -func (c *console) Write(p []byte) (n int, err error) { - c.Call(c.call, string(p)) - return len(p), nil -} - -// JsConsoleLogListener redirects log output to the Javascript console. -type JsConsoleLogListener struct { - jww.Threshold - js.Value - - trace *console - debug *console - info *console - error *console - warn *console - critical *console - fatal *console - def *console -} - -// NewJsConsoleLogListener initialises a new log listener that listener for the -// specific threshold and prints the logs to the Javascript console. -func NewJsConsoleLogListener(threshold jww.Threshold) *JsConsoleLogListener { - consoleObj := js.Global().Get("console") - return &JsConsoleLogListener{ - Threshold: threshold, - Value: consoleObj, - trace: &console{"debug", consoleObj}, - debug: &console{"log", consoleObj}, - info: &console{"info", consoleObj}, - warn: &console{"warn", consoleObj}, - error: &console{"error", consoleObj}, - critical: &console{"error", consoleObj}, - fatal: &console{"error", consoleObj}, - def: &console{"log", consoleObj}, - } -} - -// Listen is called for every logging event. This function adheres to the -// [jwalterweatherman.LogListener] type. -func (ll *JsConsoleLogListener) Listen(t jww.Threshold) io.Writer { - if t < ll.Threshold { - return nil - } - - switch t { - case jww.LevelTrace: - return ll.trace - case jww.LevelDebug: - return ll.debug - case jww.LevelInfo: - return ll.info - case jww.LevelWarn: - return ll.warn - case jww.LevelError: - return ll.error - case jww.LevelCritical: - return ll.critical - case jww.LevelFatal: - return ll.fatal - default: - return ll.def - } -} - -//////////////////////////////////////////////////////////////////////////////// -// Log File Log Listener // -//////////////////////////////////////////////////////////////////////////////// - -// LogFile represents a virtual log file in memory. It contains a circular -// buffer that limits the log file, overwriting the oldest logs. -type LogFile struct { - name string - threshold jww.Threshold - b *circbuf.Buffer -} - -// NewLogFile initialises a new [LogFile] for log writing. -func NewLogFile( - name string, threshold jww.Threshold, maxSize int) (*LogFile, error) { - // Create new buffer of the specified size - b, err := circbuf.NewBuffer(int64(maxSize)) - if err != nil { - return nil, err - } - - return &LogFile{ - name: name, - threshold: threshold, - b: b, - }, nil -} - -// newLogFileJS creates a new Javascript compatible object (map[string]any) that -// matches the [LogFile] structure. -func newLogFileJS(lf *LogFile) map[string]any { - logFile := map[string]any{ - "Name": js.FuncOf(lf.Name), - "Threshold": js.FuncOf(lf.Threshold), - "GetFile": js.FuncOf(lf.GetFile), - "MaxSize": js.FuncOf(lf.MaxSize), - "Size": js.FuncOf(lf.Size), - } - - return logFile -} - -// Listen is called for every logging event. This function adheres to the -// [jwalterweatherman.LogListener] type. -func (lf *LogFile) Listen(t jww.Threshold) io.Writer { - if t < lf.threshold { - return nil - } - - return lf.b -} - -// Name returns the name of the log file. -// -// Returns: -// - File name (string). -func (lf *LogFile) Name(js.Value, []js.Value) any { - return lf.name -} - -// Threshold returns the log level threshold used in the file. -// -// Returns: -// - Log level (string). -func (lf *LogFile) Threshold(js.Value, []js.Value) any { - return lf.threshold.String() -} - -// GetFile returns the entire log file. -// -// Returns: -// - Log file contents (string). -func (lf *LogFile) GetFile(js.Value, []js.Value) any { - return string(lf.b.Bytes()) -} - -// MaxSize returns the max size, in bytes, that the log file is allowed to be. -// -// Returns: -// - Max file size (int). -func (lf *LogFile) MaxSize(js.Value, []js.Value) any { - return lf.b.Size() -} - -// Size returns the current size, in bytes, written to the log file. -// -// Returns: -// - Current file size (int). -func (lf *LogFile) Size(js.Value, []js.Value) any { - return lf.b.TotalWritten() -} diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e5027c3fe7d366d5096bb9b09406ea6ab6edaa2f --- /dev/null +++ b/worker/README.md @@ -0,0 +1,54 @@ +# Web Worker API + +This package allows you to create +a [Javascript Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) +from WASM and facilitates communication between the web worker and the main +thread using a messaging system. + +## Creating a Worker + +Web workers have two sides. There is the main side that creates the worker +thread and the thread itself. The thread needs to be compiled into its own WASM +binary and must have a corresponding Javascript file to launch it. + +Example `main.go`: + +```go +package main + +import ( + "fmt" + "gitlab.com/elixxir/xxdk-wasm/utils/worker" +) + +func main() { + fmt.Println("Starting WebAssembly Worker.") + tm := worker.NewThreadManager("exampleWebWorker") + tm.SignalReady() + <-make(chan bool) +} +``` + +Example WASM start file: + +```javascript +importScripts('wasm_exec.js'); + +const go = new Go(); +const binPath = 'xxdk-exampleWebWorker.wasm' +WebAssembly.instantiateStreaming(fetch(binPath), go.importObject).then((result) => { + go.run(result.instance); +}).catch((err) => { + console.error(err); +}); +``` + +To start the worker, call `worker.NewManager` with the Javascript file to launch +the worker. + +```go +wm, err := worker.NewManager("workerWasm.js", "exampleWebWorker") +if err != nil { + return nil, err +} +``` \ No newline at end of file diff --git a/worker/manager.go b/worker/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..1a28ac2f99e803b902cdac1e1696ec031d42ca48 --- /dev/null +++ b/worker/manager.go @@ -0,0 +1,340 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package worker + +import ( + "encoding/json" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/utils" + "sync" + "syscall/js" + "time" +) + +// initID is the ID for the first item in the callback list. If the list only +// contains one callback, then this is the ID of that callback. If the list has +// autogenerated unique IDs, this is the initial ID to start at. +const initID = uint64(0) + +// Response timeouts. +const ( + // workerInitialConnectionTimeout is the time to wait to receive initial + // contact from a new worker before timing out. + workerInitialConnectionTimeout = 90 * time.Second + + // ResponseTimeout is the general time to wait after sending a message to + // receive a response before timing out. + ResponseTimeout = 30 * time.Second +) + +// ReceptionCallback is the function that handles incoming data from the worker. +type ReceptionCallback func(data []byte) + +// Manager manages the handling of messages received from the worker. +type Manager struct { + // worker is the Worker Javascript object. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker + worker js.Value + + // callbacks are a list of ReceptionCallback that handle a specific message + // received from the worker. Each callback is keyed on a tag specifying how + // the received message should be handled. If the message is a reply to a + // message sent to the worker, then the callback is also keyed on a unique + // ID. If the message is not a reply, then it appears on initID. + callbacks map[Tag]map[uint64]ReceptionCallback + + // responseIDs is a list of the newest ID to assign to each callback when + // registered. The IDs are used to connect a reply from the worker to the + // original message sent by the main thread. + responseIDs map[Tag]uint64 + + // name describes the worker. It is used for debugging and logging purposes. + name string + + // messageLogging determines if debug message logs should be printed every + // time a message is sent/received to/from the worker. + messageLogging bool + + mux sync.Mutex +} + +// NewManager generates a new Manager. This functions will only return once +// communication with the worker has been established. +func NewManager(aURL, name string, messageLogging bool) (*Manager, error) { + // Create new worker options with the given name + opts := newWorkerOptions("", "", name) + + m := &Manager{ + worker: js.Global().Get("Worker").New(aURL, opts), + callbacks: make(map[Tag]map[uint64]ReceptionCallback), + responseIDs: make(map[Tag]uint64), + name: name, + messageLogging: messageLogging, + } + + // Register listeners on the Javascript worker object that receive messages + // and errors from the worker + m.addEventListeners() + + // Register a callback that will receive initial message from worker + // indicating that it is ready + ready := make(chan struct{}) + m.RegisterCallback(readyTag, func([]byte) { ready <- struct{}{} }) + + // Wait for the ready signal from the worker + select { + case <-ready: + case <-time.After(workerInitialConnectionTimeout): + return nil, errors.Errorf("[WW] [%s] timed out after %s waiting for "+ + "initial message from worker", + m.name, workerInitialConnectionTimeout) + } + + return m, nil +} + +// SendMessage sends a message to the worker with the given tag. If a reception +// callback is specified, then the message is given a unique ID to handle the +// reply. Set receptionCB to nil if no reply is expected. +func (m *Manager) SendMessage( + tag Tag, data []byte, receptionCB ReceptionCallback) { + var id uint64 + if receptionCB != nil { + id = m.registerReplyCallback(tag, receptionCB) + } + + if m.messageLogging { + jww.DEBUG.Printf("[WW] [%s] Main sending message for %q and ID %d "+ + "with data: %s", m.name, tag, id, data) + } + + msg := Message{ + Tag: tag, + ID: id, + Data: data, + } + payload, err := json.Marshal(msg) + if err != nil { + jww.FATAL.Panicf("[WW] [%s] Main failed to marshal %T for %q and "+ + "ID %d going to worker: %+v", m.name, msg, tag, id, err) + } + + go m.postMessage(string(payload)) +} + +// receiveMessage is registered with the Javascript event listener and is called +// every time a new message from the worker is received. +func (m *Manager) receiveMessage(data []byte) error { + var msg Message + err := json.Unmarshal(data, &msg) + if err != nil { + return err + } + + if m.messageLogging { + jww.DEBUG.Printf("[WW] [%s] Main received message for %q and ID %d "+ + "with data: %s", m.name, msg.Tag, msg.ID, msg.Data) + } + + callback, err := m.getCallback(msg.Tag, msg.ID, msg.DeleteCB) + if err != nil { + return err + } + + go callback(msg.Data) + + return nil +} + +// getCallback returns the callback for the given ID or returns an error if no +// callback is found. The callback is deleted from the map if specified in the +// message. This function is thread safe. +func (m *Manager) getCallback( + tag Tag, id uint64, deleteCB bool) (ReceptionCallback, error) { + m.mux.Lock() + defer m.mux.Unlock() + callbacks, exists := m.callbacks[tag] + if !exists { + return nil, errors.Errorf("no callbacks found for tag %q", tag) + } + + callback, exists := callbacks[id] + if !exists { + return nil, errors.Errorf("no %q callback found for ID %d", tag, id) + } + + if deleteCB { + delete(m.callbacks[tag], id) + if len(m.callbacks[tag]) == 0 { + delete(m.callbacks, tag) + } + } + + return callback, nil +} + +// RegisterCallback registers the reception callback for the given tag. If a +// previous callback was registered, it is overwritten. This function is thread +// safe. +func (m *Manager) RegisterCallback(tag Tag, receptionCB ReceptionCallback) { + m.mux.Lock() + defer m.mux.Unlock() + + id := initID + + jww.DEBUG.Printf("[WW] [%s] Main registering callback for tag %q and ID %d", + m.name, tag, id) + + m.callbacks[tag] = map[uint64]ReceptionCallback{id: receptionCB} +} + +// RegisterCallback registers the reception callback for the given tag and a new +// unique ID used to associate the reply to the callback. Returns the ID that +// was registered. If a previous callback was registered, it is overwritten. +// This function is thread safe. +func (m *Manager) registerReplyCallback( + tag Tag, receptionCB ReceptionCallback) uint64 { + m.mux.Lock() + defer m.mux.Unlock() + id := m.getNextID(tag) + + jww.DEBUG.Printf("[WW] [%s] Main registering callback for tag %q and ID %d", + m.name, tag, id) + + if _, exists := m.callbacks[tag]; !exists { + m.callbacks[tag] = make(map[uint64]ReceptionCallback) + } + m.callbacks[tag][id] = receptionCB + + return id +} + +// getNextID returns the next unique ID for the given tag. This function is not +// thread-safe. +func (m *Manager) getNextID(tag Tag) uint64 { + if _, exists := m.responseIDs[tag]; !exists { + m.responseIDs[tag] = initID + } + + id := m.responseIDs[tag] + m.responseIDs[tag]++ + return id +} + +// GetWorker returns the web worker object. This returned so the worker object +// can be returned to the Javascript layer for it to communicate with the worker +// thread. +func (m *Manager) GetWorker() js.Value { return m.worker } + +// Name returns the name of the web worker object. +func (m *Manager) Name() string { return m.name } + +//////////////////////////////////////////////////////////////////////////////// +// Javascript Call Wrappers // +//////////////////////////////////////////////////////////////////////////////// + +// addEventListeners adds the event listeners needed to receive a message from +// the worker. Two listeners were added; one to receive messages from the worker +// and the other to receive errors. +func (m *Manager) addEventListeners() { + // Create a listener for when the message event is fired on the worker. This + // occurs when a message is received from the worker. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/message_event + messageEvent := js.FuncOf(func(_ js.Value, args []js.Value) any { + err := m.receiveMessage([]byte(args[0].Get("data").String())) + if err != nil { + jww.ERROR.Printf("[WW] [%s] Failed to receive message from "+ + "worker: %+v", m.name, err) + } + return nil + }) + + // Create listener for when an error event is fired on the worker. This + // occurs when an error occurs in the worker. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/error_event + errorEvent := js.FuncOf(func(_ js.Value, args []js.Value) any { + event := args[0] + jww.ERROR.Printf("[WW] [%s] Main received error event: %s", + m.name, utils.JsErrorToJson(event)) + return nil + }) + + // Create listener for when a messageerror event is fired on the worker. + // This occurs when it receives a message that cannot be deserialized. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/messageerror_event + messageerrorEvent := js.FuncOf(func(_ js.Value, args []js.Value) any { + event := args[0] + jww.ERROR.Printf("[WW] [%s] Main received message error event: %s", + m.name, utils.JsErrorToJson(event)) + return nil + }) + + // Register each event listener on the worker using addEventListener + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + m.worker.Call("addEventListener", "message", messageEvent) + m.worker.Call("addEventListener", "error", errorEvent) + m.worker.Call("addEventListener", "messageerror", messageerrorEvent) +} + +// postMessage sends a message to the worker. +// +// message is the object to deliver to the worker; this will be in the data +// field in the event delivered to the worker. It must be a js.Value or a +// primitive type that can be converted via js.ValueOf. The Javascript object +// must be "any value or JavaScript object handled by the structured clone +// algorithm, which includes cyclical references.". See the doc for more +// information. +// +// If the message parameter is not provided, a SyntaxError will be thrown by the +// parser. If the data to be passed to the worker is unimportant, js.Null or +// js.Undefined can be passed explicitly. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage +func (m *Manager) postMessage(msg any) { + m.worker.Call("postMessage", msg) +} + +// Terminate immediately terminates the Worker. This does not offer the worker +// an opportunity to finish its operations; it is stopped at once. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate +func (m *Manager) Terminate() { + m.worker.Call("terminate") +} + +// newWorkerOptions creates a new Javascript object containing optional +// properties that can be set when creating a new worker. +// +// Each property is optional; leave a property empty to use the defaults (as +// documented). The available properties are: +// - type - The type of worker to create. The value can be either "classic" or +// "module". If not specified, the default used is "classic". +// - credentials - The type of credentials to use for the worker. The value +// can be "omit", "same-origin", or "include". If it is not specified, or if +// the type is "classic", then the default used is "omit" (no credentials +// are required). +// - name - An identifying name for the worker, used mainly for debugging +// purposes. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker#options +func newWorkerOptions(workerType, credentials, name string) js.Value { + options := make(map[string]any, 3) + if workerType != "" { + options["type"] = workerType + } + if credentials != "" { + options["credentials"] = credentials + } + if name != "" { + options["name"] = name + } + return js.ValueOf(options) +} diff --git a/worker/manager_test.go b/worker/manager_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2eadaff2edeaf66a4071f387f608a602c6967b8d --- /dev/null +++ b/worker/manager_test.go @@ -0,0 +1,212 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package worker + +import ( + "encoding/json" + "reflect" + "testing" + "time" +) + +// Tests Manager.receiveMessage calls the expected callback. +func TestManager_receiveMessage(t *testing.T) { + m := &Manager{callbacks: make(map[Tag]map[uint64]ReceptionCallback)} + + msg := Message{Tag: readyTag, ID: 5} + cbChan := make(chan struct{}) + cb := func([]byte) { cbChan <- struct{}{} } + m.callbacks[msg.Tag] = map[uint64]ReceptionCallback{msg.ID: cb} + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Failed to JSON marshal Message: %+v", err) + } + + go func() { + select { + case <-cbChan: + case <-time.After(10 * time.Millisecond): + t.Error("Timed out waiting for callback to be called.") + } + }() + + err = m.receiveMessage(data) + if err != nil { + t.Errorf("Failed to receive message: %+v", err) + } +} + +// Tests Manager.getCallback returns the expected callback and deletes only the +// given callback when deleteCB is true. +func TestManager_getCallback(t *testing.T) { + m := &Manager{callbacks: make(map[Tag]map[uint64]ReceptionCallback)} + + // Add new callback and check that it is returned by getCallback + tag, id1 := readyTag, uint64(5) + cb := func([]byte) {} + m.callbacks[tag] = map[uint64]ReceptionCallback{id1: cb} + + received, err := m.getCallback(tag, id1, false) + if err != nil { + t.Errorf("getCallback error for tag %q and ID %d: %+v", tag, id1, err) + } + + if reflect.ValueOf(cb).Pointer() != reflect.ValueOf(received).Pointer() { + t.Errorf("Wrong callback.\nexpected: %p\nreceived: %p", cb, received) + } + + // Add new callback under the same tag but with deleteCB set to true and + // check that it is returned by getCallback and that it was deleted from the + // map while id1 was not + id2 := uint64(56) + cb = func([]byte) {} + m.callbacks[tag][id2] = cb + + received, err = m.getCallback(tag, id2, true) + if err != nil { + t.Errorf("getCallback error for tag %q and ID %d: %+v", tag, id2, err) + } + + if reflect.ValueOf(cb).Pointer() != reflect.ValueOf(received).Pointer() { + t.Errorf("Wrong callback.\nexpected: %p\nreceived: %p", cb, received) + } + + received, err = m.getCallback(tag, id1, false) + if err != nil { + t.Errorf("getCallback error for tag %q and ID %d: %+v", tag, id1, err) + } + + received, err = m.getCallback(tag, id2, true) + if err == nil { + t.Errorf("getCallback did not get error when trying to get deleted "+ + "callback for tag %q and ID %d", tag, id2) + } +} + +// Tests that Manager.RegisterCallback registers a callback that is then called +// by Manager.receiveMessage. +func TestManager_RegisterCallback(t *testing.T) { + m := &Manager{callbacks: make(map[Tag]map[uint64]ReceptionCallback)} + + msg := Message{Tag: readyTag, ID: initID} + cbChan := make(chan struct{}) + cb := func([]byte) { cbChan <- struct{}{} } + m.RegisterCallback(msg.Tag, cb) + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Failed to JSON marshal Message: %+v", err) + } + + go func() { + select { + case <-cbChan: + case <-time.After(10 * time.Millisecond): + t.Error("Timed out waiting for callback to be called.") + } + }() + + err = m.receiveMessage(data) + if err != nil { + t.Errorf("Failed to receive message: %+v", err) + } +} + +// Tests that Manager.registerReplyCallback registers a callback that is then +// called by Manager.receiveMessage. +func TestManager_registerReplyCallback(t *testing.T) { + m := &Manager{ + callbacks: make(map[Tag]map[uint64]ReceptionCallback), + responseIDs: make(map[Tag]uint64), + } + + msg := Message{Tag: readyTag, ID: 5} + cbChan := make(chan struct{}) + cb := func([]byte) { cbChan <- struct{}{} } + m.registerReplyCallback(msg.Tag, cb) + m.callbacks[msg.Tag] = map[uint64]ReceptionCallback{msg.ID: cb} + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Failed to JSON marshal Message: %+v", err) + } + + go func() { + select { + case <-cbChan: + case <-time.After(10 * time.Millisecond): + t.Error("Timed out waiting for callback to be called.") + } + }() + + err = m.receiveMessage(data) + if err != nil { + t.Errorf("Failed to receive message: %+v", err) + } +} + +// Tests that Manager.getNextID returns the expected ID for various Tags. +func TestManager_getNextID(t *testing.T) { + m := &Manager{ + callbacks: make(map[Tag]map[uint64]ReceptionCallback), + responseIDs: make(map[Tag]uint64), + } + + for _, tag := range []Tag{readyTag, "test", "A", "B", "C"} { + id := m.getNextID(tag) + if id != initID { + t.Errorf("ID for new tag %q is not initID."+ + "\nexpected: %d\nreceived: %d", tag, initID, id) + } + + for j := uint64(1); j < 100; j++ { + id = m.getNextID(tag) + if id != j { + t.Errorf("Unexpected ID for tag %q."+ + "\nexpected: %d\nreceived: %d", tag, j, id) + } + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Javascript Call Wrappers // +//////////////////////////////////////////////////////////////////////////////// + +// Tests that newWorkerOptions returns a Javascript object with the expected +// type, credentials, and name fields. +func Test_newWorkerOptions(t *testing.T) { + for i, workerType := range []string{"classic", "module"} { + for j, credentials := range []string{"omit", "same-origin", "include"} { + for k, name := range []string{"name1", "name2", "name3"} { + opts := newWorkerOptions(workerType, credentials, name) + + v := opts.Get("type").String() + if v != workerType { + t.Errorf("Unexpected type (%d, %d, %d)."+ + "\nexpected: %s\nreceived: %s", i, j, k, workerType, v) + } + + v = opts.Get("credentials").String() + if v != credentials { + t.Errorf("Unexpected credentials (%d, %d, %d)."+ + "\nexpected: %s\nreceived: %s", i, j, k, credentials, v) + } + + v = opts.Get("name").String() + if v != name { + t.Errorf("Unexpected name (%d, %d, %d)."+ + "\nexpected: %s\nreceived: %s", i, j, k, name, v) + } + } + } + } +} diff --git a/worker/message.go b/worker/message.go new file mode 100644 index 0000000000000000000000000000000000000000..3d3bc23f131ef96cdc97a3e8684d1386b07cbeb2 --- /dev/null +++ b/worker/message.go @@ -0,0 +1,19 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package worker + +// Message is the outer message that contains the contents of each message sent +// to the worker. It is transmitted as JSON. +type Message struct { + Tag Tag `json:"tag"` + ID uint64 `json:"id"` + DeleteCB bool `json:"deleteCB"` + Data []byte `json:"data"` +} diff --git a/worker/tag.go b/worker/tag.go new file mode 100644 index 0000000000000000000000000000000000000000..fa458c291802c47d0ffb1970ef75202b936561a4 --- /dev/null +++ b/worker/tag.go @@ -0,0 +1,18 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package worker + +// Tag describes how a message sent to or from the worker should be handled. +type Tag string + +// Generic tags used by all workers. +const ( + readyTag Tag = "Ready" +) diff --git a/worker/thread.go b/worker/thread.go new file mode 100644 index 0000000000000000000000000000000000000000..df824d045f4337c6384eac389f692b15bec200a8 --- /dev/null +++ b/worker/thread.go @@ -0,0 +1,220 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package worker + +import ( + "encoding/json" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/utils" + "sync" + "syscall/js" +) + +// ThreadReceptionCallback is the function that handles incoming data from the +// main thread. +type ThreadReceptionCallback func(data []byte) ([]byte, error) + +// ThreadManager queues incoming messages from the main thread and handles them +// based on their tag. +type ThreadManager struct { + // messages is a list of queued messages sent from the main thread. + messages chan js.Value + + // callbacks is a list of callbacks to handle messages that come from the + // main thread keyed on the callback tag. + callbacks map[Tag]ThreadReceptionCallback + + // name describes the worker. It is used for debugging and logging purposes. + name string + + // messageLogging determines if debug message logs should be printed every + // time a message is sent/received to/from the worker. + messageLogging bool + + mux sync.Mutex +} + +// NewThreadManager initialises a new ThreadManager. +func NewThreadManager(name string, messageLogging bool) *ThreadManager { + mh := &ThreadManager{ + messages: make(chan js.Value, 100), + callbacks: make(map[Tag]ThreadReceptionCallback), + name: name, + messageLogging: messageLogging, + } + + mh.addEventListeners() + + return mh +} + +// SignalReady sends a signal to the main thread indicating that the worker is +// ready. Once the main thread receives this, it will initiate communication. +// Therefore, this should only be run once all listeners are ready. +func (tm *ThreadManager) SignalReady() { + tm.SendMessage(readyTag, nil) +} + +// SendMessage sends a message to the main thread for the given tag. +func (tm *ThreadManager) SendMessage(tag Tag, data []byte) { + msg := Message{ + Tag: tag, + ID: initID, + DeleteCB: false, + Data: data, + } + + if tm.messageLogging { + jww.DEBUG.Printf("[WW] [%s] Worker sending message for %q with data: %s", + tm.name, tag, data) + } + + payload, err := json.Marshal(msg) + if err != nil { + jww.FATAL.Panicf("[WW] [%s] Worker failed to marshal %T for %q going "+ + "to main: %+v", tm.name, msg, tag, err) + } + + go tm.postMessage(string(payload)) +} + +// sendResponse sends a reply to the main thread with the given tag and ID. +func (tm *ThreadManager) sendResponse( + tag Tag, id uint64, data []byte) { + msg := Message{ + Tag: tag, + ID: id, + DeleteCB: true, + Data: data, + } + + if tm.messageLogging { + jww.DEBUG.Printf("[WW] [%s] Worker sending reply for %q and ID %d "+ + "with data: %s", tm.name, tag, id, data) + } + + payload, err := json.Marshal(msg) + if err != nil { + jww.FATAL.Panicf("[WW] [%s] Worker failed to marshal %T for %q and ID "+ + "%d going to main: %+v", tm.name, msg, tag, id, err) + } + + go tm.postMessage(string(payload)) +} + +// receiveMessage is registered with the Javascript event listener and is called +// everytime a message from the main thread is received. If the registered +// callback returns a response, it is sent to the main thread. +func (tm *ThreadManager) receiveMessage(data []byte) error { + var msg Message + err := json.Unmarshal(data, &msg) + if err != nil { + return err + } + + if tm.messageLogging { + jww.DEBUG.Printf("[WW] [%s] Worker received message for %q and ID %d "+ + "with data: %s", tm.name, msg.Tag, msg.ID, msg.Data) + } + + tm.mux.Lock() + callback, exists := tm.callbacks[msg.Tag] + tm.mux.Unlock() + if !exists { + return errors.Errorf("no callback found for tag %q", msg.Tag) + } + + // Call callback and register response with its return + go func() { + response, err2 := callback(msg.Data) + if err2 != nil { + jww.ERROR.Printf("[WW] [%s] Callback for for %q and ID %d "+ + "returned an error: %+v", tm.name, msg.Tag, msg.ID, err) + } + if response != nil { + tm.sendResponse(msg.Tag, msg.ID, response) + } + }() + + return nil +} + +// RegisterCallback registers the callback with the given tag overwriting any +// previous registered callbacks with the same tag. This function is thread +// safe. +// +// If the callback returns anything but nil, it will be returned as a response. +func (tm *ThreadManager) RegisterCallback( + tag Tag, receptionCallback ThreadReceptionCallback) { + jww.DEBUG.Printf( + "[WW] [%s] Worker registering callback for tag %q", tm.name, tag) + tm.mux.Lock() + tm.callbacks[tag] = receptionCallback + tm.mux.Unlock() +} + +//////////////////////////////////////////////////////////////////////////////// +// Javascript Call Wrappers // +//////////////////////////////////////////////////////////////////////////////// + +// addEventListeners adds the event listeners needed to receive a message from +// the worker. Two listeners were added; one to receive messages from the worker +// and the other to receive errors. +func (tm *ThreadManager) addEventListeners() { + // Create a listener for when the message event is fire on the worker. This + // occurs when a message is received from the main thread. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/message_event + messageEvent := js.FuncOf(func(_ js.Value, args []js.Value) any { + err := tm.receiveMessage([]byte(args[0].Get("data").String())) + if err != nil { + jww.ERROR.Printf("[WW] [%s] Failed to receive message from "+ + "main thread: %+v", tm.name, err) + } + return nil + }) + + // Create listener for when an error event is fired on the worker. This + // occurs when an error occurs in the worker. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/error_event + errorEvent := js.FuncOf(func(_ js.Value, args []js.Value) any { + event := args[0] + jww.ERROR.Printf("[WW] [%s] Worker received error event: %s", + tm.name, utils.JsErrorToJson(event)) + return nil + }) + + // Create listener for when a messageerror event is fired on the worker. + // This occurs when it receives a message that cannot be deserialized. + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/Worker/messageerror_event + messageerrorEvent := js.FuncOf(func(_ js.Value, args []js.Value) any { + event := args[0] + jww.ERROR.Printf("[WW] [%s] Worker received message error event: %s", + tm.name, utils.JsErrorToJson(event)) + return nil + }) + + // Register each event listener on the worker using addEventListener + // Doc: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + js.Global().Call("addEventListener", "message", messageEvent) + js.Global().Call("addEventListener", "error", errorEvent) + js.Global().Call("addEventListener", "messageerror", messageerrorEvent) +} + +// postMessage sends a message from this worker to the main WASM thread. +// +// aMessage must be a js.Value or a primitive type that can be converted via +// js.ValueOf. The Javascript object must be "any value or JavaScript object +// handled by the structured clone algorithm". See the doc for more information. +// +// Doc: https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/postMessage +func (tm *ThreadManager) postMessage(aMessage any) { + js.Global().Call("postMessage", aMessage) +} diff --git a/worker/thread_test.go b/worker/thread_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8f7d09cd931f5e28002ea9cb22b764df834c8062 --- /dev/null +++ b/worker/thread_test.go @@ -0,0 +1,73 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package worker + +import ( + "encoding/json" + "testing" + "time" +) + +// Tests that ThreadManager.receiveMessage calls the expected callback. +func TestThreadManager_receiveMessage(t *testing.T) { + tm := &ThreadManager{callbacks: make(map[Tag]ThreadReceptionCallback)} + + msg := Message{Tag: readyTag, ID: 5} + cbChan := make(chan struct{}) + cb := func([]byte) ([]byte, error) { cbChan <- struct{}{}; return nil, nil } + tm.callbacks[msg.Tag] = cb + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Failed to JSON marshal Message: %+v", err) + } + + go func() { + select { + case <-cbChan: + case <-time.After(10 * time.Millisecond): + t.Error("Timed out waiting for callback to be called.") + } + }() + + err = tm.receiveMessage(data) + if err != nil { + t.Errorf("Failed to receive message: %+v", err) + } +} + +// Tests that ThreadManager.RegisterCallback registers a callback that is then +// called by ThreadManager.receiveMessage. +func TestThreadManager_RegisterCallback(t *testing.T) { + tm := &ThreadManager{callbacks: make(map[Tag]ThreadReceptionCallback)} + + msg := Message{Tag: readyTag, ID: 5} + cbChan := make(chan struct{}) + cb := func([]byte) ([]byte, error) { cbChan <- struct{}{}; return nil, nil } + tm.RegisterCallback(msg.Tag, cb) + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Failed to JSON marshal Message: %+v", err) + } + + go func() { + select { + case <-cbChan: + case <-time.After(10 * time.Millisecond): + t.Error("Timed out waiting for callback to be called.") + } + }() + + err = tm.receiveMessage(data) + if err != nil { + t.Errorf("Failed to receive message: %+v", err) + } +}