diff --git a/.gitignore b/.gitignore index 2b44d75..98ed488 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ *.bak *.key *.swp -*.wasm **/.envrc **/.DS_Store diff --git a/Makefile b/Makefile index 11f7237..4c39ee5 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,14 @@ dev : build ## Build debug versions of the cli and server @./salty-chat -v @./saltyd -v +pwa-dev : DEBUG=1 +pwa-dev : build ## Build debug version of saltyd and PWA + @CGO_ENABLED=1 $(GOCMD) build -tags "embed" ./cmd/saltyd/... + @./saltyd -D -b :https -u https://salty.home.arpa \ + --tls --tls-key ./certs/salty.home.arpa/key.pem \ + --tls-cert ./certs/salty.home.arpa/cert.pem \ + --svc-user salty@salty.home.arpa + cli: ## Build the salty-chat command-line client and tui @$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \ -ldflags "-w \ @@ -57,9 +65,13 @@ generate: ## Genereate any code required by the build echo 'Running in debug mode...'; \ fi -pwa: +PWA_SRCS = $(shell find ./internal/pwa -type f) + +internal/web/app.wasm: $(PWA_SRCS) @GOARCH=wasm GOOS=js $(GOCMD) build -o ./internal/web/app.wasm ./internal/pwa/ +pwa: internal/web/app.wasm + install: build ## Install salty-chat (cli) and saltyd (server) to $DESTDIR @install -D -m 755 salty-chat $(DESTDIR)/salty-chat @install -D -m 755 saltyd $(DESTDIR)/saltyd @@ -87,7 +99,7 @@ coverage: ## Get test coverage report @$(GOCMD) tool cover -html=coverage.out clean: ## Remove untracked files - @git clean -f -d + @git clean -f -d -x -e certs clean-all: ## Remove untracked and Git ignores files @git clean -f -d -X diff --git a/client.go b/client.go index 56eaf8a..728f093 100644 --- a/client.go +++ b/client.go @@ -3,6 +3,7 @@ package saltyim import ( "bytes" "context" + "errors" "fmt" "net/http" "os" @@ -18,12 +19,47 @@ import ( "go.mills.io/saltyim/internal/exec" ) +const ( + DefaultEnvPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ServiceUser = "salty" +) + +var ( + ErrNoMessages = errors.New("error: no messages found") +) + type addrCache map[string]*Addr +type Message struct { + Text string + Key *keys.EdX25519PublicKey +} + +// TODO: Support shell quoting and escapes? +func parseExtraEnvs(extraenvs string) map[string]string { + env := make(map[string]string) + for _, extraenv := range strings.Split(extraenvs, " ") { + tokens := strings.SplitN(extraenv, "=", 2) + switch len(tokens) { + case 1: + env[tokens[0]] = "" + case 2: + env[tokens[0]] = tokens[1] + } + } + return env +} + // PackMessage formts an outoing message in the Message Format // \t() func PackMessage(me *Addr, msg string) []byte { - return []byte(fmt.Sprint(time.Now().UTC().Format(time.RFC3339), "\t", me.Formatted(), "\t", strings.TrimSpace(msg), "\n")) + return []byte( + fmt.Sprint( + time.Now().UTC().Format(time.RFC3339), "\t", + me.Formatted(), "\t", + strings.TrimSpace(msg), "\n", + ), + ) } // Send sends the encrypted message `msg` to the Endpoint `endpoint` using a @@ -41,45 +77,39 @@ func Send(endpoint, msg string) error { // and Sedngina and Receiving messages to/from Salty IM Users. type Client struct { me *Addr + id *Identity key *keys.EdX25519Key cache addrCache } -func (c *Client) String() string { - b := &bytes.Buffer{} - fmt.Fprintln(b, "Me: ", c.me) - fmt.Fprintln(b, "Endpoint: ", c.me.Endpoint()) - fmt.Fprintln(b, "Key: ", c.key) - return b.String() -} - // NewClient reeturns a new Salty IM client for sending and receiving // encrypted messages to other Salty IM users as well as decrypting // and displaying messages of the user's own inbox. func NewClient(me *Addr, options ...IdentityOption) (*Client, error) { - ident, err := GetIdentity(options...) + id, err := GetIdentity(options...) if err != nil { - return nil, fmt.Errorf("error opening identity %s: %w", ident.Source(), err) + return nil, fmt.Errorf("error opening identity %s: %w", id.Source(), err) } if me == nil || me.IsZero() { - me = ident.addr + me = id.addr } if me == nil || me.IsZero() { - return nil, fmt.Errorf("unable to find your user addressn in %s", ident.Source()) + return nil, fmt.Errorf("unable to find your user addressn in %s", id.Source()) } if err := me.Refresh(); err != nil { - return nil, fmt.Errorf("error looking up user endpoint %s: %w", me.HashURI(), err) + log.WithError(err).Warn("error looking up user endpoint") } - log.Debugf("Using identity %s with public key %s", ident.Source(), ident.key) + log.Debugf("Using identity %s with public key %s", id.Source(), id.key) log.Debugf("Salty Addr is %s", me) log.Debugf("Endpoint is %s", me.Endpoint()) return &Client{ me: me, - key: ident.key, + id: id, + key: id.key, cache: make(addrCache), }, nil } @@ -100,36 +130,44 @@ func (cli *Client) getAddr(user string) (*Addr, error) { return addr, nil } -type Message struct { - Text string - Key *keys.EdX25519PublicKey -} - -func (cli *Client) handleMessage(prehook, posthook string, msgs chan Message) msgbus.HandlerFunc { - return func(msg *msgbus.Message) error { - if prehook != "" { - out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, prehook, bytes.NewBuffer(msg.Payload)) - if err != nil { - log.WithError(err).Debugf("error running pre-hook %s", prehook) - } - log.Debugf("pre-hook: %q", out) - } - - data, senderKey, err := salty.Decrypt(cli.key, msg.Payload) - if err != nil { - fmt.Fprintf(os.Stderr, "error decrypting message") - return err - } - - msgs <- Message{Text: string(data), Key: senderKey} +func (cli *Client) processMessage(msg *msgbus.Message, extraenvs, prehook, posthook string) (Message, error) { + var data []byte + defer func() { if posthook != "" { - out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, posthook, bytes.NewBuffer(data)) + out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, cli.Env(extraenvs), posthook, bytes.NewBuffer(data)) if err != nil { log.WithError(err).Debugf("error running post-hook %s", posthook) } log.Debugf("post-hook: %q", out) } + }() + + if prehook != "" { + out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, cli.Env(extraenvs), prehook, bytes.NewBuffer(msg.Payload)) + if err != nil { + log.WithError(err).Debugf("error running pre-hook %s", prehook) + } + log.Debugf("pre-hook: %q", out) + } + + unencrypted, senderKey, err := salty.Decrypt(cli.key, msg.Payload) + if err != nil { + return Message{}, fmt.Errorf("error decrypting message: %w", err) + } + data = unencrypted[:] + + return Message{Text: string(data), Key: senderKey}, nil +} + +func (cli *Client) messageHandler(extraenvs, prehook, posthook string, msgs chan Message) msgbus.HandlerFunc { + return func(msg *msgbus.Message) error { + message, err := cli.processMessage(msg, extraenvs, prehook, posthook) + if err != nil { + return fmt.Errorf("error processing message: %w", err) + } + + msgs <- message return nil } @@ -138,13 +176,104 @@ func (cli *Client) handleMessage(prehook, posthook string, msgs chan Message) ms func (cli *Client) Me() *Addr { return cli.me } func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.key.PublicKey() } -// Read subscribers to this user's inbox for new messages -func (cli *Client) Read(ctx context.Context, prehook, posthook string) chan Message { +func (cli *Client) Env(extraenvs string) []string { + Path := DefaultEnvPath + GoPath := os.Getenv("GOPATH") + if GoPath != "" { + Path = fmt.Sprintf("%s/bin:%s", GoPath, Path) + } + + env := []string{ + fmt.Sprintf("PATH=%s", Path), + fmt.Sprintf("PWD=%s", os.Getenv("PWD")), + fmt.Sprintf("HOME=%s", os.Getenv("HOME")), + + fmt.Sprintf("SALTY_USER=%s", cli.me.String()), + fmt.Sprintf("SALTY_IDENTITY=%s", cli.id.Source()), + } + + for key, val := range parseExtraEnvs(extraenvs) { + log.Debugf("key: %q", key) + log.Debugf("val: %q", val) + val = os.ExpandEnv(val) + if val == "" { + val = os.Getenv(key) + } + if val != "" { + env = append(env, fmt.Sprintf("%s=%s", key, val)) + } + } + + log.Debugf("env: #%v", env) + + return env +} + +func (cli *Client) String() string { + b := &bytes.Buffer{} + fmt.Fprintln(b, "Me: ", cli.me) + fmt.Fprintln(b, "Endpoint: ", cli.me.Endpoint()) + fmt.Fprintln(b, "Key: ", cli.key) + return b.String() +} + +// Drain drains this user's inbox by simulteneiously reading until empty anda +// subscribing to the inbox for new messages. +func (cli *Client) Drain(ctx context.Context, extraenvs, prehook, posthook string) chan Message { + msgs := make(chan Message) + + go func() { + for { + msg, err := cli.Read(extraenvs, prehook, posthook) + if err != nil { + if err == ErrNoMessages { + break + } + log.WithError(err).Warn("error reading inbox") + } else { + msgs <- msg + } + time.Sleep(time.Millisecond * 100) + } + }() + + go func() { + for msg := range cli.Subscribe(ctx, extraenvs, prehook, posthook) { + msgs <- msg + } + }() + + go func() { + <-ctx.Done() + close(msgs) + }() + + return msgs +} + +// Read reads a single message from this user's inbox +func (cli *Client) Read(extraenvs, prehook, posthook string) (Message, error) { + uri, inbox := SplitInbox(cli.me.Endpoint().String()) + bus := msgbus_client.NewClient(uri, nil) + + msg, err := bus.Pull(inbox) + if err != nil { + return Message{}, fmt.Errorf("error reading inbox: %w", err) + } + if msg == nil { + return Message{}, ErrNoMessages + } + + return cli.processMessage(msg, extraenvs, prehook, posthook) +} + +// Subscribe subscribers to this user's inbox for new messages +func (cli *Client) Subscribe(ctx context.Context, extraenvs, prehook, posthook string) chan Message { uri, inbox := SplitInbox(cli.me.Endpoint().String()) bus := msgbus_client.NewClient(uri, nil) msgs := make(chan Message) - s := bus.Subscribe(inbox, cli.handleMessage(prehook, posthook, msgs)) + s := bus.Subscribe(inbox, cli.messageHandler(extraenvs, prehook, posthook, msgs)) s.Start() log.Debugf("Connected to %s/%s", uri, inbox) diff --git a/cmd/salty-chat/read.go b/cmd/salty-chat/read.go index 354aed6..a2b3d04 100644 --- a/cmd/salty-chat/read.go +++ b/cmd/salty-chat/read.go @@ -40,6 +40,16 @@ not specified defaults to the local user ($USER)`, } // XXX: What if me.IsZero() + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + log.Fatal("error getting -f--follow flag") + } + + extraenvs, err := cmd.Flags().GetString("extra-envs") + if err != nil { + log.Fatal("error getting --extra-envs flag") + } + prehook, err := cmd.Flags().GetString("pre-hook") if err != nil { log.Fatal("error getting --pre-hook flag") @@ -50,7 +60,7 @@ not specified defaults to the local user ($USER)`, log.Fatal("error getting --post-hook flag") } - read(me, identity, prehook, posthook, args...) + read(me, identity, follow, extraenvs, prehook, posthook, args...) }, } @@ -62,6 +72,16 @@ type profile struct { func init() { rootCmd.AddCommand(readCmd) + readCmd.Flags().BoolP( + "follow", "f", false, + "Subscribe to the inbox and follow all messages", + ) + + readCmd.Flags().String( + "extra-envs", "", + "List of extra env vars to pass to pre/post hooks (KEY=[VALUE] ...)", + ) + readCmd.Flags().String( "pre-hook", "", "Execute pre-hook before message decryption", @@ -73,7 +93,7 @@ func init() { ) } -func read(me *saltyim.Addr, identity string, prehook, posthook string, args ...string) { +func read(me *saltyim.Addr, identity string, follow bool, extraenvs, prehook, posthook string, args ...string) { cli, err := saltyim.NewClient(me, saltyim.WithIdentityPath(identity)) if err != nil { fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err) @@ -91,11 +111,27 @@ func read(me *saltyim.Addr, identity string, prehook, posthook string, args ...s cancel() }() - for msg := range cli.Read(ctx, prehook, posthook) { + if follow { + for msg := range cli.Drain(ctx, extraenvs, prehook, posthook) { + if term.IsTerminal(syscall.Stdin) { + fmt.Println(saltyim.FormatMessage(msg.Text)) + } else { + fmt.Println(msg.Text) + } + } + } else { + msg, err := cli.Read(extraenvs, prehook, posthook) + if err != nil { + if err == saltyim.ErrNoMessages { + os.Exit(0) + } + fmt.Fprintf(os.Stderr, "error reading message: %s\n", err) + os.Exit(2) + } if term.IsTerminal(syscall.Stdin) { fmt.Println(saltyim.FormatMessage(msg.Text)) } else { - fmt.Println(msg) + fmt.Println(msg.Text) } } } diff --git a/go.mod b/go.mod index 9cc8464..e2a9bb7 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ go 1.17 //) require ( + github.com/avast/retry-go v2.7.0+incompatible github.com/likexian/doh-go v0.6.4 github.com/mitchellh/go-homedir v1.1.0 github.com/mlctrez/goapp-mdc v0.2.6 @@ -79,7 +80,7 @@ require ( require ( git.mills.io/prologic/bitcask v1.0.2 - git.mills.io/prologic/msgbus v0.1.10 + git.mills.io/prologic/msgbus v0.1.12 git.mills.io/prologic/observe v0.0.0-20210712230028-fc31c7aa2bd1 git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0 github.com/NYTimes/gziphandler v1.1.1 diff --git a/go.sum b/go.sum index 3e17237..5930b0e 100644 --- a/go.sum +++ b/go.sum @@ -51,14 +51,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= git.mills.io/prologic/bitcask v1.0.2 h1:Iy9x3mVVd1fB+SWY0LTmsSDPGbzMrd7zCZPKbsb/tDA= git.mills.io/prologic/bitcask v1.0.2/go.mod h1:ppXpR3haeYrijyJDleAkSGH3p90w6sIHxEA/7UHMxH4= -git.mills.io/prologic/msgbus v0.1.9-0.20220325123528-9e1d03846ecd h1:HyqAOVMpDIl+wylV1K8FySY9RZZaDL+bQNbqCISypms= -git.mills.io/prologic/msgbus v0.1.9-0.20220325123528-9e1d03846ecd/go.mod h1:3HKT07iPSoi77CC3TpukUU5rkUErHcXThVHeYOej5kI= -git.mills.io/prologic/msgbus v0.1.9-0.20220326234253-3502f7b24292 h1:4WDEWtE5gCJmzE3z5HJ2h4u+1pZ4oLkTEw26ld7EmFU= -git.mills.io/prologic/msgbus v0.1.9-0.20220326234253-3502f7b24292/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g= -git.mills.io/prologic/msgbus v0.1.9 h1:OIPW1B47wtoGwzYHPo9LQS8EwrDOVWDcn56VjtFwdGU= -git.mills.io/prologic/msgbus v0.1.9/go.mod h1:3HKT07iPSoi77CC3TpukUU5rkUErHcXThVHeYOej5kI= -git.mills.io/prologic/msgbus v0.1.10 h1:g9H7ea1lt1uHg6z43d4TaMjLaC2Ww4QzylZF0r128XI= -git.mills.io/prologic/msgbus v0.1.10/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g= +git.mills.io/prologic/msgbus v0.1.12 h1:EWK5GEJvi/H2Yt4k+FtpUzA2uw5P9u21LImUrW+0KV4= +git.mills.io/prologic/msgbus v0.1.12/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g= git.mills.io/prologic/observe v0.0.0-20210712230028-fc31c7aa2bd1 h1:e6ZyAOFGLZJZYL2galNvfuNMqeQDdilmQ5WRBXCNL5s= git.mills.io/prologic/observe v0.0.0-20210712230028-fc31c7aa2bd1/go.mod h1:/rNXqsTHGrilgNJYH/8wsIRDScyxXUhpbSdNbBatAKY= git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0 h1:MojWEgZyiugUbgyjydrdSAkHlADnbt90dXyURRYFzQ4= @@ -85,6 +79,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/avast/retry-go v2.7.0+incompatible h1:XaGnzl7gESAideSjr+I8Hki/JBi+Yb9baHlMRPeSC84= +github.com/avast/retry-go v2.7.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/badgerodon/ioutil v0.0.0-20150716134133-06e58e34b867 h1:nsDNoesoGwPzPkcrR1w1uzPUtiqwCXoNnkWC7nUuRHI= github.com/badgerodon/ioutil v0.0.0-20150716134133-06e58e34b867/go.mod h1:Ctq1YQi0dOq7QgBLZZ7p1Fr3IbAAqL/yMqDIHoe9WtE= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -366,7 +362,6 @@ github.com/keys-pub/secretservice v0.0.0-20200519003656-26e44b8df47f/go.mod h1:Y github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A= github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= @@ -860,7 +855,6 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU= golang.org/x/sys v0.0.0-20220325203850-36772127a21f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/hooks/echobot.sh b/hooks/echobot.sh index 4e24428..fa14c41 100755 --- a/hooks/echobot.sh +++ b/hooks/echobot.sh @@ -4,17 +4,11 @@ # # Setup: # -# $ salty-chat -i ~/.config/salty/echobot.key -u echo@yourdomain.tld make-user -# $ salty-chat -i ~/.config/salty/echobot.key -u echo@yourdomain.tld read --post-hook ./echobot.sh +# $ salty-chat -i ~/.config/salty/echo.key -u echo@yourdomain.tld make-user +# $ salty-chat -i ~/.config/salty/echo.key -u echo@yourdomain.tld read --post-hook ./hooks/echobot.sh set -e -# XXX: Set this to the echobot's key -identity= - -# XXX: Set this to the echobot's addr -user= - tmpfile="$(mktemp -t "echobot-XXXXXX")" trap 'rm $tmpfile' EXIT @@ -24,4 +18,4 @@ sender="$(head -n 1 < "$tmpfile" | awk '{ print $2 }')" sender="$(echo "$sender" | sed 's/[)(]//g')" message="$(head -n 1 < "$tmpfile" | awk '{ $1 = ""; $2 = ""; print $0; }')" -echo "$message" | salty-chat -d -i "$identity" -u "$user" send "$sender" +echo "$message" | salty-chat send "$sender" diff --git a/hooks/pushover-prehook.sh b/hooks/pushover-prehook.sh index 8391525..fe02b1e 100755 --- a/hooks/pushover-prehook.sh +++ b/hooks/pushover-prehook.sh @@ -5,13 +5,16 @@ if ! command -v pushover-cli > /dev/null; then exit 1 fi +echo "PUSHOVER_CLI_API: $PUSHOVER_CLI_API" +echo "PUSHOVER_CLI_USER: $PUSHOVER_CLI_USER" + if [ -z "$PUSHOVER_CLI_API" ]; then - echo "$$PUSHOVER_CLI_API not set" + echo "PUSHOVER_CLI_API not set" exit 1 fi if [ -z "$PUSHOVER_CLI_USER" ]; then - echo "$$PUSHOVER_CLI_USER not set" + echo "PUSHOVER_CLI_USER not set" exit 1 fi diff --git a/internal/exec/cmd.go b/internal/exec/cmd.go index 84e49c2..a1faec2 100644 --- a/internal/exec/cmd.go +++ b/internal/exec/cmd.go @@ -14,7 +14,7 @@ const ( // RunCmd executes the given command and arguments and stdin and ensures the // command takes no longer than the timeout before the command is terminated. -func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...string) (string, error) { +func RunCmd(timeout time.Duration, env []string, command string, stdin io.Reader, args ...string) (string, error) { var ( ctx context.Context cancel context.CancelFunc @@ -28,6 +28,7 @@ func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...stri defer cancel() cmd := exec.CommandContext(ctx, command, args...) + cmd.Env = append(cmd.Env, env...) cmd.Stdin = stdin out, err := cmd.CombinedOutput() @@ -40,7 +41,7 @@ func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...stri } } - return "", err + return string(out), err } return string(out), nil diff --git a/internal/pwa/components/chatbox.go b/internal/pwa/components/chatbox.go new file mode 100644 index 0000000..ba46483 --- /dev/null +++ b/internal/pwa/components/chatbox.go @@ -0,0 +1,68 @@ +package components + +import ( + "fmt" + + "github.com/maxence-charriere/go-app/v9/pkg/app" + "github.com/mlctrez/goapp-mdc/pkg/base" + "go.mills.io/saltyim/internal/pwa/storage" +) + +type ChatBox struct { + app.Compo + base.JsUtil + // User is who we're conversing with + User string + messages []string +} + +func (c *ChatBox) OnMount(ctx app.Context) { + ctx.Handle("chatbox", c.actionHandler) + if c.User != "" { + c.messages = storage.ConversationsLocalStorage(ctx, c.User).Read() + } + ctx.Defer(func(context app.Context) { + c.Update() + c.scrollChatPane(ctx) + }) +} + +// actionHandler receives messages intended to update this component +func (c *ChatBox) actionHandler(ctx app.Context, action app.Action) { + c.User = action.Tags.Get("user") + c.messages = storage.ConversationsLocalStorage(ctx, c.User).Read() + c.Update() +} + +func (c *ChatBox) UpdateMessages(ctx app.Context) { + if c.User == "" { + return + } + ctx.NewAction("chatbox", app.T("user", c.User)) + c.scrollChatPane(ctx) +} + +func (c *ChatBox) Render() app.UI { + return app.Div().ID("chatbox").Body(c.body()) +} + +func (c *ChatBox) body() app.UI { + if len(c.messages) == 0 { + return app.P().Text(fmt.Sprintf("no messages for user = %q", c.User)) + } else { + return app.Range(c.messages).Slice(func(i int) app.UI { + return app.P().Class("chat-paragraph").Text(c.messages[i]) + }) + } +} + +func (c *ChatBox) OnResize(ctx app.Context) { + c.scrollChatPane(ctx) +} + +func (c *ChatBox) scrollChatPane(ctx app.Context) { + ctx.Defer(func(context app.Context) { + chatBoxDiv := c.JsUtil.JsValueAtPath("chatbox") + chatBoxDiv.Set("scrollTop", chatBoxDiv.Get("scrollHeight")) + }) +} diff --git a/internal/pwa/components/configuration.go b/internal/pwa/components/configuration.go index 9192d7d..18faf1c 100644 --- a/internal/pwa/components/configuration.go +++ b/internal/pwa/components/configuration.go @@ -1,10 +1,10 @@ package components import ( - "fmt" "log" "github.com/maxence-charriere/go-app/v9/pkg/app" + "github.com/mlctrez/goapp-mdc/pkg/bar" "github.com/mlctrez/goapp-mdc/pkg/base" "github.com/mlctrez/goapp-mdc/pkg/button" "github.com/mlctrez/goapp-mdc/pkg/icon" @@ -17,6 +17,8 @@ type Configuration struct { app.Compo base.JsUtil + navigation *Navigation + // components user *textfield.TextField identity *textarea.TextArea @@ -41,27 +43,48 @@ func (c *Configuration) OnMount(ctx app.Context) { } func (c *Configuration) Render() app.UI { - fmt.Println("render") + + topBar := &bar.TopAppBar{Title: "Salty IM", + Navigation: []app.HTMLButton{icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) { + c.navigation.drawer.ActionOpen(ctx) + })}, + Fixed: true, + ScrollTarget: "main-content", + Actions: c.topActions(), + } + if c.user == nil { c.user = &textfield.TextField{Id: "config-user", Label: "User in the form user@domain"} c.identity = textarea.New("identity").Size(5, 100).Label("identity").MaxLength(1024) c.identity.WithCallback(func(in app.HTMLTextarea) { in.OnChange(c.identity.ValueTo(&c.identity.Value)) }) + c.navigation = &Navigation{} } return app.Div().Body( - &AppUpdateBanner{}, - app.H4().Text("configuration"), - c.user, - &button.Button{Icon: string(icon.MICreate), Label: "new identity", - Outlined: true, Raised: true, Callback: c.newIdentity()}, - app.Br(), - c.identity, - &button.Button{Icon: string(icon.MIUpdate), Label: "update identity", - Outlined: true, Raised: true, Callback: c.updateIdentity()}, - app.Hr(), + c.navigation, + app.Div().Class("mdc-drawer-app-content").Body( + &AppUpdateBanner{}, + topBar, + app.Div().Class("main-content").ID("main-content").Body( + topBar.Main().Body( + app.Div().ID("wrapper").Body( + app.H4().Text("configuration"), + c.user, + &button.Button{Icon: string(icon.MICreate), Label: "new identity", + Outlined: true, Raised: true, Callback: c.newIdentity()}, + app.Br(), + c.identity, + &button.Button{Icon: string(icon.MIUpdate), Label: "update identity", + Outlined: true, Raised: true, Callback: c.updateIdentity()}, + app.Hr(), + ), + ), + ), + ), ) + } func (c *Configuration) newIdentity() func(button app.HTMLButton) { @@ -107,3 +130,10 @@ func (c *Configuration) updateIdentity() func(button app.HTMLButton) { }) } } + +func (c *Configuration) topActions() (actions []app.HTMLButton) { + actions = append(actions, icon.MIRefresh.Button().Title("reload"). + OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() })) + + return actions +} diff --git a/internal/pwa/components/dialog.go b/internal/pwa/components/dialog.go new file mode 100644 index 0000000..05dc94e --- /dev/null +++ b/internal/pwa/components/dialog.go @@ -0,0 +1,39 @@ +package components + +import ( + "github.com/maxence-charriere/go-app/v9/pkg/app" + "github.com/mlctrez/goapp-mdc/pkg/button" + "github.com/mlctrez/goapp-mdc/pkg/dialog" + log "github.com/sirupsen/logrus" +) + +type ModalDialog struct { + app.Compo + dialog *dialog.Dialog +} + +func (m *ModalDialog) Render() app.UI { + if m.dialog == nil { + m.dialog = &dialog.Dialog{Id: "notification-dialog"} + m.dialog.Title = []app.UI{app.Div().Text("Error")} + m.dialog.Content = []app.UI{} + m.dialog.Buttons = []app.UI{ + &button.Button{Id: "notification-dialog-dismiss", + Dialog: true, DialogAction: "dismiss", Label: "dismiss"}, + } + } + return m.dialog +} + +func (m *ModalDialog) ShowDialog(msg ...string) { + if m.dialog == nil { + log.Debug("ModalDialog.dialog is nil, unable to display message", msg) + return + } + m.dialog.Content = []app.UI{} + for _, s := range msg { + m.dialog.Content = append(m.dialog.Content, app.Div().Text(s)) + } + m.dialog.Update() + m.dialog.Open() +} diff --git a/internal/pwa/components/navigation.go b/internal/pwa/components/navigation.go new file mode 100644 index 0000000..5e9d2ae --- /dev/null +++ b/internal/pwa/components/navigation.go @@ -0,0 +1,51 @@ +package components + +import ( + "github.com/maxence-charriere/go-app/v9/pkg/app" + "github.com/mlctrez/goapp-mdc/pkg/drawer" + "github.com/mlctrez/goapp-mdc/pkg/icon" + "github.com/mlctrez/goapp-mdc/pkg/list" + "go.mills.io/saltyim/internal/pwa/storage" +) + +type Navigation struct { + app.Compo + drawer *drawer.Drawer + items list.Items + Contacts []string +} + +func (n *Navigation) Render() app.UI { + if n.drawer == nil { + n.drawer = &drawer.Drawer{ + Type: drawer.Dismissible, + Id: "navigationDrawer", + List: &list.List{Type: list.Navigation, Id: "navigationList"}, + } + } + + items := list.Items{ + {Type: list.ItemTypeAnchor, Graphic: icon.MISettings, Href: "/config", Text: "settings"}, + {Type: list.ItemTypeAnchor, Graphic: icon.MIPersonAdd, Href: "/newchat", Text: "new chat"}, + {Type: list.ItemTypeDivider}, + } + + for _, contact := range n.Contacts { + i := &list.Item{Type: list.ItemTypeAnchor, Graphic: icon.MIPerson, Href: "/#" + contact, Text: contact} + items = append(items, i) + } + + n.drawer.List.Items = items.UIList() + + return n.drawer +} + +func (n *Navigation) OnMount(ctx app.Context) { + n.items.SelectHref(ctx.Page().URL().Path) + n.Contacts = storage.ContactsLocalStorage(ctx).List() + ctx.Handle(string(list.Select), func(context app.Context, action app.Action) { + if action.Value == n.drawer.List { + n.drawer.ActionClose(context) + } + }) +} diff --git a/internal/pwa/components/newchat.go b/internal/pwa/components/newchat.go new file mode 100644 index 0000000..3255dce --- /dev/null +++ b/internal/pwa/components/newchat.go @@ -0,0 +1,78 @@ +package components + +import ( + "github.com/maxence-charriere/go-app/v9/pkg/app" + "github.com/mlctrez/goapp-mdc/pkg/bar" + "github.com/mlctrez/goapp-mdc/pkg/base" + "github.com/mlctrez/goapp-mdc/pkg/button" + "github.com/mlctrez/goapp-mdc/pkg/icon" + "github.com/mlctrez/goapp-mdc/pkg/textfield" + "go.mills.io/saltyim" +) + +type NewChat struct { + app.Compo + base.JsUtil + + navigation *Navigation + dialog *ModalDialog + + user *textfield.TextField +} + +func (n *NewChat) Render() app.UI { + + topBar := &bar.TopAppBar{Title: "Salty IM", + Navigation: []app.HTMLButton{icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) { + n.navigation.drawer.ActionOpen(ctx) + })}, + Fixed: true, + ScrollTarget: "main-content", + Actions: n.topActions(), + } + + if n.user == nil { + n.user = &textfield.TextField{Id: "add-user", Label: "Start Chat with user@domain"} + n.dialog = &ModalDialog{} + n.navigation = &Navigation{} + } + + return app.Div().Body( + n.navigation, + app.Div().Class("mdc-drawer-app-content").Body( + &AppUpdateBanner{}, + topBar, + app.Div().Class("main-content").ID("main-content").Body( + topBar.Main().Body( + app.Div().ID("wrapper").Body( + app.H4().Text("new chat"), + n.user, + &button.Button{Icon: string(icon.MICreate), Label: "new chat", + Outlined: true, Raised: true, Callback: n.newChat()}, + n.dialog, + ), + ), + ), + ), + ) +} + +func (n *NewChat) newChat() func(button app.HTMLButton) { + return func(button app.HTMLButton) { + button.OnClick(func(ctx app.Context, e app.Event) { + addr, err := saltyim.LookupAddr(n.user.Value) + if err != nil { + n.dialog.ShowDialog("error", err.Error()) + return + } + ctx.Navigate("/#" + addr.String()) + }) + } +} + +func (n *NewChat) topActions() (actions []app.HTMLButton) { + actions = append(actions, icon.MIRefresh.Button().Title("reload"). + OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() })) + + return actions +} diff --git a/internal/pwa/components/saltychat.go b/internal/pwa/components/saltychat.go index b7b6fa9..cc83964 100644 --- a/internal/pwa/components/saltychat.go +++ b/internal/pwa/components/saltychat.go @@ -3,37 +3,36 @@ package components import ( "context" "log" - "strings" "github.com/maxence-charriere/go-app/v9/pkg/app" "github.com/mlctrez/goapp-mdc/pkg/bar" "github.com/mlctrez/goapp-mdc/pkg/base" - "github.com/mlctrez/goapp-mdc/pkg/button" - "github.com/mlctrez/goapp-mdc/pkg/dialog" "github.com/mlctrez/goapp-mdc/pkg/icon" "github.com/mlctrez/goapp-mdc/pkg/textfield" "go.mills.io/saltyim" + "go.mills.io/saltyim/internal/pwa/storage" + "go.yarn.social/lextwt" ) const ( - menuWidth = 282 - introText = "Hello! Welcome to Salty IM 🧂 This PWA App is not quite ready yet! Come back later 🤞" - descText = `salty.im is an open specification for a new Saltpack based e2e encrypted messaging protocol and platform for secure communications with a focus on privacy, security and being self-hosted.` + descText = `salty.im is an open specification for a new Saltpack based e2e encrypted messaging protocol and platform for secure communications with a focus on privacy, security and being self-hosted.` ) +var client *saltyim.Client + // SaltyChat ... type SaltyChat struct { app.Compo base.JsUtil isAppInstallable bool - dialog *dialog.Dialog + navigation *Navigation + dialog *ModalDialog + chatBox *ChatBox - messages []string - friend *textfield.TextField + Friend string chatInput *textfield.TextField - client *saltyim.Client incoming chan string } @@ -53,93 +52,121 @@ func (h *SaltyChat) OnPreRender(ctx app.Context) { func (h *SaltyChat) OnResize(ctx app.Context) { h.ResizeContent() - h.scrollChatPane(ctx) + h.navigation.drawer.ActionClose(ctx) } func (h *SaltyChat) OnAppInstallChange(ctx app.Context) { h.isAppInstallable = ctx.IsAppInstallable() } +func (h *SaltyChat) OnNav(ctx app.Context) { + h.refreshMessages(ctx) +} + +func (h *SaltyChat) refreshMessages(ctx app.Context) { + + if ctx.Page().URL().Fragment == "" { + return + } + + h.Friend = ctx.Page().URL().Fragment + h.chatBox.User = h.Friend + h.chatBox.UpdateMessages(ctx) + +} + func (h *SaltyChat) OnMount(ctx app.Context) { + h.isAppInstallable = ctx.IsAppInstallable() + + h.refreshMessages(ctx) if app.IsClient { h.connect(ctx) } } func (h *SaltyChat) connect(ctx app.Context) { - // TODO: how is client affected with mount / unmount / navigation - if h.client != nil { + + if client != nil { return } identity, err := GetIdentityFromState(ctx) if err != nil { - h.showDialog("missing identity, please configure", err.Error()) + h.dialog.ShowDialog("missing identity, please configure", err.Error()) return } - client, err := saltyim.NewClient(identity.Addr(), saltyim.WithIdentityBytes(identity.Contents())) + newClient, err := saltyim.NewClient(identity.Addr(), saltyim.WithIdentityBytes(identity.Contents())) if err != nil { - h.showDialog("error setting up client", err.Error()) + h.dialog.ShowDialog("error setting up client", err.Error()) return } - h.client = client + client = newClient ctx.Async(func() { - for msg := range client.Read(context.Background(), "", "") { - log.Println("incoming message", msg) - ctx.Dispatch(func(ctx app.Context) { - h.incomingMessage(ctx, msg.Text) - }) + for msg := range client.Drain(context.Background(), "", "", "") { + s, err := lextwt.ParseSalty(msg.Text) + if err != nil { + log.Println("incoming message error", err) + continue + } + switch s := s.(type) { + case *lextwt.SaltyText: + user := s.User.String() + storage.ConversationsLocalStorage(ctx, user).Append(msg.Text) + // only update when incoming user's message is the active chat + if h.Friend == user { + h.chatBox.UpdateMessages(ctx) + } else { + // TODO: how to notify message received in background + } + } } }) } func (h *SaltyChat) Render() app.UI { + topBar := &bar.TopAppBar{Title: "Salty IM", - Navigation: []app.HTMLButton{icon.MIMenu.Button()}, + Navigation: []app.HTMLButton{icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) { + h.navigation.drawer.ActionOpen(ctx) + })}, Fixed: true, ScrollTarget: "main-content", Actions: h.topActions(), } - if h.chatInput == nil { + if h.chatBox == nil { + h.chatBox = &ChatBox{} h.chatInput = &textfield.TextField{Id: "chat-input", Placeholder: ">"} - h.friend = &textfield.TextField{Id: "friend-input", Placeholder: "send-to"} - h.dialog = &dialog.Dialog{Id: "notification-dialog"} - h.dialog.Title = []app.UI{app.Div().Text("Error")} - h.dialog.Content = []app.UI{} - h.dialog.Buttons = []app.UI{&button.Button{Id: "notification-dialog-dismiss", - Dialog: true, DialogAction: "dismiss", Label: "dismiss"}} + //h.friend = &textfield.TextField{Id: "friend-input", Placeholder: "send-to"} + h.dialog = &ModalDialog{} + h.navigation = &Navigation{} } + h.chatBox.User = h.Friend + return app.Div().Body( - &AppUpdateBanner{}, - topBar, - h.dialog, - app.Div().Class("main-content").ID("main-content").Body( - topBar.Main().Body( - app.Div().ID("wrapper").Body( - h.buildChatList(), - app.Form().OnSubmit(h.handleSendMessage).Body( - h.chatInput, - icon.MISend.Button().ID("chat-send"), - h.friend, + h.navigation, + app.Div().Class("mdc-drawer-app-content").Body( + &AppUpdateBanner{}, + topBar, + app.Div().Class("main-content").ID("main-content").Body( + topBar.Main().Body( + app.Div().ID("wrapper").Body( + h.chatBox, + app.Form().OnSubmit(h.handleSendMessage).Body( + h.chatInput, + ), + h.dialog, ), ), ), ), ) -} -func (h *SaltyChat) buildChatList() app.UI { - return app.Div().ID("chatbox").Body( - app.Range(h.messages).Slice(func(i int) app.UI { - return app.P().Class("chat-paragraph").Text(h.messages[i]) - }), - ) } func (h *SaltyChat) handleSendMessage(ctx app.Context, e app.Event) { @@ -147,35 +174,35 @@ func (h *SaltyChat) handleSendMessage(ctx app.Context, e app.Event) { msg := h.chatInput.Value h.chatInput.Value = "" h.focusChatInput() - if msg == "" { + + //friendAddress := strings.TrimSpace(h.friend.Value) + + if msg == "" || h.Friend == "" { // nothing to send return } + storage.ContactsLocalStorage(ctx).Add(h.Friend) + // determine current user to send message to and use client to send the message - if h.client != nil { - friendAddress := strings.TrimSpace(h.friend.Value) - h.friend.Value = friendAddress - if _, err := saltyim.LookupAddr(friendAddress); err != nil { - h.showDialog("problem with send-to address", err.Error()) + if client != nil { + //h.friend.Value = friendAddress + h.chatBox.User = h.Friend + h.chatBox.Update() + if _, err := saltyim.LookupAddr(h.Friend); err != nil { + h.dialog.ShowDialog("problem with send-to address", err.Error()) } else { - if err := h.client.Send(friendAddress, msg); err == nil { - h.incomingMessage(ctx, string(saltyim.PackMessage(h.client.Me(), msg))) + if err := client.Send(h.Friend, msg); err == nil { + storage.ConversationsLocalStorage(ctx, h.Friend). + Append(string(saltyim.PackMessage(client.Me(), msg))) + h.chatBox.UpdateMessages(ctx) } else { - h.showDialog("error sending message", err.Error()) + h.dialog.ShowDialog("error sending message", err.Error()) } } } } -func (h *SaltyChat) showDialog(msg ...string) { - h.dialog.Content = []app.UI{} - for _, s := range msg { - h.dialog.Content = append(h.dialog.Content, app.Div().Text(s)) - } - h.dialog.Update() - h.dialog.Open() -} func (h *SaltyChat) focusChatInput() { chatInputValue := h.JsUtil.JsValueAtPath(h.chatInput.Id + "-input") @@ -183,33 +210,14 @@ func (h *SaltyChat) focusChatInput() { chatInputValue.Call("focus") } -// incomingMessage adds a new message to the chat window and scrolls the window to the bottom -// TODO: better formatting -func (h *SaltyChat) incomingMessage(ctx app.Context, msg string) { - h.messages = append(h.messages, msg) - - h.scrollChatPane(ctx) -} - -func (h *SaltyChat) scrollChatPane(ctx app.Context) { - ctx.Defer(func(context app.Context) { - chatBoxDiv := h.JsUtil.JsValueAtPath("chatbox") - chatBoxDiv.Set("scrollTop", chatBoxDiv.Get("scrollHeight")) - }) -} - func (h *SaltyChat) topActions() (actions []app.HTMLButton) { if h.isAppInstallable { actions = append(actions, icon.MIDownload.Button().Title("Install PWA"). OnClick(func(ctx app.Context, e app.Event) { ctx.ShowAppInstallPrompt() })) } - actions = append(actions, icon.MIRefresh.Button().Title("reload the page"). + actions = append(actions, icon.MIRefresh.Button().Title("reload"). OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() })) - actions = append(actions, icon.MISettings.Button().Title("settings").OnClick(func(ctx app.Context, e app.Event) { - ctx.Navigate("/config") - })) - return actions } diff --git a/internal/pwa/main.go b/internal/pwa/main.go index 553c773..d3fdaa6 100644 --- a/internal/pwa/main.go +++ b/internal/pwa/main.go @@ -13,26 +13,7 @@ func init() { saltyim.SetResolver(&saltyim.DNSOverHTTPResolver{}) } -// The main function is the entry point where the app is configured and started. -// It is executed in 2 different environments: A client (the web browser) and a -// server. func main() { - // The first thing to do is to associate the hello component with a path. - // - // This is done by calling the Route() function, which tells go-app what - // component to display for a given path, on both client and server-side. routes.AddRoutes() - - // Once the routes set up, the next thing to do is to either launch the app - // or the server that serves the app. - // - // When executed on the client-side, the RunWhenOnBrowser() function - // launches the app, starting a loop that listens for app events and - // executes client instructions. Since it is a blocking call, the code below - // it will never be executed. - // - // When executed on the server-side, RunWhenOnBrowser() does nothing, which - // lets room for server implementation without the need for precompiling - // instructions. app.RunWhenOnBrowser() } diff --git a/internal/pwa/routes/routes.go b/internal/pwa/routes/routes.go index b8d3bb6..49ec740 100644 --- a/internal/pwa/routes/routes.go +++ b/internal/pwa/routes/routes.go @@ -9,4 +9,5 @@ import ( func AddRoutes() { app.Route("/", &components.SaltyChat{}) app.Route("/config", &components.Configuration{}) + app.Route("/newchat", &components.NewChat{}) } diff --git a/internal/pwa/storage/actions.go b/internal/pwa/storage/actions.go new file mode 100644 index 0000000..16cec6c --- /dev/null +++ b/internal/pwa/storage/actions.go @@ -0,0 +1,10 @@ +package storage + +import "github.com/maxence-charriere/go-app/v9/pkg/app" + +// Actions defines the action operations +type Actions interface { + Handle(actionName string, h app.ActionHandler) + NewAction(name string, tags ...app.Tagger) + NewActionWithValue(name string, v interface{}, tags ...app.Tagger) +} diff --git a/internal/pwa/storage/contacts.go b/internal/pwa/storage/contacts.go new file mode 100644 index 0000000..11c8b71 --- /dev/null +++ b/internal/pwa/storage/contacts.go @@ -0,0 +1,65 @@ +package storage + +import ( + "sort" + "sync" + + "github.com/maxence-charriere/go-app/v9/pkg/app" +) + +const ( + ContactsKey = "saltyim-contacts" +) + +type Contacts interface { + Add(addr string) + Remove(addr string) + List() []string +} + +type contacts struct { + state StateOperations + lock *sync.Mutex +} + +func readContacts(state StateOperations) map[string]interface{} { + contacts := make(map[string]interface{}) + state.GetState(ContactsKey, &contacts) + return contacts +} +func updateContacts(state StateOperations, contacts map[string]interface{}) { + state.SetState(ContactsKey, contacts, app.Persist, app.Encrypt) +} + +func (c *contacts) Add(addr string) { + c.lock.Lock() + defer c.lock.Unlock() + contacts := readContacts(c.state) + contacts[addr] = true + updateContacts(c.state, contacts) +} + +func (c *contacts) Remove(addr string) { + c.lock.Lock() + defer c.lock.Unlock() + contacts := make(map[string]interface{}) + c.state.GetState(ContactsKey, &contacts) + delete(contacts, addr) + updateContacts(c.state, contacts) +} + +func (c *contacts) List() []string { + c.lock.Lock() + defer c.lock.Unlock() + contacts := readContacts(c.state) + var result []string + for s := range contacts { + result = append(result, s) + } + sort.Strings(result) + return result +} + +func ContactsLocalStorage(state StateOperations) Contacts { + return &contacts{state: state, lock: &sync.Mutex{}} +} diff --git a/internal/pwa/storage/conversations.go b/internal/pwa/storage/conversations.go new file mode 100644 index 0000000..9e0c331 --- /dev/null +++ b/internal/pwa/storage/conversations.go @@ -0,0 +1,52 @@ +package storage + +import ( + "crypto/sha256" + "fmt" + "sync" + + "github.com/maxence-charriere/go-app/v9/pkg/app" +) + +const ( + ConversationsKey = "saltyim-conversations-%x" +) + +type Conversations interface { + Read() []string + Append(line string) + Update(lines []string) +} + +type conversations struct { + state StateOperations + lock *sync.Mutex + addr string +} + +func (c *conversations) Read() []string { + return readConversations(c.state, c.addr) +} + +func (c *conversations) Update(lines []string) { + updateConversations(c.state, c.addr, lines) +} + +func (c *conversations) Append(line string) { + c.Update(append(readConversations(c.state, c.addr), line)) +} + +func readConversations(state StateOperations, addr string) []string { + conversationKey := fmt.Sprintf(ConversationsKey, sha256.Sum256([]byte(addr))) + var conversations []string + state.GetState(conversationKey, &conversations) + return conversations +} +func updateConversations(state StateOperations, addr string, conversations []string) { + conversationKey := fmt.Sprintf(ConversationsKey, sha256.Sum256([]byte(addr))) + state.SetState(conversationKey, conversations, app.Persist, app.Encrypt) +} + +func ConversationsLocalStorage(state StateOperations, addr string) Conversations { + return &conversations{state: state, lock: &sync.Mutex{}, addr: addr} +} diff --git a/internal/pwa/storage/operations.go b/internal/pwa/storage/operations.go new file mode 100644 index 0000000..db7d228 --- /dev/null +++ b/internal/pwa/storage/operations.go @@ -0,0 +1,10 @@ +package storage + +import "github.com/maxence-charriere/go-app/v9/pkg/app" + +// StateOperations are the state operations present in app.Context +type StateOperations interface { + SetState(state string, v interface{}, opts ...app.StateOption) + GetState(state string, recv interface{}) + DelState(state string) +} diff --git a/internal/server.go b/internal/server.go index a685f9c..1a6c8f2 100644 --- a/internal/server.go +++ b/internal/server.go @@ -108,7 +108,11 @@ func (s *Server) Run() (err error) { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - go s.svc.Run(ctx) + go func() { + if err := s.svc.Run(ctx); err != nil { + log.WithError(err).Error("error running service user") + } + }() <-ctx.Done() log.Infof("Received signal %s", ctx.Err()) @@ -257,7 +261,7 @@ func (s *Server) setupServiceUser() error { return err } - err = CreateConfig(s.config, addr.Hash(), key.String()) + err = CreateConfig(s.config, addr.Hash(), key.ID().String()) if err != nil { return err } @@ -314,6 +318,7 @@ func (s *Server) initRoutes() { } s.router.Handler(http.MethodGet, "/", app) s.router.Handler(http.MethodGet, "/config", app) + s.router.Handler(http.MethodGet, "/newchat", app) s.router.Handler(http.MethodGet, "/app.js", app) s.router.Handler(http.MethodGet, "/app.css", app) s.router.Handler(http.MethodGet, "/web/*static", app) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index adf0aa2..ed997db 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -33,7 +33,6 @@ type ChatTUI struct { cli *saltyim.Client user string addr *saltyim.Addr - config *saltyim.Config // Configurations. palette map[string]string @@ -158,7 +157,7 @@ func (c *ChatTUI) RunChat(inCh chan<- string, outCh <-chan string) { // Receives incoming messages on a separate goroutine to be non-blocking. go func() { - for msg := range c.cli.Read(ctx, "", "") { + for msg := range c.cli.Subscribe(ctx, "", "", "") { inCh <- msg.Text } }() diff --git a/internal/web/app.wasm b/internal/web/app.wasm new file mode 100755 index 0000000..043422f --- /dev/null +++ b/internal/web/app.wasm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87dcce2255e923ae98fdbbcd2bca06f3520f8bdda214ecab177b32b76737d58d +size 26749737 diff --git a/service.go b/service.go index 9ea6b19..2f2911a 100644 --- a/service.go +++ b/service.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" "sync" - "time" + "github.com/avast/retry-go" "github.com/keys-pub/keys" log "github.com/sirupsen/logrus" @@ -37,12 +37,19 @@ func NewService(me *Addr, id *Identity) (*Service, error) { eventFns: make(map[string]MessageEventHandlerFunc), } svc.TextFunc("ping", func(ctx context.Context, svc *Service, key *keys.EdX25519PublicKey, msg *lextwt.SaltyText) error { - return svc.Respond(msg.User.String(), "Pong!") + return svc.Respond(msg.User.String(), "pong") }) return svc, nil } +func (svc *Service) SetClient(cli *Client) { + svc.mu.Lock() + defer svc.mu.Unlock() + + svc.cli = cli +} + func (svc *Service) String() string { svc.mu.RLock() defer svc.mu.RUnlock() @@ -65,26 +72,36 @@ func (svc *Service) Respond(user, msg string) error { return svc.cli.Send(user, msg) } -func (svc *Service) Run(ctx context.Context) { +func (svc *Service) Run(ctx context.Context) error { // create the service user's client in a loop until successful // TODO: Should this timeout? Use a context? - for { + if err := retry.Do(func() error { cli, err := NewClient(svc.me, WithIdentity(svc.id)) if err != nil { - log.WithError(err).Warn("error creating service user client") - time.Sleep(time.Second * 3) - continue + return err } - svc.cli = cli - break + if err := cli.me.Refresh(); err != nil { + return err + } + svc.SetClient(cli) + return nil + }, + retry.LastErrorOnly(true), + retry.OnRetry(func(n uint, err error) { + log.Debugf("retrying service user setup (try #%d): %s", n, err) + }), + ); err != nil { + return fmt.Errorf("error setting up service user %s: %w", svc.me, err) } - log.Println("listining for bot: ", svc.me) - msgch := svc.cli.Read(ctx, "", "") + log.Debugf("listening for service requests as %s", svc.me) + + msgch := svc.cli.Drain(ctx, "", "", "") + for { select { case <-ctx.Done(): - return + return nil case msg := <-msgch: if err := svc.handle(ctx, msg); err != nil { log.WithError(err).Println("failed to handle message")