diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a2bc4ff20ca7f074e45b2ec38abd8a4e7e978449..da74d23776081bd96dc7d3d084548e047975c67f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,22 +19,18 @@ before_script: stages: - test - build + - combine-artifacts - tag - doc-update - version_check -build: - stage: build +go-test: + stage: test except: - tags script: - go mod vendor -v - - mkdir -p release - - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -o release/xxdk.wasm main.go - - cp wasm_exec.js release/ - artifacts: - paths: - - release/ + - go test ./... -v wasm-test: stage: test @@ -43,35 +39,38 @@ wasm-test: script: - export PATH=/root/go/bin:$PATH - echo > utils/utils_js.s - - env - # - go install github.com/agnivade/wasmbrowsertest@latest - # - mv ~/go/bin/go_js_wasm_exec ~/go/bin/go_js_wasm_exec.old - # - ln -s ~/go/bin/wasmbrowsertest ~/go/bin/go_js_wasm_exec - go mod vendor - unset SSH_PRIVATE_KEY - unset $(env | grep '=' | awk -F= '{print $1}' | grep -v PATH | grep -v GO | grep -v HOME) - - GOOS=js GOARCH=wasm go test ./indexedDb/... -v - GOOS=js GOARCH=wasm go test ./... -v -go-test: - stage: test +build: + stage: build except: - tags script: - go mod vendor -v - - go test ./... -v + - mkdir -p release + - GOOS=js GOARCH=wasm go build -ldflags '-w -s' -trimpath -o release/xxdk.wasm main.go + - cp wasm_exec.js release/ + artifacts: + paths: + - release/ -version_check: - stage: version_check +build-workers: + stage: build 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 + - go mod vendor -v + - mkdir -p 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/... + - cp indexedDb/impl/channels/channelsIndexedDbWorker.js release/ + - cp indexedDb/impl/dm/dmIndexedDbWorker.js release/ + artifacts: + paths: + - release/ tag: stage: build @@ -84,12 +83,44 @@ tag: - git tag $(sha256sum release/xxdk.wasm | awk '{ print $1 }') -f - git push origin_tags -f --tags +combine-artifacts: + stage: combine-artifacts + except: + - tags + image: $DOCKER_IMAGE + script: + - echo $CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/pipelines/$CI_PIPELINE_ID/jobs + - '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")') + - BUILD_WORKERS_JOB_JSON=$(echo $PIPELINE_JOBS | jq '.[] | select(.name=="build-workers")') + + - BUILD_JOB_ID=$(echo $BUILD_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/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/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' + + - ls release + artifacts: + paths: + - release/ + expose_as: "release" + # 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: @@ -101,3 +132,15 @@ doc-update: 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..f2aef79a98a682b68cc2134da3e0d1892ab5fac3 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,12 @@ 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/... + +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..1ab19b8e741658bcd09268a8b7a8abc8db6d2ae1 --- /dev/null +++ b/indexedDb/impl/channels/main.go @@ -0,0 +1,44 @@ +//////////////////////////////////////////////////////////////////////////////// +// 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/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 := wasm.NewJsConsoleLogListener(jww.LevelInfo) + jww.SetLogListeners(ll.Listen) + jww.SetStdoutThreshold(jww.LevelFatal + 1) + jww.INFO.Printf("xxDK channels web worker version: v%s", SEMVER) +} + +func main() { + fmt.Println("[WW] Starting xxDK WebAssembly Channels Database Worker.") + jww.INFO.Print("[WW] Starting xxDK WebAssembly Channels Database Worker.") + + js.Global().Set("LogLevel", js.FuncOf(wasm.LogLevel)) + js.Global().Set("LogToFile", js.FuncOf(wasm.LogToFile)) + js.Global().Set("RegisterLogWriter", js.FuncOf(wasm.RegisterLogWriter)) + + 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..37885aa182d76bf56a562efbb1b318b10cdaf638 --- /dev/null +++ b/indexedDb/impl/dm/main.go @@ -0,0 +1,44 @@ +//////////////////////////////////////////////////////////////////////////////// +// 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/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 := wasm.NewJsConsoleLogListener(jww.LevelInfo) + jww.SetLogListeners(ll.Listen) + jww.SetStdoutThreshold(jww.LevelFatal + 1) + jww.INFO.Printf("xxDK DM web worker version: v%s", SEMVER) +} + +func main() { + fmt.Println("[WW] Starting xxDK WebAssembly DM Database Worker.") + jww.INFO.Print("[WW] Starting xxDK WebAssembly DM Database Worker.") + + js.Global().Set("LogLevel", js.FuncOf(wasm.LogLevel)) + js.Global().Set("LogToFile", js.FuncOf(wasm.LogToFile)) + js.Global().Set("RegisterLogWriter", js.FuncOf(wasm.RegisterLogWriter)) + + 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/main.go b/main.go index 80fea11e65a139a0d939c1c05a6ada764bd92ab1..6a25f8d5da5dc386ecd2a1ecc31acd49220f3ada 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,6 @@ import ( "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" @@ -37,7 +36,6 @@ func init() { func main() { fmt.Println("Starting xxDK WebAssembly bindings.") - fmt.Printf("Client version %s\n", bindings.GetVersion()) // storage/password.go js.Global().Set("GetOrInitPassword", js.FuncOf(storage.GetOrInitPassword)) 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/storage/version.go b/storage/version.go index fc0d3a39349d7821781f30e98b3ce1ea65d021e0..255fd1373dd46b0c96ee739b0f0cc850274ae2fe 100644 --- a/storage/version.go +++ b/storage/version.go @@ -20,7 +20,7 @@ import ( ) // SEMVER is the current semantic version of xxDK WASM. -const SEMVER = "0.2.0" +const SEMVER = "0.2.1" // Storage keys. const ( 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/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) + } +}