refactor!: bubblezone inspired architecture
This commit is contained in:
parent
2355d6a053
commit
767ba3ad0b
18
README.md
18
README.md
|
@ -18,23 +18,7 @@ or [the docs](https://docs.cwtch.im/) are a great place to start.
|
|||
|
||||
## Feature set
|
||||
|
||||
> Initial goals: a reliable, usable & portable client which supports basic one-to-one chat.
|
||||
|
||||
- [ ] connectivity
|
||||
- [x] ACN
|
||||
- [ ] peers
|
||||
|
||||
- [ ] profile
|
||||
- [x] create unencrypted profile
|
||||
- [x] create encrypted profile
|
||||
- [ ] unlock encrypted profile
|
||||
|
||||
- [ ] contacts
|
||||
- [ ] invite contact
|
||||
- [ ] accept invite
|
||||
|
||||
- [ ] messaging
|
||||
- [ ] create conversation
|
||||
> Coming Soon ™
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
mrand "math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cwtch.im/cwtch/app"
|
||||
"cwtch.im/cwtch/protocol/connections"
|
||||
"cwtch.im/cwtch/settings"
|
||||
"git.openprivacy.ca/openprivacy/connectivity"
|
||||
"git.openprivacy.ca/openprivacy/connectivity/tor"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type uiErrMsg struct{ Err error }
|
||||
|
||||
func (e uiErrMsg) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
type appInitialisedMsg struct {
|
||||
app app.Application
|
||||
acn connectivity.ACN
|
||||
}
|
||||
|
||||
var mainStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("#F7B3DA"))
|
||||
|
||||
type mainModel struct {
|
||||
username string
|
||||
userDir string
|
||||
|
||||
width int
|
||||
height int
|
||||
|
||||
app app.Application
|
||||
acn connectivity.ACN
|
||||
engineHooks connections.EngineHooks
|
||||
|
||||
titleModel titleModel
|
||||
}
|
||||
|
||||
func NewMainModel(username, homeDir string) mainModel {
|
||||
// TODO(d1): integrate from build system later
|
||||
titleModel := newTitleModel("cairde", "v0.0.1")
|
||||
|
||||
return mainModel{
|
||||
username: username,
|
||||
userDir: path.Join(homeDir, "/.cairde/"),
|
||||
|
||||
titleModel: titleModel,
|
||||
}
|
||||
}
|
||||
|
||||
func initApp(m mainModel) 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 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 uiErrMsg{Err: fmt.Errorf("unable to create tor directory: %s", err)}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Join(m.userDir, "profiles"), 0700); err != nil {
|
||||
return 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 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 uiErrMsg{Err: fmt.Errorf("unable to bootstrap tor: %s", err)}
|
||||
}
|
||||
|
||||
if err := acn.WaitTillBootstrapped(); err != nil {
|
||||
return uiErrMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)}
|
||||
}
|
||||
|
||||
settingsFile, err := settings.InitGlobalSettingsFile(m.userDir, "")
|
||||
if err != nil {
|
||||
return 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,
|
||||
}
|
||||
}
|
||||
|
||||
func (m mainModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
func() tea.Msg { return initApp(m) },
|
||||
)
|
||||
}
|
||||
|
||||
func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var (
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width - 2
|
||||
m.height = msg.Height - 2
|
||||
msg.Height -= 1
|
||||
msg.Width -= 4
|
||||
return m.propagate(msg), nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m.propagate(msg), tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m mainModel) propagate(msg tea.Msg) tea.Model {
|
||||
m.titleModel, _ = m.titleModel.Update(msg)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m mainModel) View() string {
|
||||
body := strings.Builder{}
|
||||
|
||||
body.WriteString(
|
||||
mainStyle.
|
||||
Width(m.width).
|
||||
Height(m.height).
|
||||
Render(
|
||||
m.titleModel.View(),
|
||||
),
|
||||
)
|
||||
|
||||
return body.String()
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var titleStyle = lipgloss.NewStyle().
|
||||
Align(lipgloss.Center).
|
||||
Bold(true)
|
||||
|
||||
var backgroundStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#EE00EE")).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
type titleModel struct {
|
||||
programName string
|
||||
programVersion string
|
||||
title string
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newTitleModel(programName, programVersion string) titleModel {
|
||||
return titleModel{
|
||||
programName: programName,
|
||||
programVersion: programVersion,
|
||||
title: fmt.Sprintf("%s@%s", programName, programVersion),
|
||||
height: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (m titleModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m titleModel) Update(msg tea.Msg) (titleModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m titleModel) View() string {
|
||||
body := strings.Builder{}
|
||||
|
||||
body.WriteString(
|
||||
lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
titleStyle.
|
||||
Width(m.width).
|
||||
Height(m.height).
|
||||
Render(
|
||||
backgroundStyle.
|
||||
Width(lipgloss.Width(m.title)+2).
|
||||
Render(m.title),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return body.String()
|
||||
}
|
311
main.go
311
main.go
|
@ -1,311 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
mrand "math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cwtch.im/cwtch/app"
|
||||
"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/textinput"
|
||||
_ "github.com/mutecomm/go-sqlcipher/v4"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
centerViewportStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(2)
|
||||
leftViewportStyle = centerViewportStyle
|
||||
)
|
||||
|
||||
type cmdWindow struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (c *cmdWindow) log(msg string) {
|
||||
c.messages = append(c.messages, fmt.Sprintf("> %s", msg))
|
||||
}
|
||||
|
||||
func (c *cmdWindow) view() string {
|
||||
return strings.Join(c.messages, "\n")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
app app.Application // Cwtch application core
|
||||
acn connectivity.ACN // Anonymous Communication Network networking abstraction
|
||||
engineHooks connections.EngineHooks // Tor connectivity engine hooks
|
||||
|
||||
appInitialised bool
|
||||
|
||||
leftViewport viewport.Model
|
||||
centerViewport viewport.Model
|
||||
input textinput.Model
|
||||
|
||||
cmdWindow *cmdWindow
|
||||
}
|
||||
|
||||
// initialModel constucts an initial state for the UI.
|
||||
func initialModel(username, homeDir string, width, height int) model {
|
||||
input := textinput.New()
|
||||
input.Focus()
|
||||
|
||||
leftViewport := viewport.New(20, height/2)
|
||||
centerViewport := viewport.New(width, height/2)
|
||||
|
||||
cmdWindow := cmdWindow{}
|
||||
|
||||
return model{
|
||||
username: username,
|
||||
userDir: path.Join(homeDir, "/.cairde/"),
|
||||
|
||||
leftViewport: leftViewport,
|
||||
centerViewport: centerViewport,
|
||||
input: input,
|
||||
|
||||
cmdWindow: &cmdWindow,
|
||||
}
|
||||
}
|
||||
|
||||
type uiErrMsg struct{ Err error }
|
||||
|
||||
func (e uiErrMsg) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// appInitialisedMsg is a Bubbletea Message which signals that Cwtch internals
|
||||
// (e.g. Tor, profiles, etc.) have been initialised.
|
||||
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 {
|
||||
m.cmdWindow.log("attempting to initialise tor")
|
||||
|
||||
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 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 uiErrMsg{Err: fmt.Errorf("unable to create tor directory: %s", err)}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Join(m.userDir, "profiles"), 0700); err != nil {
|
||||
return 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 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 uiErrMsg{Err: fmt.Errorf("unable to bootstrap tor: %s", err)}
|
||||
}
|
||||
|
||||
m.cmdWindow.log("waiting for ACN to bootstrap...")
|
||||
if err := acn.WaitTillBootstrapped(); err != nil {
|
||||
return uiErrMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)}
|
||||
}
|
||||
|
||||
m.cmdWindow.log("loading cwtch global settings")
|
||||
settingsFile, err := settings.InitGlobalSettingsFile(m.userDir, "")
|
||||
if err != nil {
|
||||
return 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)
|
||||
|
||||
m.cmdWindow.log("loading unencrypted cwtch profiles")
|
||||
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) },
|
||||
textinput.Blink,
|
||||
)
|
||||
}
|
||||
|
||||
// Update updates the program state.
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
|
||||
m.leftViewport, cmd = m.leftViewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
m.centerViewport, cmd = m.centerViewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case uiErrMsg: // TODO
|
||||
|
||||
case tea.WindowSizeMsg: // TODO
|
||||
|
||||
case appInitialisedMsg:
|
||||
m.app = msg.app
|
||||
m.acn = msg.acn
|
||||
m.appInitialised = true
|
||||
m.cmdWindow.log("tor successfully initialised")
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
if !m.appInitialised {
|
||||
m.cmdWindow.log("still initialising... just a sec")
|
||||
break
|
||||
}
|
||||
|
||||
m.app.Shutdown()
|
||||
m.acn.Close()
|
||||
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View outputs the program state for viewing.
|
||||
func (m model) View() string {
|
||||
body := strings.Builder{}
|
||||
|
||||
m.centerViewport.SetContent(m.cmdWindow.view())
|
||||
|
||||
panes := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
leftViewportStyle.Render(m.leftViewport.View()),
|
||||
centerViewportStyle.Render(m.centerViewport.View()),
|
||||
)
|
||||
body.WriteString(panes)
|
||||
|
||||
body.WriteString("\n" + m.input.View())
|
||||
|
||||
return body.String()
|
||||
}
|
||||
|
||||
// help is the cairde CLI help output.
|
||||
const help = `cairde [options]
|
||||
|
||||
A text-based user interface for metadata resistant online chat.
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
width, height, err := term.GetSize(0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(
|
||||
initialModel(
|
||||
user.Username,
|
||||
user.HomeDir,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
if err := p.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue