From c138b09b84c3d118f6d8987085c71831a00a6838 Mon Sep 17 00:00:00 2001 From: decentral1se Date: Mon, 12 Jun 2023 13:19:58 +0200 Subject: [PATCH] init: beep boop --- .gitignore | 3 + LICENSE | 15 +++ README.md | 50 ++++++++ go.mod | 41 ++++++ go.sum | 95 ++++++++++++++ main.go | 299 +++++++++++++++++++++++++++++++++++++++++++ messages/messages.go | 17 +++ profile/add.go | 209 ++++++++++++++++++++++++++++++ 8 files changed, 729 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 messages/messages.go create mode 100644 profile/add.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4e8228 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +cairde +*.log +*.work diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e6407d3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +cairde: A text-based user interface for metadata resistant online chat. +Copyright (C) 2023 decentral1se + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f9a630 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# cairde + +> Cairde /ˈkɑːɾˠdʲə/, an Irish word which translates as "friends" + +A text-based user interface which uses the [Cwtch +protocol](https://docs.openprivacy.ca/cwtch-security-handbook/overview.html#what-is-cwtch) +for metadata resistant online chat. + +This is a **pre-alpha prototype** and an *unofficial* community implementation +which is currently only ready for experimentation and testing. Please *do not* +use existing `cwtch-ui` profiles with `cairde`! We cannot guarantee that things +will not break at this early stage. + +Please see [cwtch.im](https://cwtch.im/download/) for the official Cwtch GUI. +If you're new to Cwtch, this +[video](https://docs.openprivacy.ca/cwtch-security-handbook/overview.html#a-video-explainer) +or [the docs](https://docs.cwtch.im/) are a great place to start. + +## Prerequisites + +### Tor + +On Debian systems, you can install with: + +``` +sudo apt install tor +``` + +## Hack + +``` +go build -v . +./cairde +``` + +## Contribute + +It would be wonderful if you'd like to help out: code, design, thoughts, +testing, documentation or otherwise! Feel free to reach out to me on Cwtch: +`52u75tikfpxm7xgjcbuxmtmtojkh3wb32balgu7bem7aix3qrwubo7qd`. We're also lurking +on the [Release candidate testers +group](https://docs.cwtch.im/docs/contribute/testing#join-the-cwtch-release-candidate-testers-group). + +## References + +* [`sarah/cwtchbot`](https://git.openprivacy.ca/sarah/cwtchbot) + +## License + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b08d737 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module cairde + +go 1.20 + +require ( + cwtch.im/cwtch v0.20.8 + git.openprivacy.ca/openprivacy/connectivity v1.10.0 + git.openprivacy.ca/openprivacy/log v1.0.3 + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 + github.com/mutecomm/go-sqlcipher/v4 v4.4.2 +) + +require ( + filippo.io/edwards25519 v1.0.0 // indirect + git.openprivacy.ca/cwtch.im/tapir v0.6.0 // indirect + git.openprivacy.ca/openprivacy/bine v0.0.5 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/gtank/merlin v0.1.1 // indirect + github.com/gtank/ristretto255 v0.1.3-0.20210930101514-6bb39798585c // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + go.etcd.io/bbolt v1.3.6 // indirect + golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect + golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..61ad934 --- /dev/null +++ b/go.sum @@ -0,0 +1,95 @@ +cwtch.im/cwtch v0.20.8 h1:5N95p6uYquu9Vj2E9jK41FJpa679h1oYfiv/2jm5gbA= +cwtch.im/cwtch v0.20.8/go.mod h1:h8S7EgEM+8pE1k+XLB5jAFdIPlOzwoXEY0GH5mQye5A= +filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= +filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +git.openprivacy.ca/cwtch.im/tapir v0.6.0 h1:TtnKjxitkIDMM7Qn0n/u+mOHRLJzuQUYjYRu5n0/QFY= +git.openprivacy.ca/cwtch.im/tapir v0.6.0/go.mod h1:iQIq4y7N+DuP3CxyG66WNEC/d6vzh+wXvvOmelB+KoY= +git.openprivacy.ca/openprivacy/bine v0.0.5 h1:DJs5gqw3SkvLSgRDvroqJxZ7F+YsbxbBRg5t0rU5gYE= +git.openprivacy.ca/openprivacy/bine v0.0.5/go.mod h1:fwdeq6RO08WDkV0k7HfArsjRvurVULoUQmT//iaABZM= +git.openprivacy.ca/openprivacy/connectivity v1.10.0 h1:7P5xdFL4mGg9bwlIY+sdaNGUMazlMB82/v/6kXlvqxY= +git.openprivacy.ca/openprivacy/connectivity v1.10.0/go.mod h1:OQO1+7OIz/jLxDrorEMzvZA6SEbpbDyLGpjoFqT3z1Y= +git.openprivacy.ca/openprivacy/log v1.0.3 h1:E/PMm4LY+Q9s3aDpfySfEDq/vYQontlvNj/scrPaga0= +git.openprivacy.ca/openprivacy/log v1.0.3/go.mod h1:gGYK8xHtndRLDymFtmjkG26GaMQNgyhioNS82m812Iw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is= +github.com/gtank/merlin v0.1.1/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s= +github.com/gtank/ristretto255 v0.1.3-0.20210930101514-6bb39798585c h1:gkfmnY4Rlt3VINCo4uKdpvngiibQyoENVj5Q88sxXhE= +github.com/gtank/ristretto255 v0.1.3-0.20210930101514-6bb39798585c/go.mod h1:tDPFhGdt3hJWqtKwx57i9baiB1Cj0yAg22VOPUqm5vY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM= +github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk= +github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/mutecomm/go-sqlcipher/v4 v4.4.2 h1:eM10bFtI4UvibIsKr10/QT7Yfz+NADfjZYh0GKrXUNc= +github.com/mutecomm/go-sqlcipher/v4 v4.4.2/go.mod h1:mF2UmIpBnzFeBdu/ypTDb/LdbS0nk0dfSN1WUsWTjMA= +github.com/onsi/ginkgo/v2 v2.1.4 h1:GNapqRSid3zijZ9H77KrgVG4/8KqiyRsxcSxe+7ApXY= +github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo= +golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY= +golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..815d5e6 --- /dev/null +++ b/main.go @@ -0,0 +1,299 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "log" + mrand "math/rand" + "os" + "os/exec" + "os/user" + "path" + "path/filepath" + + "cairde/messages" + "cairde/profile" + + "strings" + "time" + + "cwtch.im/cwtch/app" + "cwtch.im/cwtch/model/attr" + "cwtch.im/cwtch/model/constants" + "cwtch.im/cwtch/protocol/connections" + "cwtch.im/cwtch/settings" + "git.openprivacy.ca/openprivacy/connectivity" + "git.openprivacy.ca/openprivacy/connectivity/tor" + openPrivacyLog "git.openprivacy.ca/openprivacy/log" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/lipgloss" + _ "github.com/mutecomm/go-sqlcipher/v4" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + initialising = iota + initialised + addProfile +) + +// model offers the core of the state for the entire UI. +type model struct { + username string // Name of user + userDir string // Directory for user data + + err error // Application error + + initSpinner spinner.Model // Spinner to show while initialising the app + + app app.Application // Cwtch application core + acn connectivity.ACN // Anonymous Communication Network networking abstraction + engineHooks connections.EngineHooks // Tor connectivity engine hooks + peers []string // List of Cwtch peers + + state int + prevState int + + addProfile profile.ProfileAddModel +} + +// initialModel constucts an initial state for the UI. +func initialModel(username, homeDir string) model { + initSpinner := spinner.New() + initSpinner.Spinner = spinner.Dot + initSpinner.Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")) + + return model{ + username: username, + userDir: path.Join(homeDir, "/.cairde/"), + initSpinner: initSpinner, + state: initialising, + prevState: initialising, + addProfile: profile.InitialModel(), + } +} + +// appInitialisedMsg is a Bubbletea Message which signals that Cwtch +// internals (e.g. Tor, profiles, etc.) have been initialised. The UI view can +// proceed now. +type appInitialisedMsg struct { + app app.Application // The initialised Cwtch application + acn connectivity.ACN // Anonymous Communication Network networking abstraction +} + +// initApp initialises Cwtch (e.g. Tor, profiles, etc.). +func initApp(m model) tea.Msg { + + mrand.Seed(int64(time.Now().Nanosecond())) + port := mrand.Intn(1000) + 9600 + controlPort := port + 1 + + key := make([]byte, 64) + _, err := rand.Read(key) + if err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to generate control port password: %s", err)} + } + + if err := os.MkdirAll(path.Join(m.userDir, "/.tor", "tor"), 0700); err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to create tor directory: %s", err)} + } + + if err := os.MkdirAll(path.Join(m.userDir, "profiles"), 0700); err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to create profiles directory: %s", err)} + } + + if err := tor.NewTorrc(). + WithSocksPort(port). + WithOnionTrafficOnly(). + WithControlPort(controlPort). + WithHashedPassword(base64.StdEncoding.EncodeToString(key)). + Build(filepath.Join(m.userDir, ".tor", "tor", "torrc")); err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to initialise torrc builder: %s", err)} + } + + acn, err := tor.NewTorACNWithAuth( + path.Join(m.userDir, "/.tor"), + "", + path.Join(m.userDir, "/.tor", "data"), + controlPort, + tor.HashedPasswordAuthenticator{ + Password: base64.StdEncoding.EncodeToString(key), + }, + ) + if err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to bootstrap tor: %s", err)} + } + + if err := acn.WaitTillBootstrapped(); err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)} + } + + settingsFile, err := settings.InitGlobalSettingsFile(m.userDir, "") + if err != nil { + return messages.UIErrMsg{Err: fmt.Errorf("unable to initialise settings: %s", err)} + } + gSettings := settingsFile.ReadGlobalSettings() + gSettings.ExperimentsEnabled = false + gSettings.DownloadPath = "./" + settingsFile.WriteGlobalSettings(gSettings) + + app := app.NewApp(acn, m.userDir, settingsFile) + app.InstallEngineHooks(m.engineHooks) + app.LoadProfiles("") + + return appInitialisedMsg{ + app: app, + acn: acn, + } +} + +// Init initialises the program. +func (m model) Init() tea.Cmd { + return tea.Batch( + func() tea.Msg { return initApp(m) }, + m.initSpinner.Tick, + ) +} + +// Update updates the program state. +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + m.initSpinner, cmd = m.initSpinner.Update(msg) + cmds = append(cmds, cmd) + + switch msg := msg.(type) { + case messages.ToPreviousStateMsg: + tmpPrevState := m.state + m.state = m.prevState + m.prevState = tmpPrevState + + case messages.UIErrMsg: + m.err = msg + + case appInitialisedMsg: + m.app = msg.app + m.acn = msg.acn + + m.addProfile.App = msg.app + + m.peers = m.app.ListProfiles() + + m.state = initialised + m.prevState = initialised + + case tea.KeyMsg: + switch msg.String() { + case "a": + m.state = addProfile + m.prevState = initialised + cmds = append(cmds, func() tea.Msg { return messages.ToAddProfileMsg{} }) + case "ctrl+c", "q": + if m.state == initialising { + break // init before clean up + } + m.app.Shutdown() + m.acn.Close() + return m, tea.Quit + } + } + + m.addProfile, cmd = m.addProfile.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// View outputs the program state for viewing. +func (m model) View() string { + body := strings.Builder{} + + if m.err != nil { + // TODO: show this error overlayed over current view + body.WriteString(fmt.Sprintf("error: %v\n", m.err)) + return body.String() + } + + switch m.state { + case initialising: + body.WriteString(fmt.Sprintf("Initialising... %s\n", m.initSpinner.View())) + case initialised: + body.WriteString("[a]add or [u]nlock profile\n") + if len(m.peers) > 0 { + body.WriteString("profiles:\n") + for _, onion := range m.peers { + peer := m.app.GetPeer(onion) + displayName, _ := peer.GetScopedZonedAttribute(attr.PublicScope, attr.ProfileZone, constants.Name) + body.WriteString(fmt.Sprintf("%s (%s)\n", onion, displayName)) + } + } + case addProfile: + body.WriteString(m.addProfile.View()) + } + + return body.String() +} + +// help is the cairde CLI help output. +const help = `cairde [options] + +A text-based user interface for metadata resistant one-to-one chats. + +Options: + -h output help +` + +// helpFlag is the help flag for the command-line interface. +var helpFlag bool + +// main is the command-line entrypoint. +func main() { + flag.BoolVar(&helpFlag, "h", false, "output help") + flag.Parse() + + if helpFlag { + fmt.Print(help) + os.Exit(0) + } + + _, err := exec.LookPath("tor") + if err != nil { + log.Fatal("could not find 'tor' command, is it installed?") + } + + f, err := tea.LogToFile("cairde.log", "debug") + if err != nil { + log.Fatal(err) + } + defer f.Close() + + filelogger, err := openPrivacyLog.NewFile(openPrivacyLog.LevelInfo, "cwtch.log") + if err == nil { + openPrivacyLog.SetStd(filelogger) + } + + user, err := user.Current() + if err != nil { + log.Fatalf("unable to determine current user: %s", err) + } + + p := tea.NewProgram( + initialModel( + user.Username, + user.HomeDir, + ), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + if err := p.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/messages/messages.go b/messages/messages.go new file mode 100644 index 0000000..c66b681 --- /dev/null +++ b/messages/messages.go @@ -0,0 +1,17 @@ +package messages + +type ToAddProfileMsg struct{} + +type ToPreviousStateMsg struct{} + +type UIErrMsg struct{ Err error } + +func (e UIErrMsg) Error() string { + return e.Err.Error() +} + +type ProfileValidateErrMsg struct{ Err error } + +func (e ProfileValidateErrMsg) Error() string { + return e.Err.Error() +} diff --git a/profile/add.go b/profile/add.go new file mode 100644 index 0000000..91966bd --- /dev/null +++ b/profile/add.go @@ -0,0 +1,209 @@ +package profile + +import ( + "fmt" + + "cairde/messages" + + "cwtch.im/cwtch/app" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + hotPink = lipgloss.Color("#FF06B7") +) + +var ( + inputStyle = lipgloss.NewStyle().Foreground(hotPink) +) + +const ( + displayName = iota + encryptProfile + password + passwordAgain +) + +type ProfileAddModel struct { + App app.Application + focus int + inputs []textinput.Model + err error +} + +func (m *ProfileAddModel) nextInput() { + m.inputs[m.focus].Blur() + if m.focus < len(m.inputs)-1 { + m.focus++ + } else { + m.focus = 0 + } + m.inputs[m.focus].Focus() +} + +func (m *ProfileAddModel) prevInput() { + m.inputs[m.focus].Blur() + if m.focus > 0 { + m.focus-- + } else { + m.focus = len(m.inputs) - 1 + } + m.inputs[m.focus].Focus() +} + +func InitialModel() ProfileAddModel { + inputs := make([]textinput.Model, 4) + + inputs[displayName] = textinput.New() + inputs[displayName].Placeholder = "" + inputs[displayName].CharLimit = 20 + inputs[displayName].Width = 22 + inputs[displayName].Prompt = "" + + inputs[encryptProfile] = textinput.New() + inputs[encryptProfile].Placeholder = "" + inputs[encryptProfile].CharLimit = 1 + inputs[encryptProfile].Width = 3 + inputs[encryptProfile].Prompt = "" + + inputs[password] = textinput.New() + inputs[password].Placeholder = "" + inputs[password].CharLimit = 30 + inputs[password].Width = 32 + inputs[password].Prompt = "" + inputs[password].EchoMode = textinput.EchoPassword + + inputs[passwordAgain] = textinput.New() + inputs[passwordAgain].Placeholder = "" + inputs[passwordAgain].CharLimit = 30 + inputs[passwordAgain].Width = 32 + inputs[passwordAgain].Prompt = "" + inputs[passwordAgain].EchoMode = textinput.EchoPassword + + return ProfileAddModel{ + focus: displayName, + inputs: inputs, + } +} + +func (m ProfileAddModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m ProfileAddModel) Update(msg tea.Msg) (ProfileAddModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + for idx, input := range m.inputs { + m.inputs[idx], cmd = input.Update(msg) + cmds = append(cmds, cmd) + } + + switch msg := msg.(type) { + case messages.ToAddProfileMsg: + m.inputs[m.focus].Focus() + + case messages.ProfileValidateErrMsg: + m.err = msg.Err + + case tea.KeyMsg: + switch msg.String() { + case "tab", "enter": + m.nextInput() + + case "down": + m.nextInput() + + case "up", "shift+tab": + m.prevInput() + + case "esc": + m.inputs[m.focus].Blur() + + case "s": + if m.inputs[m.focus].Focused() { + break + } + + var ( + displayName = m.inputs[displayName].Value() + encryptProfile = m.inputs[encryptProfile].Value() + password = m.inputs[password].Value() + passwordAgain = m.inputs[passwordAgain].Value() + ) + + if displayName == "" { + return m, func() tea.Msg { + return messages.ProfileValidateErrMsg{Err: fmt.Errorf("display name is empty?")} + } + } + + if encryptProfile == "y" { + if password != passwordAgain { + return m, func() tea.Msg { + return messages.ProfileValidateErrMsg{Err: fmt.Errorf("passwords do not match?")} + } + } + } + + if m.err != nil { + m.err = nil + } + + // TODO guard against not being initialised yet + m.App.CreateProfile(displayName, password, false) + + cmds = append(cmds, func() tea.Msg { return messages.ToPreviousStateMsg{} }) + + case "c": + if m.inputs[m.focus].Focused() { + break + } + + m.focus = displayName + + for idx := range m.inputs { + m.inputs[idx].Reset() + m.inputs[idx].Blur() + } + + cmds = append(cmds, func() tea.Msg { return messages.ToPreviousStateMsg{} }) + } + } + + return m, tea.Batch(cmds...) +} + +func (m ProfileAddModel) View() string { + var view string + + view += fmt.Sprintf(`%s +%s +%s +%s +%s +%s +%s +%s +%s +`, inputStyle.Width(30).Render("Display name"), + m.inputs[displayName].View(), + inputStyle.Width(30).Render("Encrypted? (y/n)"), + m.inputs[encryptProfile].View(), + inputStyle.Width(30).Render("Password"), + m.inputs[password].View(), + inputStyle.Width(30).Render("Password again"), + m.inputs[passwordAgain].View(), + "[s]ave | [c]ancel | [esc]: no input", + ) + + if m.err != nil { + view += fmt.Sprintf("error: %s\n", m.err) + } + + return view +}