summaryrefslogtreecommitdiffstats
path: root/internal/ui/app.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/app.go')
-rw-r--r--internal/ui/app.go1795
1 files changed, 1795 insertions, 0 deletions
diff --git a/internal/ui/app.go b/internal/ui/app.go
new file mode 100644
index 0000000..d193ce1
--- /dev/null
+++ b/internal/ui/app.go
@@ -0,0 +1,1795 @@
+package ui
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "image"
+ "io"
+ "mime"
+ "net/mail"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "aegis/internal/config"
+ "aegis/internal/cryptokit"
+ "aegis/internal/filter"
+ "aegis/internal/identity"
+ "aegis/internal/nntp"
+ vfaceprofile "aegis/internal/profile"
+ "aegis/internal/smtpclient"
+ "aegis/internal/store"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/app"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+)
+
+const overviewLimit = int64(500)
+
+const messageIDDomain = "aegis.virebent.art"
+
+type application struct {
+ window fyne.Window
+ configPath string
+ settings config.Settings
+ state *store.Store
+ filterRules []filter.Rule
+ vfaceProfile *vfaceprofile.Profile
+ vfaceVaultPath string
+
+ clientMu sync.RWMutex
+ client *nntp.Client
+
+ status *widget.Label
+ progress *widget.ProgressBarInfinite
+ connect *widget.Button
+ disconnect *widget.Button
+ refresh *widget.Button
+ groupSearch *widget.Entry
+ groupList *widget.List
+ groupDetail *widget.Label
+ subscribe *widget.Button
+ unsubscribe *widget.Button
+ loadGroup *widget.Button
+
+ groups []nntp.GroupInfo
+ visibleGroups []nntp.GroupInfo
+ selectedGroup string
+
+ headerSearch *widget.Entry
+ headerList *widget.List
+ articleHeaders *widget.Label
+ showAllHeaders *widget.Check
+ body *widget.Entry
+ articleText string
+ headers []nntp.ArticleHeader
+ visibleHeader []nntp.ArticleHeader
+ loadedGroup string
+ selectedHeader nntp.ArticleHeader
+ hasSelectedHeader bool
+ markRead *widget.Button
+ bookmark *widget.Button
+ reply *widget.Button
+
+ composeGroups *widget.Entry
+ composeDelivery *widget.Select
+ composeMail2News *widget.Entry
+ composeFrom *widget.Entry
+ composeSubject *widget.Entry
+ composeReferences *widget.Entry
+ composeFollowupTo *widget.Entry
+ composeBody *widget.Entry
+ composeCryptoMode *widget.Select
+ composeCryptoKey *widget.Entry
+ composeCryptoSigningKey *widget.Entry
+ composeCryptoKeyURL *widget.Entry
+
+ cryptoAlgorithm *widget.Select
+ cryptoOperation *widget.Select
+ cryptoMessage *widget.Entry
+ cryptoPrimary *widget.Entry
+ cryptoSecret *widget.Entry
+ cryptoSecondary *widget.Entry
+ cryptoPrimaryLabel *widget.Label
+ cryptoSecretLabel *widget.Label
+ cryptoSecondaryLabel *widget.Label
+ cryptoPrimaryBox *fyne.Container
+ cryptoSecretBox *fyne.Container
+ cryptoSecondaryBox *fyne.Container
+ cryptoOutput *widget.Label
+
+ hostEntry *widget.Entry
+ portEntry *widget.Entry
+ tlsCheck *widget.Check
+ startTLSCheck *widget.Check
+ tlsSkipVerify *widget.Check
+ usernameEntry *widget.Entry
+ passwordEntry *widget.Entry
+ saslSelect *widget.Select
+ compressionCheck *widget.Check
+ proxySelect *widget.Select
+ proxyEntry *widget.Entry
+ smtpHostEntry *widget.Entry
+ smtpPortEntry *widget.Entry
+ smtpModeSelect *widget.Select
+ smtpUserEntry *widget.Entry
+ smtpEmailEntry *widget.Entry
+ smtpRecipientEntry *widget.Entry
+ smtpPasswordEntry *widget.Entry
+ smtpSkipVerify *widget.Check
+ displayEntry *widget.Entry
+ emailEntry *widget.Entry
+ filterField *widget.Select
+ filterOperator *widget.Select
+ filterPattern *widget.Entry
+ filterAction *widget.Select
+ filterTag *widget.Entry
+ vfaceUsernameEntry *widget.Entry
+ vfaceEmailEntry *widget.Entry
+ vfacePasswordEntry *widget.Entry
+ vfaceConfirmEntry *widget.Entry
+ vfaceStatus *widget.Label
+ vfaceImage *canvas.Image
+ vfaceHash *widget.Label
+}
+
+func Run() {
+ configPath, pathErr := config.DefaultPath()
+ settings := config.Default()
+ loadErr := pathErr
+ if pathErr == nil {
+ var err error
+ settings, err = config.Load(configPath)
+ if err != nil {
+ loadErr = err
+ settings = config.Default()
+ }
+ }
+
+ fyneApp := app.NewWithID("art.virebent.aegis")
+ window := fyneApp.NewWindow("Aegis Usenet Client")
+ window.Resize(fyne.NewSize(1180, 800))
+ a := &application{window: window, configPath: configPath, settings: settings}
+ if statePath, err := store.DefaultPath(); err == nil {
+ if localState, stateErr := store.Open(statePath); stateErr == nil {
+ a.state = localState
+ a.filterRules = localState.Snapshot().Filters
+ } else if loadErr == nil {
+ loadErr = stateErr
+ }
+ }
+ window.SetContent(a.build())
+ if loadErr != nil {
+ dialog.ShowError(loadErr, window)
+ }
+ window.ShowAndRun()
+ a.closeClient()
+}
+
+func (a *application) build() fyne.CanvasObject {
+ a.status = widget.NewLabel("Offline. Configure the server, then connect.")
+ a.progress = widget.NewProgressBarInfinite()
+ a.progress.Hide()
+
+ reader := a.buildReader()
+ composer := a.buildComposer()
+ settings := a.buildSettings()
+ tabs := container.NewAppTabs(
+ container.NewTabItemWithIcon("News Reader", theme.HomeIcon(), reader),
+ container.NewTabItemWithIcon("Compose", theme.MailComposeIcon(), composer),
+ container.NewTabItemWithIcon("Profilo e VFace", theme.AccountIcon(), a.buildProfile()),
+ container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settings),
+ )
+ return container.NewBorder(nil, container.NewVBox(a.progress, a.status), nil, nil, tabs)
+}
+
+func (a *application) buildReader() fyne.CanvasObject {
+ a.groupSearch = widget.NewEntry()
+ a.groupSearch.SetPlaceHolder("Filter available newsgroups...")
+ a.groupSearch.OnChanged = func(string) { a.filterGroups() }
+ a.groupDetail = widget.NewLabel("Select a group to see its estimated population before subscribing.")
+ a.groupDetail.Wrapping = fyne.TextWrapWord
+ a.groupList = widget.NewList(
+ func() int { return len(a.visibleGroups) },
+ func() fyne.CanvasObject { return widget.NewLabel("Newsgroup") },
+ func(id widget.ListItemID, object fyne.CanvasObject) {
+ group := a.visibleGroups[id]
+ prefix := " "
+ if a.isSubscribed(group.Name) {
+ prefix = "✓ "
+ }
+ object.(*widget.Label).SetText(fmt.Sprintf("%s%s (≈ %s posts)", prefix, group.Name, formatCount(group.EstimatedPost)))
+ },
+ )
+ a.groupList.OnSelected = func(id widget.ListItemID) {
+ if id < 0 || id >= len(a.visibleGroups) {
+ return
+ }
+ group := a.visibleGroups[id]
+ a.selectedGroup = group.Name
+ a.groupDetail.SetText(fmt.Sprintf(
+ "%s\nEstimated posts: %s (article numbers %d-%d)\nPosting flag: %s\nThe estimate may include gaps on the server.",
+ group.Name, formatCount(group.EstimatedPost), group.Low, group.High, group.Posting,
+ ))
+ }
+
+ a.subscribe = widget.NewButtonWithIcon("Subscribe", theme.ContentAddIcon(), a.subscribeSelected)
+ a.unsubscribe = widget.NewButtonWithIcon("Unsubscribe", theme.ContentRemoveIcon(), a.unsubscribeSelected)
+ a.loadGroup = widget.NewButtonWithIcon("Load articles", theme.ViewRefreshIcon(), a.loadSelectedGroup)
+ groupButtons := container.NewGridWithColumns(3, a.subscribe, a.unsubscribe, a.loadGroup)
+ groupPane := container.NewBorder(
+ container.NewVBox(widget.NewLabelWithStyle("Available groups", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), a.groupSearch),
+ container.NewVBox(a.groupDetail, groupButtons), nil, nil, a.groupList,
+ )
+
+ a.body = widget.NewMultiLineEntry()
+ a.body.SetPlaceHolder("Select an article to download it from the server...")
+ a.body.TextStyle = fyne.TextStyle{Monospace: true}
+ a.body.Disable()
+ a.articleHeaders = widget.NewLabel("No article selected.")
+ a.articleHeaders.Selectable = true
+ a.articleHeaders.TextStyle = fyne.TextStyle{Monospace: true}
+ a.articleHeaders.Wrapping = fyne.TextWrapOff
+ a.showAllHeaders = widget.NewCheck("Show all headers", func(bool) { a.refreshArticleHeaders() })
+ headerDisplay := container.NewBorder(a.showAllHeaders, nil, nil, nil, container.NewVScroll(a.articleHeaders))
+
+ a.headerSearch = widget.NewEntry()
+ a.headerSearch.SetPlaceHolder("Search loaded subjects or authors...")
+ a.headerSearch.OnChanged = func(string) { a.filterHeaders() }
+ a.headerList = widget.NewList(
+ func() int { return len(a.visibleHeader) },
+ func() fyne.CanvasObject {
+ return container.NewHBox(
+ widget.NewIcon(theme.DocumentIcon()),
+ widget.NewLabel("Subject"),
+ layout.NewSpacer(),
+ widget.NewLabel("Author"),
+ )
+ },
+ func(id widget.ListItemID, object fyne.CanvasObject) {
+ header := a.visibleHeader[id]
+ box := object.(*fyne.Container)
+ prefix := ""
+ if a.headerRead(header) {
+ prefix = "✓ "
+ }
+ if a.headerBookmarked(header) {
+ prefix += "★ "
+ }
+ box.Objects[1].(*widget.Label).SetText(prefix + header.Subject)
+ box.Objects[3].(*widget.Label).SetText(header.From)
+ },
+ )
+ a.headerList.OnSelected = func(id widget.ListItemID) {
+ if id < 0 || id >= len(a.visibleHeader) {
+ return
+ }
+ header := a.visibleHeader[id]
+ a.selectedHeader = header
+ a.hasSelectedHeader = true
+ a.refreshArticleActions()
+ a.loadArticle(a.loadedGroup, header)
+ }
+ headerPane := container.NewBorder(a.headerSearch, nil, nil, nil, a.headerList)
+ bodyPane := container.NewVSplit(headerDisplay, a.body)
+ bodyPane.SetOffset(0.24)
+ rightSplit := container.NewVSplit(headerPane, bodyPane)
+ rightSplit.SetOffset(0.45)
+ mainSplit := container.NewHSplit(groupPane, rightSplit)
+ mainSplit.SetOffset(0.36)
+
+ a.connect = widget.NewButtonWithIcon("Connect", theme.LoginIcon(), a.connectServer)
+ a.disconnect = widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), a.disconnectServer)
+ a.disconnect.Disable()
+ a.refresh = widget.NewButtonWithIcon("Refresh groups", theme.ViewRefreshIcon(), a.refreshGroups)
+ a.refresh.Disable()
+ a.markRead = widget.NewButton("Mark read", a.toggleRead)
+ a.bookmark = widget.NewButton("Bookmark", a.toggleBookmark)
+ a.reply = widget.NewButton("Reply", a.replyToSelected)
+ a.markRead.Disable()
+ a.bookmark.Disable()
+ a.reply.Disable()
+ toolbar := container.NewHBox(a.connect, a.disconnect, a.refresh, layout.NewSpacer(), a.reply, a.markRead, a.bookmark)
+ return container.NewBorder(toolbar, nil, nil, nil, mainSplit)
+}
+
+func (a *application) buildComposer() fyne.CanvasObject {
+ a.composeGroups = widget.NewEntry()
+ a.composeGroups.SetPlaceHolder("comp.lang.go,example.group")
+ a.composeDelivery = widget.NewSelect([]string{"NNTP direct posting", "SMTP mail2news"}, nil)
+ a.composeDelivery.SetSelected("NNTP direct posting")
+ a.composeMail2News = widget.NewEntry()
+ a.composeMail2News.SetText(a.settings.SMTPRecipient)
+ a.composeFrom = widget.NewEntry()
+ a.composeSubject = widget.NewEntry()
+ a.composeReferences = widget.NewEntry()
+ a.composeReferences.SetPlaceHolder("Filled automatically for replies")
+ a.composeFollowupTo = widget.NewEntry()
+ a.composeFollowupTo.SetPlaceHolder("Optional Followup-To newsgroup")
+ a.composeBody = widget.NewMultiLineEntry()
+ a.composeBody.SetPlaceHolder("Article body...")
+ a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email))
+ a.composeFrom.Disable()
+ a.composeCryptoMode = widget.NewSelect([]string{
+ "Plain",
+ "Sign with Ed25519",
+ "Encrypt with age",
+ "Encrypt with age and sign",
+ }, nil)
+ a.composeCryptoMode.SetSelected("Plain")
+ a.composeCryptoKey = widget.NewMultiLineEntry()
+ a.composeCryptoKey.SetPlaceHolder("age recipient (native X25519 or SSH Ed25519/RSA)")
+ a.composeCryptoKey.Wrapping = fyne.TextWrapOff
+ a.composeCryptoSigningKey = widget.NewMultiLineEntry()
+ a.composeCryptoSigningKey.SetPlaceHolder("Ed25519 private key, base64 or hexadecimal")
+ a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff
+ a.composeCryptoKeyURL = widget.NewEntry()
+ a.composeCryptoKeyURL.SetPlaceHolder("Optional HTTPS URL for the public key")
+ post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle)
+ form := widget.NewForm(
+ widget.NewFormItem("Newsgroups", a.composeGroups),
+ widget.NewFormItem("Delivery", a.composeDelivery),
+ widget.NewFormItem("mail2news recipient", a.composeMail2News),
+ widget.NewFormItem("From", a.composeFrom),
+ widget.NewFormItem("Subject", a.composeSubject),
+ widget.NewFormItem("References", a.composeReferences),
+ widget.NewFormItem("Followup-To", a.composeFollowupTo),
+ widget.NewFormItem("Crypto mode", a.composeCryptoMode),
+ widget.NewFormItem("Age recipient", a.composeCryptoKey),
+ widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey),
+ widget.NewFormItem("Public-key URL", a.composeCryptoKeyURL),
+ )
+ return container.NewBorder(form, post, nil, nil, a.composeBody)
+}
+
+func (a *application) buildProfile() fyne.CanvasObject {
+ a.vfaceUsernameEntry = widget.NewEntry()
+ a.vfaceUsernameEntry.SetPlaceHolder("Pseudonymous username")
+ a.vfaceEmailEntry = widget.NewEntry()
+ a.vfaceEmailEntry.SetPlaceHolder("Pseudonymous email address")
+ a.vfacePasswordEntry = widget.NewPasswordEntry()
+ a.vfacePasswordEntry.SetPlaceHolder("Vault password, minimum 12 characters")
+ a.vfaceConfirmEntry = widget.NewPasswordEntry()
+ a.vfaceConfirmEntry.SetPlaceHolder("Repeat vault password")
+ a.vfaceStatus = widget.NewLabel("No VFace identity loaded. VFace is optional.")
+ a.vfaceStatus.Wrapping = fyne.TextWrapWord
+ a.vfaceHash = widget.NewLabel("")
+ a.vfaceHash.Wrapping = fyne.TextWrapWord
+ a.vfaceImage = canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 48, 48)))
+ a.vfaceImage.FillMode = canvas.ImageFillContain
+ a.vfaceImage.SetMinSize(fyne.NewSize(96, 96))
+ a.vfaceImage.Hide()
+ path, err := vfaceprofile.DefaultPath()
+ if err == nil {
+ a.vfaceVaultPath = path
+ }
+ pathLabel := widget.NewLabel("Vault: " + a.vfaceVaultPath)
+ pathLabel.Wrapping = fyne.TextWrapBreak
+ create := widget.NewButton("Create or replace VFace identity", a.createVFaceProfile)
+ load := widget.NewButton("Load VFace identity", a.loadVFaceProfile)
+ lock := widget.NewButton("Lock identity", a.lockVFaceProfile)
+ form := widget.NewForm(
+ widget.NewFormItem("VFace username", a.vfaceUsernameEntry),
+ widget.NewFormItem("VFace email", a.vfaceEmailEntry),
+ widget.NewFormItem("Vault password", a.vfacePasswordEntry),
+ widget.NewFormItem("Confirm password", a.vfaceConfirmEntry),
+ )
+ provider := widget.NewLabel("Crypto providers: age for software encryption. OpenPGP is available only through a YubiKey integration and is not exposed as a standalone format.")
+ provider.Wrapping = fyne.TextWrapWord
+ return container.NewVScroll(container.NewVBox(
+ widget.NewLabelWithStyle("Optional pseudonymous identity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
+ form,
+ container.NewHBox(create, load, lock),
+ pathLabel,
+ a.vfaceStatus,
+ a.vfaceImage,
+ a.vfaceHash,
+ provider,
+ ))
+}
+
+func (a *application) createVFaceProfile() {
+ password := a.vfacePasswordEntry.Text
+ if password != a.vfaceConfirmEntry.Text {
+ dialog.ShowError(errors.New("VFace passwords do not match"), a.window)
+ return
+ }
+ value, err := vfaceprofile.Generate(a.vfaceUsernameEntry.Text, a.vfaceEmailEntry.Text)
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ if a.vfaceVaultPath == "" {
+ dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window)
+ return
+ }
+ a.setBusy(true, "Creating encrypted VFace vault...")
+ go func() {
+ saveErr := vfaceprofile.Save(a.vfaceVaultPath, value, password)
+ fyne.Do(func() {
+ a.setBusy(false, "")
+ if saveErr != nil {
+ dialog.ShowError(saveErr, a.window)
+ return
+ }
+ a.vfaceProfile = &value
+ a.vfaceConfirmEntry.SetText("")
+ a.renderVFaceProfile(value)
+ a.updateComposeIdentity()
+ a.vfaceStatus.SetText("VFace identity created and encrypted on disk.")
+ })
+ }()
+}
+
+func (a *application) loadVFaceProfile() {
+ if a.vfaceVaultPath == "" {
+ dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window)
+ return
+ }
+ password := a.vfacePasswordEntry.Text
+ a.setBusy(true, "Loading encrypted VFace identity...")
+ go func() {
+ value, err := vfaceprofile.Load(a.vfaceVaultPath, password)
+ fyne.Do(func() {
+ a.setBusy(false, "")
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.vfaceProfile = &value
+ a.vfaceUsernameEntry.SetText(value.Username)
+ a.vfaceEmailEntry.SetText(value.Email)
+ a.renderVFaceProfile(value)
+ a.updateComposeIdentity()
+ a.vfaceStatus.SetText("VFace identity loaded from encrypted disk vault.")
+ })
+ }()
+}
+
+func (a *application) lockVFaceProfile() {
+ a.vfaceProfile = nil
+ if a.vfaceImage != nil {
+ a.vfaceImage.Hide()
+ }
+ if a.vfaceStatus != nil {
+ a.vfaceStatus.SetText("VFace identity locked. VFace is optional.")
+ }
+ a.updateComposeIdentity()
+}
+
+func (a *application) renderVFaceProfile(value vfaceprofile.Profile) {
+ profile, err := identity.GenerateVFace(value.Username, value.Email, value.PublicKey)
+ if err != nil {
+ a.vfaceStatus.SetText("VFace profile unavailable: " + err.Error())
+ return
+ }
+ preview, err := identity.DecodeFacePNG(profile.FaceBase64)
+ if err == nil {
+ a.vfaceImage.Image = preview
+ a.vfaceImage.Show()
+ a.vfaceImage.Refresh()
+ }
+ a.vfaceHash.SetText("Identity SHA-256: " + profile.IdentityHash + "\nPNG SHA-256: " + profile.PNGHash)
+}
+
+func (a *application) updateComposeIdentity() {
+ if a.composeFrom == nil {
+ return
+ }
+ if a.vfaceProfile != nil {
+ a.composeFrom.SetText(formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email))
+ } else {
+ a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email))
+ }
+}
+
+func (a *application) buildCrypto() fyne.CanvasObject {
+ a.cryptoAlgorithm = widget.NewSelect([]string{"OpenPGP", "age", "Ed25519", "YubiCrypt"}, nil)
+ a.cryptoAlgorithm.SetSelected("OpenPGP")
+ a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify", "Encrypt", "Decrypt"}, nil)
+ a.cryptoOperation.SetSelected("Sign")
+ a.cryptoMessage = widget.NewMultiLineEntry()
+ a.cryptoMessage.SetPlaceHolder("Message or ciphertext")
+ a.cryptoMessage.Wrapping = fyne.TextWrapOff
+ a.cryptoPrimary = widget.NewMultiLineEntry()
+ a.cryptoPrimary.SetPlaceHolder("Key material supplied by you")
+ a.cryptoPrimary.Wrapping = fyne.TextWrapOff
+ a.cryptoSecret = widget.NewPasswordEntry()
+ a.cryptoSecret.SetPlaceHolder("YubiKey PIV PIN, session only")
+ a.cryptoSecondary = widget.NewMultiLineEntry()
+ a.cryptoSecondary.SetPlaceHolder("Optional second key or signature")
+ a.cryptoSecondary.Wrapping = fyne.TextWrapOff
+ a.cryptoPrimaryLabel = widget.NewLabel("Private key / identity")
+ a.cryptoSecretLabel = widget.NewLabel("Secret")
+ a.cryptoSecondaryLabel = widget.NewLabel("Public key / recipient")
+ a.cryptoOutput = widget.NewLabel("No result yet.")
+ a.cryptoOutput.Selectable = true
+ a.cryptoOutput.Wrapping = fyne.TextWrapOff
+ a.cryptoOutput.TextStyle = fyne.TextStyle{Monospace: true}
+ a.cryptoAlgorithm.OnChanged = func(string) { a.refreshCryptoFields() }
+ a.cryptoOperation.OnChanged = func(string) { a.refreshCryptoFields() }
+ run := widget.NewButtonWithIcon("Run operation", theme.MediaPlayIcon(), a.runCryptoOperation)
+ clear := widget.NewButtonWithIcon("Clear", theme.DeleteIcon(), func() {
+ a.cryptoMessage.SetText("")
+ a.cryptoPrimary.SetText("")
+ a.cryptoSecret.SetText("")
+ a.cryptoSecondary.SetText("")
+ a.cryptoOutput.SetText("No result yet.")
+ })
+ note := widget.NewLabel("Aegis does not generate or save keys. Key fields are used only for this session. YubiCrypt requires the optional yubicrypt executable, a YubiKey, pcscd and the PIV PIN.")
+ note.Wrapping = fyne.TextWrapWord
+ form := widget.NewForm(
+ widget.NewFormItem("Format", a.cryptoAlgorithm),
+ widget.NewFormItem("Operation", a.cryptoOperation),
+ )
+ messageBox := container.NewVBox(widget.NewLabel("Message / ciphertext"), a.cryptoMessage)
+ a.cryptoPrimaryBox = container.NewVBox(a.cryptoPrimaryLabel, a.cryptoPrimary)
+ a.cryptoSecretBox = container.NewVBox(a.cryptoSecretLabel, a.cryptoSecret)
+ a.cryptoSecondaryBox = container.NewVBox(a.cryptoSecondaryLabel, a.cryptoSecondary)
+ a.refreshCryptoFields()
+ keys := container.NewVBox(a.cryptoPrimaryBox, a.cryptoSecretBox, a.cryptoSecondaryBox)
+ input := container.NewVSplit(messageBox, keys)
+ input.SetOffset(0.42)
+ result := container.NewBorder(widget.NewLabel("Result, selectable for copy"), nil, nil, nil, container.NewVScroll(a.cryptoOutput))
+ main := container.NewVSplit(container.NewVSplit(form, input), result)
+ main.SetOffset(0.34)
+ return container.NewBorder(nil, container.NewVBox(note, container.NewHBox(run, clear)), nil, nil, main)
+}
+
+func (a *application) refreshCryptoFields() {
+ if a.cryptoAlgorithm == nil || a.cryptoOperation == nil || a.cryptoPrimaryBox == nil || a.cryptoSecretBox == nil || a.cryptoSecondaryBox == nil {
+ return
+ }
+ algorithm := a.cryptoAlgorithm.Selected
+ operation := a.cryptoOperation.Selected
+ a.cryptoPrimaryBox.Show()
+ a.cryptoSecretBox.Hide()
+ a.cryptoSecondaryBox.Show()
+ switch operation {
+ case "Verify":
+ if algorithm == "YubiCrypt" {
+ a.cryptoPrimaryBox.Hide()
+ a.cryptoSecondaryBox.Hide()
+ } else {
+ a.cryptoPrimaryLabel.SetText("Signature")
+ a.cryptoSecondaryLabel.SetText("Public key")
+ }
+ case "Encrypt":
+ if algorithm == "OpenPGP" {
+ a.cryptoPrimaryLabel.SetText("Recipient public key")
+ a.cryptoSecondaryLabel.SetText("Optional signer private key")
+ } else if algorithm == "YubiCrypt" {
+ a.cryptoPrimaryLabel.SetText("RSA recipient certificate/key (PEM)")
+ a.cryptoSecondaryBox.Hide()
+ } else {
+ a.cryptoPrimaryLabel.SetText("Recipient key")
+ a.cryptoSecondaryLabel.SetText("Not used")
+ }
+ case "Decrypt":
+ if algorithm == "YubiCrypt" {
+ a.cryptoPrimaryBox.Hide()
+ a.cryptoSecretBox.Show()
+ a.cryptoSecretLabel.SetText("YubiKey PIV PIN")
+ a.cryptoSecondaryBox.Hide()
+ } else {
+ a.cryptoPrimaryLabel.SetText("Private key / identity")
+ a.cryptoSecondaryLabel.SetText("Not used")
+ }
+ default:
+ if algorithm == "YubiCrypt" {
+ a.cryptoPrimaryBox.Hide()
+ a.cryptoSecretBox.Show()
+ a.cryptoSecretLabel.SetText("YubiKey PIV PIN")
+ a.cryptoSecondaryBox.Hide()
+ } else {
+ a.cryptoPrimaryLabel.SetText("Private key")
+ a.cryptoSecondaryLabel.SetText("Not used")
+ }
+ }
+ a.cryptoSecondary.Disable()
+ if operation == "Verify" || (operation == "Encrypt" && algorithm == "OpenPGP") {
+ a.cryptoSecondary.Enable()
+ }
+ a.cryptoPrimaryBox.Refresh()
+ a.cryptoSecretBox.Refresh()
+ a.cryptoSecondaryBox.Refresh()
+}
+
+func (a *application) runCryptoOperation() {
+ algorithm := a.cryptoAlgorithm.Selected
+ operation := a.cryptoOperation.Selected
+ message := []byte(a.cryptoMessage.Text)
+ primary := a.cryptoPrimary.Text
+ secret := a.cryptoSecret.Text
+ secondary := a.cryptoSecondary.Text
+ if len(strings.TrimSpace(string(message))) == 0 {
+ dialog.ShowError(errors.New("message or ciphertext is required"), a.window)
+ return
+ }
+ if algorithm == "YubiCrypt" && operation != "Verify" && operation != "Encrypt" && strings.TrimSpace(secret) == "" {
+ dialog.ShowError(errors.New("YubiKey PIV PIN is required"), a.window)
+ return
+ }
+ if algorithm == "YubiCrypt" && operation == "Encrypt" && strings.TrimSpace(primary) == "" {
+ dialog.ShowError(errors.New("RSA recipient certificate/key is required"), a.window)
+ return
+ }
+ if algorithm != "YubiCrypt" && strings.TrimSpace(primary) == "" {
+ dialog.ShowError(errors.New("primary key material is required"), a.window)
+ return
+ }
+ a.setBusy(true, "Running "+algorithm+" "+operation+"...")
+ go func() {
+ var result string
+ var err error
+ switch algorithm {
+ case "OpenPGP":
+ switch operation {
+ case "Sign":
+ result, err = cryptokit.SignOpenPGPDetached(message, primary)
+ case "Verify":
+ err = cryptokit.VerifyOpenPGPDetached(message, primary, secondary)
+ result = "OpenPGP signature verified."
+ case "Encrypt":
+ result, err = cryptokit.EncryptOpenPGP(message, primary, secondary)
+ case "Decrypt":
+ var plaintext []byte
+ plaintext, err = cryptokit.DecryptOpenPGP(message, primary)
+ result = string(plaintext)
+ }
+ case "age":
+ switch operation {
+ case "Encrypt":
+ var ciphertext []byte
+ ciphertext, err = cryptokit.EncryptAge(message, primary)
+ result = string(ciphertext)
+ case "Decrypt":
+ var plaintext []byte
+ plaintext, err = cryptokit.DecryptAge(message, primary)
+ result = string(plaintext)
+ default:
+ err = errors.New("age supports Encrypt and Decrypt")
+ }
+ case "Ed25519":
+ switch operation {
+ case "Sign":
+ result, err = cryptokit.SignEd25519(message, primary)
+ case "Verify":
+ err = cryptokit.VerifyEd25519(message, primary, secondary)
+ result = "Ed25519 signature verified."
+ default:
+ err = errors.New("raw Ed25519 supports Sign and Verify; use age SSH keys for encryption")
+ }
+ case "YubiCrypt":
+ switch operation {
+ case "Sign":
+ var signed []byte
+ signed, err = cryptokit.SignYubiCrypt(message, secret)
+ result = string(signed)
+ case "Verify":
+ var verified []byte
+ verified, err = cryptokit.VerifyYubiCrypt(message)
+ result = string(verified)
+ case "Encrypt":
+ var ciphertext []byte
+ ciphertext, err = cryptokit.EncryptYubiCrypt(message, primary)
+ result = string(ciphertext)
+ case "Decrypt":
+ var plaintext []byte
+ plaintext, err = cryptokit.DecryptYubiCrypt(message, secret)
+ result = string(plaintext)
+ }
+ }
+ fyne.Do(func() {
+ a.setBusy(false, "Cryptography operation completed.")
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.cryptoOutput.SetText(result)
+ })
+ }()
+}
+
+func (a *application) buildSettings() fyne.CanvasObject {
+ a.hostEntry = widget.NewEntry()
+ a.hostEntry.SetText(a.settings.Host)
+ a.portEntry = widget.NewEntry()
+ a.portEntry.SetText(a.settings.Port)
+ a.tlsCheck = widget.NewCheck("Use TLS", nil)
+ a.tlsCheck.SetChecked(a.settings.UseTLS)
+ a.startTLSCheck = widget.NewCheck("Use STARTTLS", nil)
+ a.startTLSCheck.SetChecked(a.settings.StartTLS)
+ a.tlsSkipVerify = widget.NewCheck("Do not verify the TLS certificate (unsafe, explicit opt-in)", nil)
+ a.tlsSkipVerify.SetChecked(a.settings.SkipTLSVerify)
+ a.usernameEntry = widget.NewEntry()
+ a.usernameEntry.SetText(a.settings.Username)
+ a.passwordEntry = widget.NewPasswordEntry()
+ a.passwordEntry.SetPlaceHolder("Session only, never saved")
+ a.saslSelect = widget.NewSelect([]string{"None", "PLAIN"}, nil)
+ if a.settings.SASLMechanism == "PLAIN" {
+ a.saslSelect.SetSelected("PLAIN")
+ } else {
+ a.saslSelect.SetSelected("None")
+ }
+ a.compressionCheck = widget.NewCheck("Use COMPRESS DEFLATE when advertised", nil)
+ a.compressionCheck.SetChecked(a.settings.UseCompression)
+ a.proxySelect = widget.NewSelect([]string{"DIRECT", "SOCKS5"}, nil)
+ a.proxySelect.SetSelected(a.settings.ProxyType)
+ a.proxyEntry = widget.NewEntry()
+ a.proxyEntry.SetText(a.settings.ProxyAddress)
+ a.smtpHostEntry = widget.NewEntry()
+ a.smtpHostEntry.SetText(a.settings.SMTPHost)
+ a.smtpPortEntry = widget.NewEntry()
+ a.smtpPortEntry.SetText(a.settings.SMTPPort)
+ a.smtpModeSelect = widget.NewSelect([]string{"Cleartext (no TLS)", "TLS", "STARTTLS"}, nil)
+ if a.settings.SMTPMode == "TLS" || a.settings.SMTPMode == "STARTTLS" {
+ a.smtpModeSelect.SetSelected(a.settings.SMTPMode)
+ } else {
+ a.smtpModeSelect.SetSelected("Cleartext (no TLS)")
+ }
+ a.smtpUserEntry = widget.NewEntry()
+ a.smtpUserEntry.SetText(a.settings.SMTPUsername)
+ a.smtpEmailEntry = widget.NewEntry()
+ a.smtpEmailEntry.SetText(a.settings.SMTPEmail)
+ a.smtpRecipientEntry = widget.NewEntry()
+ a.smtpRecipientEntry.SetText(a.settings.SMTPRecipient)
+ a.smtpPasswordEntry = widget.NewPasswordEntry()
+ a.smtpPasswordEntry.SetPlaceHolder("Session only, never saved")
+ a.smtpSkipVerify = widget.NewCheck("Do not verify SMTP TLS certificate (unsafe)", nil)
+ a.smtpSkipVerify.SetChecked(a.settings.SMTPSkipVerify)
+ a.displayEntry = widget.NewEntry()
+ a.displayEntry.SetText(a.settings.DisplayName)
+ a.emailEntry = widget.NewEntry()
+ a.emailEntry.SetText(a.settings.Email)
+ a.filterField = widget.NewSelect([]string{"subject", "from", "newsgroups", "message-id", "references", "date", "body", "header", "vface-hash", "read", "any"}, nil)
+ a.filterField.SetSelected("subject")
+ a.filterOperator = widget.NewSelect([]string{"contains", "exact", "regexp", "glob"}, nil)
+ a.filterOperator.SetSelected("contains")
+ a.filterPattern = widget.NewEntry()
+ a.filterPattern.SetPlaceHolder("es. [spam], example.org, <user@...>")
+ a.filterAction = widget.NewSelect([]string{"hide", "mark-read", "highlight", "tag", "mute-thread", "keep"}, nil)
+ a.filterAction.SetSelected("hide")
+ a.filterTag = widget.NewEntry()
+ a.filterTag.SetPlaceHolder("Tag, se l'azione è tag")
+ save := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), a.saveSettings)
+ form := widget.NewForm(
+ widget.NewFormItem("NNTP host", a.hostEntry),
+ widget.NewFormItem("Port", a.portEntry),
+ widget.NewFormItem("Transport", container.NewVBox(a.tlsCheck, a.startTLSCheck, a.tlsSkipVerify, a.compressionCheck)),
+ widget.NewFormItem("NNTP username", a.usernameEntry),
+ widget.NewFormItem("NNTP password", a.passwordEntry),
+ widget.NewFormItem("SASL", a.saslSelect),
+ widget.NewFormItem("Proxy", a.proxySelect),
+ widget.NewFormItem("SOCKS5 address", a.proxyEntry),
+ widget.NewFormItem("SMTP mail2news host", a.smtpHostEntry),
+ widget.NewFormItem("SMTP port", a.smtpPortEntry),
+ widget.NewFormItem("SMTP transport", a.smtpModeSelect),
+ widget.NewFormItem("SMTP username", a.smtpUserEntry),
+ widget.NewFormItem("SMTP email", a.smtpEmailEntry),
+ widget.NewFormItem("mail2news recipient", a.smtpRecipientEntry),
+ widget.NewFormItem("SMTP password", a.smtpPasswordEntry),
+ widget.NewFormItem("SMTP TLS", a.smtpSkipVerify),
+ widget.NewFormItem("NNTP display name", a.displayEntry),
+ widget.NewFormItem("NNTP email", a.emailEntry),
+ )
+ filters := widget.NewButton("Filters", a.showFilterEditor)
+ content := container.NewVBox(form, filters, save)
+ return container.NewVScroll(content)
+}
+
+func (a *application) readSettingsForm() config.Settings {
+ settings := a.settings
+ settings.Host = strings.TrimSpace(a.hostEntry.Text)
+ settings.Port = strings.TrimSpace(a.portEntry.Text)
+ settings.UseTLS = a.tlsCheck.Checked
+ settings.StartTLS = a.startTLSCheck.Checked
+ settings.SkipTLSVerify = a.tlsSkipVerify.Checked
+ settings.Username = strings.TrimSpace(a.usernameEntry.Text)
+ if a.saslSelect.Selected == "PLAIN" {
+ settings.SASLMechanism = "PLAIN"
+ } else {
+ settings.SASLMechanism = ""
+ }
+ settings.UseCompression = a.compressionCheck.Checked
+ settings.ProxyType = a.proxySelect.Selected
+ settings.ProxyAddress = strings.TrimSpace(a.proxyEntry.Text)
+ settings.SMTPHost = strings.TrimSpace(a.smtpHostEntry.Text)
+ settings.SMTPPort = strings.TrimSpace(a.smtpPortEntry.Text)
+ if a.smtpModeSelect.Selected == "TLS" || a.smtpModeSelect.Selected == "STARTTLS" {
+ settings.SMTPMode = a.smtpModeSelect.Selected
+ } else {
+ settings.SMTPMode = ""
+ }
+ settings.SMTPUsername = strings.TrimSpace(a.smtpUserEntry.Text)
+ settings.SMTPEmail = strings.TrimSpace(a.smtpEmailEntry.Text)
+ settings.SMTPRecipient = strings.TrimSpace(a.smtpRecipientEntry.Text)
+ settings.SMTPSkipVerify = a.smtpSkipVerify.Checked
+ settings.DisplayName = strings.TrimSpace(a.displayEntry.Text)
+ settings.Email = strings.TrimSpace(a.emailEntry.Text)
+ return settings
+}
+
+func (a *application) saveSettings() {
+ settings := a.readSettingsForm()
+ if err := settings.Validate(); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ if a.configPath == "" {
+ dialog.ShowError(errors.New("configuration path is unavailable"), a.window)
+ return
+ }
+ if err := config.Save(a.configPath, settings); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.settings = settings
+ a.updateComposeIdentity()
+ a.status.SetText("Settings saved. Password retained only for this session.")
+}
+
+func (a *application) addFilterRule() {
+ if a.state == nil {
+ dialog.ShowError(errors.New("local state is unavailable"), a.window)
+ return
+ }
+ rule := filter.Rule{
+ ID: fmt.Sprintf("rule-%d", time.Now().UnixNano()), Enabled: true,
+ Field: filter.Field(a.filterField.Selected), Operator: filter.Operator(a.filterOperator.Selected),
+ Pattern: strings.TrimSpace(a.filterPattern.Text), Action: filter.Action(a.filterAction.Selected), Tag: strings.TrimSpace(a.filterTag.Text),
+ }
+ if _, err := filter.Evaluate(filter.Article{Headers: map[string]string{}}, []filter.Rule{rule}); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.filterRules = append(a.filterRules, rule)
+ a.state.SetFilters(a.filterRules)
+ if err := a.state.Save(); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.filterPattern.SetText("")
+ a.filterTag.SetText("")
+ a.filterHeaders()
+ a.status.SetText("Filtro locale aggiunto.")
+}
+
+func (a *application) showFilterEditor() {
+ form := widget.NewForm(
+ widget.NewFormItem("Field", a.filterField),
+ widget.NewFormItem("Operator", a.filterOperator),
+ widget.NewFormItem("Pattern", a.filterPattern),
+ widget.NewFormItem("Action", a.filterAction),
+ widget.NewFormItem("Tag", a.filterTag),
+ )
+ note := widget.NewLabel("Local filter only. It never deletes or rewrites server articles.")
+ note.Wrapping = fyne.TextWrapWord
+ content := container.NewVBox(note, form)
+ dialog.ShowCustomConfirm("Filters", "Add filter", "Close", content, func(confirmed bool) {
+ if confirmed {
+ a.addFilterRule()
+ }
+ }, a.window)
+}
+
+func (a *application) connectServer() {
+ settings := a.readSettingsForm()
+ if err := settings.Validate(); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ password := a.passwordEntry.Text
+ a.settings = settings
+ a.setBusy(true, "Connecting securely to "+settings.Host+"...")
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
+ defer cancel()
+ client, err := nntp.Dial(ctx, nntp.DialConfig{
+ Host: settings.Host,
+ Port: settings.Port,
+ UseTLS: settings.UseTLS,
+ StartTLS: settings.StartTLS,
+ InsecureSkipVerify: settings.SkipTLSVerify,
+ Username: settings.Username,
+ Password: password,
+ SASLMechanism: settings.SASLMechanism,
+ UseCompression: settings.UseCompression,
+ ProxyType: settings.ProxyType,
+ ProxyAddress: settings.ProxyAddress,
+ })
+ if err != nil {
+ a.asyncError("Connection failed", err)
+ return
+ }
+ groups, err := client.ListActive()
+ if err != nil {
+ client.Close()
+ a.asyncError("Connected, but LIST ACTIVE failed", err)
+ return
+ }
+ a.replaceClient(client)
+ fyne.Do(func() {
+ a.groups = groups
+ a.filterGroups()
+ a.connect.Disable()
+ a.disconnect.Enable()
+ a.refresh.Enable()
+ a.setBusy(false, fmt.Sprintf("Connected. %s available newsgroups loaded.", formatCount(int64(len(groups)))))
+ })
+ }()
+}
+
+func (a *application) disconnectServer() {
+ a.closeClient()
+ a.groups = nil
+ a.visibleGroups = nil
+ a.headers = nil
+ a.visibleHeader = nil
+ a.articleText = ""
+ a.selectedGroup = ""
+ a.loadedGroup = ""
+ a.groupList.Refresh()
+ a.headerList.Refresh()
+ a.body.SetText("")
+ a.articleHeaders.SetText("No article selected.")
+ a.connect.Enable()
+ a.disconnect.Disable()
+ a.refresh.Disable()
+ a.status.SetText("Disconnected.")
+}
+
+func (a *application) refreshGroups() {
+ client := a.currentClient()
+ if client == nil {
+ dialog.ShowError(errors.New("connect to a server first"), a.window)
+ return
+ }
+ a.setBusy(true, "Refreshing available newsgroups...")
+ go func() {
+ groups, err := client.ListActive()
+ if err != nil {
+ a.asyncError("Cannot refresh groups", err)
+ return
+ }
+ fyne.Do(func() {
+ a.groups = groups
+ a.filterGroups()
+ a.setBusy(false, fmt.Sprintf("%s available newsgroups loaded.", formatCount(int64(len(groups)))))
+ })
+ }()
+}
+
+func (a *application) subscribeSelected() {
+ if a.selectedGroup == "" {
+ dialog.ShowError(errors.New("select a newsgroup first"), a.window)
+ return
+ }
+ if !a.isSubscribed(a.selectedGroup) {
+ a.settings.Subscriptions = append(a.settings.Subscriptions, a.selectedGroup)
+ sort.Strings(a.settings.Subscriptions)
+ if err := a.persistSubscriptions(); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ }
+ a.composeGroups.SetText(a.selectedGroup)
+ a.groupList.Refresh()
+ a.status.SetText("Subscribed to " + a.selectedGroup + ".")
+}
+
+func (a *application) unsubscribeSelected() {
+ if a.selectedGroup == "" {
+ dialog.ShowError(errors.New("select a newsgroup first"), a.window)
+ return
+ }
+ groups := a.settings.Subscriptions[:0]
+ for _, group := range a.settings.Subscriptions {
+ if group != a.selectedGroup {
+ groups = append(groups, group)
+ }
+ }
+ a.settings.Subscriptions = groups
+ if err := a.persistSubscriptions(); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.groupList.Refresh()
+ a.status.SetText("Unsubscribed from " + a.selectedGroup + ".")
+}
+
+func (a *application) persistSubscriptions() error {
+ if a.configPath == "" {
+ return errors.New("configuration path is unavailable")
+ }
+ settings := a.readSettingsForm()
+ settings.Subscriptions = append([]string(nil), a.settings.Subscriptions...)
+ if err := config.Save(a.configPath, settings); err != nil {
+ return err
+ }
+ a.settings = settings
+ return nil
+}
+
+func (a *application) loadSelectedGroup() {
+ group := a.selectedGroup
+ if group == "" {
+ dialog.ShowError(errors.New("select a newsgroup first"), a.window)
+ return
+ }
+ if !a.isSubscribed(group) {
+ dialog.ShowError(errors.New("subscribe to the newsgroup before loading its articles"), a.window)
+ return
+ }
+ client := a.currentClient()
+ if client == nil {
+ dialog.ShowError(errors.New("connect to a server first"), a.window)
+ return
+ }
+ a.setBusy(true, "Loading recent headers from "+group+"...")
+ go func() {
+ status, headers, err := client.LatestOverview(group, overviewLimit)
+ if err != nil {
+ a.asyncError("Cannot load article overview", err)
+ return
+ }
+ fyne.Do(func() {
+ a.loadedGroup = group
+ a.headers = headers
+ if a.state != nil {
+ for _, header := range headers {
+ _ = a.state.UpsertArticle(store.Article{Key: articleKey(group, header), Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID})
+ }
+ _ = a.state.Save()
+ }
+ a.filterHeaders()
+ a.body.SetText("")
+ a.composeGroups.SetText(group)
+ a.setBusy(false, fmt.Sprintf("%s: loaded %d recent headers, server reports %d articles.", group, len(headers), status.Count))
+ })
+ }()
+}
+
+func (a *application) loadArticle(group string, header nntp.ArticleHeader) {
+ client := a.currentClient()
+ if client == nil || group == "" {
+ return
+ }
+ a.setBusy(true, fmt.Sprintf("Downloading article %d from %s...", header.Number, group))
+ go func() {
+ article, err := client.ArticleInGroup(group, header.Number)
+ if err != nil {
+ a.asyncError("Cannot download article", err)
+ return
+ }
+ fyne.Do(func() {
+ a.body.SetText(article)
+ a.articleText = article
+ if a.state != nil {
+ key := articleKey(group, header)
+ _ = a.state.UpsertArticle(store.Article{Key: key, Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID, Raw: article})
+ _ = a.state.SetRead(key, true)
+ _ = a.state.Save()
+ }
+ a.refreshArticleHeaders()
+ a.refreshArticleActions()
+ a.headerList.Refresh()
+ a.setBusy(false, fmt.Sprintf("Article %d downloaded from %s.", header.Number, group))
+ })
+ }()
+}
+
+func (a *application) replyToSelected() {
+ if !a.hasSelectedHeader || strings.TrimSpace(a.articleText) == "" {
+ return
+ }
+ message, err := mail.ReadMessage(strings.NewReader(a.articleText))
+ if err != nil {
+ dialog.ShowError(fmt.Errorf("cannot parse selected article: %w", err), a.window)
+ return
+ }
+ body, err := io.ReadAll(io.LimitReader(message.Body, 2<<20))
+ if err != nil {
+ dialog.ShowError(fmt.Errorf("cannot read selected article: %w", err), a.window)
+ return
+ }
+ groups := strings.TrimSpace(message.Header.Get("Followup-To"))
+ if groups == "" || strings.EqualFold(groups, "poster") {
+ groups = strings.TrimSpace(message.Header.Get("Newsgroups"))
+ }
+ a.composeGroups.SetText(groups)
+ subject := strings.TrimSpace(message.Header.Get("Subject"))
+ if !strings.HasPrefix(strings.ToLower(subject), "re:") {
+ subject = "Re: " + subject
+ }
+ a.composeSubject.SetText(subject)
+ references := strings.TrimSpace(message.Header.Get("References"))
+ messageID := strings.TrimSpace(message.Header.Get("Message-ID"))
+ if messageID != "" {
+ if references != "" {
+ references += " "
+ }
+ references += messageID
+ }
+ a.composeReferences.SetText(references)
+ a.composeFollowupTo.SetText(strings.TrimSpace(message.Header.Get("Followup-To")))
+ a.composeBody.SetText(quoteBody(string(body), message.Header.Get("From")))
+ a.status.SetText("Reply preparata con quoting e References. Controlla Followup-To prima dell'invio.")
+}
+
+func quoteBody(body, from string) string {
+ body = normalizeCRLF(body)
+ lines := strings.Split(strings.TrimSuffix(body, "\r\n"), "\r\n")
+ var builder strings.Builder
+ if strings.TrimSpace(from) != "" {
+ builder.WriteString("On behalf of ")
+ builder.WriteString(strings.TrimSpace(from))
+ builder.WriteString(" wrote:\r\n")
+ }
+ for _, line := range lines {
+ builder.WriteString("> ")
+ builder.WriteString(line)
+ builder.WriteString("\r\n")
+ }
+ return builder.String()
+}
+
+func (a *application) refreshArticleHeaders() {
+ if a.articleHeaders == nil {
+ return
+ }
+ text := formatArticleHeaders(a.articleText, a.showAllHeaders != nil && a.showAllHeaders.Checked)
+ verification := identity.VerifyArticle(a.articleText)
+ if verification.SignaturePresent || verification.PublicKey != "" {
+ status := "VFace invalid"
+ if verification.VFaceValid {
+ status = "VFace valid"
+ }
+ if verification.SignaturePresent {
+ if verification.SignatureValid {
+ status += "; Ed25519 signature valid"
+ } else {
+ status += "; Ed25519 signature invalid"
+ }
+ }
+ text += "\n\nVerification: " + status
+ if verification.Error != nil {
+ text += " (" + verification.Error.Error() + ")"
+ }
+ }
+ a.articleHeaders.SetText(text)
+}
+
+func formatArticleHeaders(article string, showAll bool) string {
+ if strings.TrimSpace(article) == "" {
+ return "No article selected."
+ }
+ if showAll {
+ raw := articleHeaderBlock(article)
+ if raw != "" {
+ return raw
+ }
+ }
+ message, err := mail.ReadMessage(strings.NewReader(article))
+ if err != nil {
+ raw := articleHeaderBlock(article)
+ if raw == "" {
+ return "The article headers could not be parsed."
+ }
+ return raw
+ }
+ important := []string{
+ "From", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To",
+ "Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type",
+ "Content-Transfer-Encoding", "Face", "OpenPGP", "X-OpenPGP", "X-Signature",
+ "X-Aegis-Crypto-Version", "X-Aegis-Encryption", "X-Aegis-Signature",
+ "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "X-Aegis-Public-Key-URL",
+ "X-VFace-Version", "X-Ed25519-Pub", "X-Ed25519-Sig", "Identity-Hash",
+ "X-VFace-Hash", "X-VFace-PNG-SHA256", "X-VFace-Verify",
+ }
+ var lines []string
+ for _, name := range important {
+ if value := strings.TrimSpace(message.Header.Get(name)); value != "" {
+ lines = append(lines, name+": "+value)
+ }
+ }
+ if len(lines) == 0 {
+ return "No recognized headers in this article."
+ }
+ return strings.Join(lines, "\n")
+}
+
+func articleHeaderBlock(article string) string {
+ article = strings.ReplaceAll(article, "\r\n", "\n")
+ article = strings.ReplaceAll(article, "\r", "\n")
+ if separator := strings.Index(article, "\n\n"); separator >= 0 {
+ return strings.TrimSpace(article[:separator])
+ }
+ return ""
+}
+
+func (a *application) postArticle() {
+ delivery := a.composeDelivery.Selected
+ settings := a.readSettingsForm()
+ if settings.SMTPHost == "" {
+ delivery = "NNTP direct posting"
+ }
+ client := a.currentClient()
+ if delivery != "SMTP mail2news" && client == nil {
+ dialog.ShowError(errors.New("connect to a server first"), a.window)
+ return
+ }
+ if err := settings.Validate(); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ groups, err := normalizeGroups(a.composeGroups.Text)
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ from := formatFrom(settings.DisplayName, settings.Email)
+ identityHeaders := []string(nil)
+ expectedPublicKey := ""
+ signingKey := strings.TrimSpace(a.composeCryptoSigningKey.Text)
+ if a.vfaceProfile != nil {
+ profile, profileErr := identity.GenerateVFace(a.vfaceProfile.Username, a.vfaceProfile.Email, a.vfaceProfile.PublicKey)
+ if profileErr != nil {
+ dialog.ShowError(fmt.Errorf("invalid loaded VFace identity: %w", profileErr), a.window)
+ return
+ }
+ from = formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email)
+ identityHeaders = profile.Headers()
+ expectedPublicKey = profile.PublicKey
+ if signingKey == "" {
+ signingKey = a.vfaceProfile.PrivateKey
+ }
+ }
+ subject := strings.TrimSpace(a.composeSubject.Text)
+ if from == "" || subject == "" || strings.ContainsAny(from+subject, "\r\n") {
+ dialog.ShowError(errors.New("From and Subject are required and must be one line"), a.window)
+ return
+ }
+ if _, err := mail.ParseAddress(from); err != nil {
+ dialog.ShowError(fmt.Errorf("invalid From address: %w", err), a.window)
+ return
+ }
+ var recipients []string
+ if delivery == "SMTP mail2news" {
+ recipient := strings.TrimSpace(a.composeMail2News.Text)
+ if recipient == "" {
+ recipient = strings.TrimSpace(settings.SMTPRecipient)
+ }
+ if recipient == "" {
+ dialog.ShowError(errors.New("mail2news recipient is required for SMTP delivery"), a.window)
+ return
+ }
+ if _, err := mail.ParseAddress(recipient); err != nil {
+ dialog.ShowError(fmt.Errorf("invalid mail2news recipient: %w", err), a.window)
+ return
+ }
+ recipients = []string{recipient}
+ }
+ article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{
+ Mode: a.composeCryptoMode.Selected,
+ EncryptionKey: a.composeCryptoKey.Text,
+ SigningKey: signingKey,
+ PublicKeyURL: a.composeCryptoKeyURL.Text,
+ ExpectedPublicKey: expectedPublicKey,
+ References: strings.TrimSpace(a.composeReferences.Text),
+ FollowupTo: strings.TrimSpace(a.composeFollowupTo.Text),
+ })
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ a.setBusy(true, "Posting article...")
+ go func() {
+ var postErr error
+ if delivery == "SMTP mail2news" {
+ smtpFrom := strings.TrimSpace(settings.SMTPEmail)
+ if smtpFrom == "" {
+ smtpFrom = from
+ }
+ postErr = smtpclient.Send(smtpclient.Config{
+ Host: settings.SMTPHost, Port: settings.SMTPPort, Mode: settings.SMTPMode,
+ Username: settings.SMTPUsername, Password: a.smtpPasswordEntry.Text,
+ InsecureSkipVerify: settings.SMTPSkipVerify,
+ ProxyType: settings.ProxyType, ProxyAddress: settings.ProxyAddress,
+ }, smtpFrom, recipients, []byte(article))
+ } else {
+ postErr = client.Post(article)
+ }
+ if postErr != nil {
+ a.asyncError("Posting failed", postErr)
+ return
+ }
+ fyne.Do(func() {
+ a.composeSubject.SetText("")
+ a.composeBody.SetText("")
+ a.composeReferences.SetText("")
+ a.composeFollowupTo.SetText("")
+ a.composeMail2News.SetText(settings.SMTPRecipient)
+ a.composeCryptoKey.SetText("")
+ a.composeCryptoSigningKey.SetText("")
+ a.composeCryptoKeyURL.SetText("")
+ a.composeCryptoMode.SetSelected("Plain")
+ a.setBusy(false, "Article accepted by the NNTP server.")
+ dialog.ShowInformation("Article posted", "The NNTP server accepted the article.", a.window)
+ })
+ }()
+}
+
+func buildTextArticle(groups []string, from, subject, body string) (string, error) {
+ return buildTextArticleWithCrypto(groups, from, subject, body, articleCryptoOptions{Mode: "Plain"})
+}
+
+type articleCryptoOptions struct {
+ Mode string
+ EncryptionKey string
+ SigningKey string
+ PublicKeyURL string
+ ExpectedPublicKey string
+ References string
+ FollowupTo string
+}
+
+func buildTextArticleWithCrypto(groups []string, from, subject, body string, options articleCryptoOptions) (string, error) {
+ face, err := identity.GenerateFace(from)
+ if err != nil {
+ return "", fmt.Errorf("generate Face header: %w", err)
+ }
+ return buildArticleWithIdentityHeaders(groups, from, subject, body, []string{identity.FormatFaceHeader(face)}, options)
+}
+
+func buildTextArticleWithVFace(groups []string, from, subject, body string, profile identity.VFace, options articleCryptoOptions) (string, error) {
+ if profile.IdentityHash == "" {
+ return buildArticleWithIdentityHeaders(groups, from, subject, body, nil, options)
+ }
+ return buildArticleWithIdentityHeaders(groups, from, subject, body, profile.Headers(), options)
+}
+
+func buildArticleWithIdentityHeaders(groups []string, from, subject, body string, identityHeaders []string, options articleCryptoOptions) (string, error) {
+ fromHeader, err := formatFromHeader(from)
+ if err != nil {
+ return "", err
+ }
+ if subject == "" || strings.ContainsAny(subject, "\r\n") {
+ return "", errors.New("Subject is required and must be one line")
+ }
+ if !utf8.ValidString(body) {
+ return "", errors.New("article body is not valid UTF-8")
+ }
+ messageID, err := generateMessageID()
+ if err != nil {
+ return "", err
+ }
+ body = strings.ReplaceAll(body, "\r\n", "\n")
+ body = strings.ReplaceAll(body, "\r", "\n")
+ body = strings.ReplaceAll(body, "\n", "\r\n")
+ mode := strings.TrimSpace(options.Mode)
+ if mode == "" {
+ mode = "Plain"
+ }
+ cryptoHeaders, contentType, contentTransferEncoding, wireBody, err := prepareArticleCrypto(body, mode, options)
+ if err != nil {
+ return "", err
+ }
+ headers := []string{
+ "From: " + fromHeader,
+ "Message-ID: " + messageID,
+ "Newsgroups: " + strings.Join(groups, ","),
+ "Subject: " + mime.QEncoding.Encode("UTF-8", subject),
+ "Date: " + time.Now().Format(time.RFC1123Z),
+ "User-Agent: Aegis/0.1",
+ "MIME-Version: 1.0",
+ "Content-Type: " + contentType,
+ "Content-Transfer-Encoding: " + contentTransferEncoding,
+ }
+ if options.References != "" {
+ if strings.ContainsAny(options.References, "\r\n") {
+ return "", errors.New("References must not contain line breaks")
+ }
+ headers = append(headers, foldHeader("References", options.References))
+ }
+ if options.FollowupTo != "" {
+ if !validFollowupTo(options.FollowupTo) {
+ return "", errors.New("Followup-To must be poster or valid newsgroup names")
+ }
+ headers = append(headers, foldHeader("Followup-To", options.FollowupTo))
+ }
+ headers = append(headers, identityHeaders...)
+ headers = append(headers, cryptoHeaders...)
+ article := strings.Join([]string{
+ strings.Join(headers, "\r\n"),
+ "",
+ wireBody,
+ }, "\r\n")
+ if err := nntp.ValidateArticle(article); err != nil {
+ return "", fmt.Errorf("article format is not Usenet-safe: %w", err)
+ }
+ return article, nil
+}
+
+func validFollowupTo(value string) bool {
+ if strings.EqualFold(strings.TrimSpace(value), "poster") {
+ return true
+ }
+ _, err := normalizeGroups(value)
+ return err == nil
+}
+
+func generateMessageID() (string, error) {
+ randomPart := make([]byte, 16)
+ if _, err := rand.Read(randomPart); err != nil {
+ return "", fmt.Errorf("generate Message-ID: %w", err)
+ }
+ return "<" + hex.EncodeToString(randomPart) + "@" + messageIDDomain + ">", nil
+}
+
+func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (headers []string, contentType, transferEncoding, wireBody string, err error) {
+ wireBody = body
+ contentType = "text/plain; charset=UTF-8"
+ transferEncoding = "8bit"
+ signingKey := strings.TrimSpace(options.SigningKey)
+ encryptionKey := strings.TrimSpace(options.EncryptionKey)
+ publicKeyURL := strings.TrimSpace(options.PublicKeyURL)
+ if publicKeyURL != "" {
+ parsed, parseErr := url.Parse(publicKeyURL)
+ if parseErr != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || strings.ContainsAny(publicKeyURL, "\r\n") {
+ return nil, "", "", "", errors.New("public-key URL must be a valid HTTPS URL without line breaks")
+ }
+ }
+
+ switch mode {
+ case "Plain":
+ if signingKey != "" || encryptionKey != "" || publicKeyURL != "" {
+ return nil, "", "", "", errors.New("plain mode does not accept cryptographic key fields")
+ }
+ case "Sign with Ed25519":
+ if signingKey == "" {
+ return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
+ }
+ case "Encrypt with age":
+ if encryptionKey == "" {
+ return nil, "", "", "", errors.New("an age recipient is required for encryption")
+ }
+ ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey)
+ if encryptErr != nil {
+ return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr)
+ }
+ wireBody = normalizeCRLF(string(ciphertext))
+ contentType = "application/vnd.aegis.age"
+ transferEncoding = "7bit"
+ case "Encrypt with age and sign":
+ if encryptionKey == "" || signingKey == "" {
+ return nil, "", "", "", errors.New("an age recipient and an Ed25519 private key are required")
+ }
+ ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey)
+ if encryptErr != nil {
+ return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr)
+ }
+ wireBody = normalizeCRLF(string(ciphertext))
+ contentType = "application/vnd.aegis.age"
+ transferEncoding = "7bit"
+ default:
+ return nil, "", "", "", fmt.Errorf("unsupported article crypto mode %q", mode)
+ }
+
+ if mode == "Sign with Ed25519" || mode == "Encrypt with age and sign" {
+ if signingKey == "" {
+ return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
+ }
+ signature, signErr := cryptokit.SignEd25519([]byte(wireBody), signingKey)
+ if signErr != nil {
+ return nil, "", "", "", fmt.Errorf("sign article body: %w", signErr)
+ }
+ publicKey, publicErr := cryptokit.Ed25519PublicKey(signingKey)
+ if publicErr != nil {
+ return nil, "", "", "", fmt.Errorf("derive Ed25519 public key: %w", publicErr)
+ }
+ fingerprint, fingerprintErr := cryptokit.Ed25519PublicKeyFingerprint(publicKey)
+ if fingerprintErr != nil {
+ return nil, "", "", "", fmt.Errorf("fingerprint Ed25519 public key: %w", fingerprintErr)
+ }
+ if options.ExpectedPublicKey != "" {
+ expectedKey, expectedErr := cryptokit.CanonicalEd25519PublicKey(options.ExpectedPublicKey)
+ if expectedErr != nil {
+ return nil, "", "", "", fmt.Errorf("canonicalize profile Ed25519 public key: %w", expectedErr)
+ }
+ if publicKey != expectedKey {
+ return nil, "", "", "", errors.New("signing key does not match the VFace profile public key")
+ }
+ }
+ headers = append(headers,
+ "X-Aegis-Crypto-Version: 1",
+ "X-Aegis-Signature: ed25519; "+signature,
+ "X-Aegis-Public-Key: "+publicKey,
+ "X-Ed25519-Sig: "+signature,
+ "X-Aegis-Key-Fingerprint: "+fingerprint,
+ )
+ if publicKeyURL != "" {
+ headers = append(headers, foldHeader("X-Aegis-Public-Key-URL", publicKeyURL))
+ }
+ } else if publicKeyURL != "" {
+ return nil, "", "", "", errors.New("public-key URL requires an Ed25519 signature mode")
+ }
+ if mode == "Encrypt with age" || mode == "Encrypt with age and sign" {
+ headers = append([]string{"X-Aegis-Crypto-Version: 1", "X-Aegis-Encryption: age"}, headers...)
+ }
+ return headers, contentType, transferEncoding, wireBody, nil
+}
+
+func normalizeCRLF(value string) string {
+ value = strings.ReplaceAll(value, "\r\n", "\n")
+ value = strings.ReplaceAll(value, "\r", "\n")
+ return strings.ReplaceAll(value, "\n", "\r\n")
+}
+
+func foldHeader(name, value string) string {
+ const maxHeaderValue = 76
+ if len(name)+2+len(value) <= 998 {
+ return name + ": " + value
+ }
+ var out strings.Builder
+ out.WriteString(name)
+ out.WriteString(":")
+ for len(value) > 0 {
+ out.WriteString("\r\n ")
+ limit := maxHeaderValue
+ if len(value) < limit {
+ limit = len(value)
+ }
+ out.WriteString(value[:limit])
+ value = value[limit:]
+ }
+ return out.String()
+}
+
+func formatFromHeader(value string) (string, error) {
+ address, err := mail.ParseAddress(strings.TrimSpace(value))
+ if err != nil {
+ return "", fmt.Errorf("invalid From address: %w", err)
+ }
+ if address.Name == "" {
+ return address.Address, nil
+ }
+ if isASCII(address.Name) {
+ return (&mail.Address{Name: address.Name, Address: address.Address}).String(), nil
+ }
+ return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">", nil
+}
+
+func isASCII(value string) bool {
+ for i := 0; i < len(value); i++ {
+ if value[i] > 0x7f {
+ return false
+ }
+ }
+ return true
+}
+
+func (a *application) filterGroups() {
+ query := strings.ToLower(strings.TrimSpace(a.groupSearch.Text))
+ a.visibleGroups = a.visibleGroups[:0]
+ for _, group := range a.groups {
+ if query == "" || strings.Contains(strings.ToLower(group.Name), query) {
+ a.visibleGroups = append(a.visibleGroups, group)
+ }
+ }
+ if a.groupList != nil {
+ a.groupList.Refresh()
+ }
+}
+
+func (a *application) filterHeaders() {
+ query := strings.ToLower(strings.TrimSpace(a.headerSearch.Text))
+ a.visibleHeader = a.visibleHeader[:0]
+ for _, header := range a.headers {
+ if query != "" && !strings.Contains(strings.ToLower(header.Subject), query) && !strings.Contains(strings.ToLower(header.From), query) && !strings.Contains(strings.ToLower(header.MessageID), query) && !strings.Contains(strings.ToLower(header.References), query) {
+ continue
+ }
+ article := filter.Article{From: header.From, Subject: header.Subject, Newsgroups: a.loadedGroup, MessageID: header.MessageID, References: header.References, Read: a.headerRead(header)}
+ result, err := filter.Evaluate(article, a.filterRules)
+ if err != nil {
+ a.status.SetText("Filter error: " + err.Error())
+ continue
+ }
+ if result.Hidden || result.MuteThread {
+ continue
+ }
+ a.visibleHeader = append(a.visibleHeader, header)
+ }
+ if a.headerList != nil {
+ a.headerList.Refresh()
+ }
+}
+
+func articleKey(group string, header nntp.ArticleHeader) string {
+ if header.MessageID != "" {
+ return group + ":" + header.MessageID
+ }
+ return fmt.Sprintf("%s:%d", group, header.Number)
+}
+
+func (a *application) headerRead(header nntp.ArticleHeader) bool {
+ if a.state == nil {
+ return false
+ }
+ article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)]
+ return ok && article.Read
+}
+
+func (a *application) headerBookmarked(header nntp.ArticleHeader) bool {
+ if a.state == nil {
+ return false
+ }
+ article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)]
+ return ok && article.Bookmarked
+}
+
+func (a *application) refreshArticleActions() {
+ if a.markRead == nil || a.bookmark == nil || a.reply == nil {
+ return
+ }
+ if !a.hasSelectedHeader {
+ a.markRead.Disable()
+ a.bookmark.Disable()
+ a.reply.Disable()
+ return
+ }
+ a.markRead.Enable()
+ a.bookmark.Enable()
+ if strings.TrimSpace(a.articleText) == "" {
+ a.reply.Disable()
+ } else {
+ a.reply.Enable()
+ }
+ if a.headerRead(a.selectedHeader) {
+ a.markRead.SetText("Mark unread")
+ } else {
+ a.markRead.SetText("Mark read")
+ }
+ if a.headerBookmarked(a.selectedHeader) {
+ a.bookmark.SetText("Remove bookmark")
+ } else {
+ a.bookmark.SetText("Bookmark")
+ }
+}
+
+func (a *application) toggleRead() {
+ if a.state == nil || !a.hasSelectedHeader {
+ return
+ }
+ key := articleKey(a.loadedGroup, a.selectedHeader)
+ _ = a.state.SetRead(key, !a.headerRead(a.selectedHeader))
+ _ = a.state.Save()
+ a.refreshArticleActions()
+ a.headerList.Refresh()
+}
+
+func (a *application) toggleBookmark() {
+ if a.state == nil || !a.hasSelectedHeader {
+ return
+ }
+ key := articleKey(a.loadedGroup, a.selectedHeader)
+ _ = a.state.SetBookmarked(key, !a.headerBookmarked(a.selectedHeader))
+ _ = a.state.Save()
+ a.refreshArticleActions()
+ a.headerList.Refresh()
+}
+
+func (a *application) isSubscribed(group string) bool {
+ for _, subscribed := range a.settings.Subscriptions {
+ if subscribed == group {
+ return true
+ }
+ }
+ return false
+}
+
+func (a *application) setBusy(busy bool, message string) {
+ if busy {
+ a.progress.Show()
+ } else {
+ a.progress.Hide()
+ }
+ a.status.SetText(message)
+}
+
+func (a *application) asyncError(title string, err error) {
+ fyne.Do(func() {
+ a.setBusy(false, title+".")
+ dialog.ShowError(fmt.Errorf("%s: %w", title, err), a.window)
+ })
+}
+
+func (a *application) currentClient() *nntp.Client {
+ a.clientMu.RLock()
+ defer a.clientMu.RUnlock()
+ return a.client
+}
+
+func (a *application) replaceClient(client *nntp.Client) {
+ a.clientMu.Lock()
+ old := a.client
+ a.client = client
+ a.clientMu.Unlock()
+ if old != nil {
+ _ = old.Close()
+ }
+}
+
+func (a *application) closeClient() {
+ a.clientMu.Lock()
+ client := a.client
+ a.client = nil
+ a.clientMu.Unlock()
+ if client != nil {
+ _ = client.Close()
+ }
+}
+
+func normalizeGroups(value string) ([]string, error) {
+ parts := strings.Split(value, ",")
+ groups := make([]string, 0, len(parts))
+ seen := make(map[string]struct{}, len(parts))
+ for _, part := range parts {
+ group := strings.TrimSpace(part)
+ if group == "" {
+ continue
+ }
+ if err := config.ValidateGroupName(group); err != nil {
+ return nil, err
+ }
+ if _, ok := seen[group]; ok {
+ continue
+ }
+ seen[group] = struct{}{}
+ groups = append(groups, group)
+ }
+ if len(groups) == 0 {
+ return nil, errors.New("at least one newsgroup is required")
+ }
+ return groups, nil
+}
+
+func formatFrom(name, email string) string {
+ if email == "" {
+ return ""
+ }
+ return (&mail.Address{Name: name, Address: email}).String()
+}
+
+func formatCount(value int64) string {
+ if value < 1000 {
+ return strconv.FormatInt(value, 10)
+ }
+ parts := make([]string, 0, 4)
+ for value > 0 {
+ part := value % 1000
+ value /= 1000
+ if value > 0 {
+ parts = append(parts, fmt.Sprintf("%03d", part))
+ } else {
+ parts = append(parts, strconv.FormatInt(part, 10))
+ }
+ }
+ for left, right := 0, len(parts)-1; left < right; left, right = left+1, right-1 {
+ parts[left], parts[right] = parts[right], parts[left]
+ }
+ return strings.Join(parts, " ")
+}