473 lines
13 KiB
Go
473 lines
13 KiB
Go
// TODO: RENAME ALL OCCURENCES OF FEDERALE WITH HASHBANG
|
|
package main
|
|
|
|
import (
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/app"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/storage"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/widget"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/kirsle/configdir"
|
|
"github.com/mattn/go-mastodon"
|
|
"github.com/k3a/html2text"
|
|
/*webview "github.com/webview/webview_go"*/
|
|
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"net/url"
|
|
_ "net/http"
|
|
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
_ "time"
|
|
"strings"
|
|
)
|
|
|
|
var App fyne.App
|
|
var MainWindow fyne.Window
|
|
|
|
var Version string = "25w47a"
|
|
|
|
// Client used for posting, getting posts, etc.
|
|
var Client *mastodon.Client
|
|
|
|
// Hashbang config settings apply to all profiles.
|
|
// The config stores the name of the profile to launch,
|
|
// as well as if the profile selection screen should
|
|
// show when the program is next launched.
|
|
|
|
type HashbangConfig struct {
|
|
ProfileName string
|
|
DoNotDropToProfileSelection bool
|
|
}
|
|
|
|
type HashbangProfile struct { // Blueprint for a Hashbang profile
|
|
Name string // Name displayed to user
|
|
InternalName string // Filename
|
|
|
|
Server string // Homeserver to connect to
|
|
Username string // Username of user (user@domain)
|
|
|
|
ClientID string // ID of the client
|
|
ClientSecret string // Secret of the client
|
|
|
|
UserAuthorizationCode string // Authorization code of the user
|
|
|
|
Running bool // Whether the profile is currently running
|
|
}
|
|
|
|
var LoadedProfile *HashbangProfile // Profile currently loaded into memory for this Hashbang instance
|
|
var LoadedConfig *HashbangConfig // Config currently loaded into memory
|
|
var Profiles []*HashbangProfile // Profiles loaded from FS go here.
|
|
|
|
var ProfileSetupDone bool = false
|
|
var ProfileSetupProfile *HashbangProfile
|
|
|
|
var ProfileSelectionDone bool = false
|
|
var ProfileSelectionProfile *HashbangProfile
|
|
|
|
// The visibility that the post is going to be posted with
|
|
var PostVisibility string
|
|
|
|
// This function saves the config in memory to disk.
|
|
func SaveConfigToDisk() error {
|
|
ConfigPath := configdir.LocalConfig("hashbang") // Hashbang foler in the user's config directory
|
|
|
|
err := configdir.MakePath(ConfigPath) // Ensure it exists.
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ConfigFilePath := filepath.Join(ConfigPath, "hashbang.json")
|
|
b, err := json.MarshalIndent(LoadedConfig, "", "\t")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Println("Saving configuration to disk")
|
|
|
|
err = os.WriteFile(filepath.Join(ConfigFilePath), b, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// This function asks for the profile to launch Hashbang with.
|
|
func ProfileLaunch() {
|
|
ConfigPath := configdir.LocalConfig("hashbang") // Hashbang foler in the user's config directory
|
|
|
|
err := configdir.MakePath(ConfigPath) // Ensure it exists.
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
Files, err := os.ReadDir(ConfigPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
for _, v := range Files {
|
|
if !v.IsDir() {
|
|
if v.Name() == "hashbang.json" {
|
|
continue
|
|
}
|
|
Profile := new(HashbangProfile)
|
|
dat, err := os.ReadFile(filepath.Join(ConfigPath, v.Name()))
|
|
if err != nil {
|
|
log.Println("ERR - " + err.Error())
|
|
continue
|
|
}
|
|
|
|
err = json.Unmarshal(dat, Profile)
|
|
if err != nil {
|
|
log.Println("ERR - " + err.Error())
|
|
continue
|
|
}
|
|
|
|
Profiles = append(Profiles, Profile)
|
|
}
|
|
}
|
|
|
|
App = app.New()
|
|
MainWindow = App.NewWindow("Select a profile")
|
|
Box := container.NewVBox()
|
|
Box.Add(widget.NewRichTextFromMarkdown("# Please pick a profile"))
|
|
Box.Add(widget.NewLabel(fmt.Sprintf("There are %d profile(s) available.", len(Profiles))))
|
|
ProfileSelection := container.NewVBox(
|
|
container.NewHBox(
|
|
widget.NewButton("+ Add profile", func() {
|
|
AddProfileBox := container.NewVBox()
|
|
InstanceEntry := widget.NewEntry()
|
|
InstanceEntry.SetPlaceHolder("https://example.com")
|
|
|
|
AddProfileWindow := App.NewWindow("Add a profile")
|
|
AddProfileWindow.SetFixedSize(true)
|
|
|
|
GoButton := widget.NewButton("Go", func() {
|
|
if !strings.HasPrefix(InstanceEntry.Text, "https://") {
|
|
InstanceEntry.SetText("https://" + InstanceEntry.Text) // FIXME: This may not work with darknet instances?
|
|
}
|
|
Domain := InstanceEntry.Text
|
|
// Step one: register the application
|
|
AppConfig := &mastodon.AppConfig{
|
|
Server: Domain,
|
|
ClientName: "Hashbang",
|
|
Scopes: "read write push",
|
|
Website: "https://forge.sunglocto.net/sunglocto/Hashbang",
|
|
RedirectURIs: "urn:ietf:wg:oauth:2.0:oob",
|
|
}
|
|
|
|
app, err := mastodon.RegisterApp(context.Background(), AppConfig)
|
|
if err != nil {
|
|
dialog.ShowError(err, AddProfileWindow)
|
|
return
|
|
}
|
|
|
|
fmt.Println("App successfully created:\n", app)
|
|
// Step two: the user now needs to log in
|
|
|
|
/*
|
|
Webview := webview.New(false)
|
|
defer Webview.Destroy()
|
|
Webview.SetTitle("Authenticate - copy the authorisation code when logged in.")
|
|
Webview.SetSize(480, 320, webview.HintNone)
|
|
Webview.Navigate(app.AuthURI)
|
|
Webview.Run()
|
|
*/
|
|
u, err := url.Parse(app.AuthURI)
|
|
if err != nil {
|
|
dialog.ShowError(err, AddProfileWindow)
|
|
return
|
|
}
|
|
|
|
App.OpenURL(u)
|
|
|
|
AddProfileWindow.Resize(fyne.NewSize(500, 500))
|
|
|
|
// Step three: get the authorization code from the user
|
|
dialog.ShowConfirm("Confirm", "Do you have an authorisation code?", func(b bool) {
|
|
if b {
|
|
NewProfile := new(HashbangProfile)
|
|
NewProfile.Name = fmt.Sprintf("Profile %d", len(Profiles)+1)
|
|
NewProfile.InternalName = uuid.New().String()
|
|
NewProfile.Server = Domain
|
|
NewProfile.ClientID = app.ClientID
|
|
NewProfile.ClientSecret = app.ClientSecret
|
|
|
|
AuthPasswordWidget := widget.NewPasswordEntry()
|
|
|
|
AuthCodeInput := new(widget.FormItem)
|
|
AuthCodeInput.Text = "Authorization code"
|
|
AuthCodeInput.Widget = AuthPasswordWidget
|
|
|
|
var FormItems []*widget.FormItem
|
|
FormItems = append(FormItems, AuthCodeInput)
|
|
|
|
dialog.ShowForm("Enter authorization code", "Continue", "Exit", FormItems, func(b bool) {
|
|
if b {
|
|
config := &mastodon.Config{
|
|
Server: NewProfile.Server,
|
|
ClientID: NewProfile.ClientID,
|
|
ClientSecret: NewProfile.ClientSecret,
|
|
}
|
|
|
|
// Create the client
|
|
c := mastodon.NewClient(config)
|
|
|
|
err = c.GetUserAccessToken(context.Background(), AuthPasswordWidget.Text, app.RedirectURI)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
NewProfile.UserAuthorizationCode = c.Config.AccessToken
|
|
fmt.Println("Successfully created user profile:\n", NewProfile)
|
|
// Save profile to disk
|
|
b, err := json.MarshalIndent(NewProfile, "", "\t")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
err = os.WriteFile(filepath.Join(ConfigPath, NewProfile.InternalName+".json"), b, 0644)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
ProfileSetupDone = true
|
|
ProfileSetupProfile = NewProfile
|
|
AddProfileWindow.Close()
|
|
MainWindow.Close()
|
|
}
|
|
}, AddProfileWindow)
|
|
}
|
|
}, AddProfileWindow)
|
|
})
|
|
GoButton.Importance = widget.HighImportance
|
|
|
|
AddProfileBox.Add(widget.NewLabel("Hashbang will log in via OAuth2\nInstance:"))
|
|
AddProfileBox.Add(InstanceEntry)
|
|
AddProfileBox.Add(GoButton)
|
|
|
|
AddProfileWindow.SetContent(AddProfileBox)
|
|
AddProfileWindow.Show()
|
|
}),
|
|
),
|
|
)
|
|
|
|
for _, v := range Profiles {
|
|
|
|
ProfileSelection.Add(
|
|
container.NewHBox(
|
|
widget.NewLabel(v.Name),
|
|
widget.NewButton("Log in", func() {
|
|
ProfileSelectionDone = true
|
|
ProfileSelectionProfile = v
|
|
MainWindow.Close()
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
Box.Add(ProfileSelection)
|
|
RootBox := container.New(layout.NewCenterLayout(), Box)
|
|
|
|
MainWindow.SetContent(RootBox)
|
|
MainWindow.SetFixedSize(true)
|
|
MainWindow.ShowAndRun()
|
|
|
|
if ProfileSetupDone { // A new profile was created. Return this new profile.
|
|
LoadedConfig.ProfileName = ProfileSetupProfile.InternalName
|
|
LoadedConfig.DoNotDropToProfileSelection = true
|
|
err = SaveConfigToDisk()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
App.SendNotification(fyne.NewNotification("Done", "Relaunch the application"))
|
|
} else { // A profile was either picked from the list OR no profile was picked by the user.
|
|
if ProfileSelectionDone {
|
|
LoadedConfig.ProfileName = ProfileSelectionProfile.InternalName
|
|
LoadedConfig.DoNotDropToProfileSelection = true
|
|
err = SaveConfigToDisk()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
App.SendNotification(fyne.NewNotification("Done", "Relaunch the application"))
|
|
} else {
|
|
panic(errors.New("no profile specified"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
|
|
log.Println("Checking for hashbang config")
|
|
|
|
ConfigPath := configdir.LocalConfig("hashbang") // Hashbang foler in the user's config directory
|
|
|
|
err := configdir.MakePath(ConfigPath) // Ensure it exists.
|
|
log.Println("Creating hashbang folder if it does not exist")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
log.Println("Checking if configuration file exists")
|
|
// Check if the Hashbang configuration file exists
|
|
_, err = os.Stat(filepath.Join(ConfigPath, "hashbang.json"))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
log.Println("Creating new configuration")
|
|
// Create a new configuration
|
|
EmptyConfig := new(HashbangConfig)
|
|
EmptyConfig.DoNotDropToProfileSelection = false
|
|
log.Println("Converting configuration to JSON")
|
|
b, err := json.MarshalIndent(EmptyConfig, "", "\t")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
log.Println("Saving configuration to disk")
|
|
|
|
err = os.WriteFile(filepath.Join(ConfigPath, "hashbang.json"), b, 0644)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
} else if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Read the config from disk
|
|
log.Println("Grabbing config from disk")
|
|
b, err := os.ReadFile(filepath.Join(ConfigPath, "hashbang.json"))
|
|
// You get the gist by now
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
log.Println("Unmarshalling JSON")
|
|
|
|
tempconf := new(HashbangConfig)
|
|
err = json.Unmarshal(b, tempconf)
|
|
fmt.Println(tempconf)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
LoadedConfig = tempconf
|
|
|
|
if !LoadedConfig.DoNotDropToProfileSelection || (len(os.Args) > 1 && os.Args[1] == "new") {
|
|
log.Println("Launching profile selection")
|
|
ProfileLaunch()
|
|
return
|
|
}
|
|
|
|
ProfilePath := filepath.Join(ConfigPath, LoadedConfig.ProfileName+".json")
|
|
b, err = os.ReadFile(ProfilePath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
tempprof := new(HashbangProfile)
|
|
log.Println("Unmarshalling config to RAM")
|
|
err = json.Unmarshal(b, tempprof)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
LoadedProfile = tempprof
|
|
|
|
////////////////////////////////////////////////////
|
|
|
|
config := &mastodon.Config{
|
|
Server: LoadedProfile.Server,
|
|
ClientID: LoadedProfile.ClientID,
|
|
ClientSecret: LoadedProfile.ClientSecret,
|
|
AccessToken: LoadedProfile.UserAuthorizationCode,
|
|
}
|
|
|
|
Client = mastodon.NewClient(config)
|
|
log.Println(Client)
|
|
|
|
App = app.New()
|
|
MainWindow = App.NewWindow("Hashbang")
|
|
TootEntry := widget.NewMultiLineEntry()
|
|
ReplyIDEntry := widget.NewEntry()
|
|
ReplyIDLabel := widget.NewLabel("In reply to")
|
|
ReplyBox := container.NewGridWithColumns(2, ReplyIDLabel, ReplyIDEntry)
|
|
|
|
Timeline := container.NewGridWithColumns(4)
|
|
|
|
VisibilitySelector := widget.NewSelect([]string{"public", "unlisted", "private", "direct"}, func(value string) {
|
|
PostVisibility = value
|
|
})
|
|
|
|
ShowNotifications := func() {
|
|
Timeline.RemoveAll()
|
|
pg := new(mastodon.Pagination)
|
|
//NewTimeline := container.NewVBox(widget.NewLabel("Notifications"))
|
|
notis, err := Client.GetNotifications(context.Background(), pg)
|
|
if err != nil {
|
|
Timeline.Objects[0] = widget.NewLabel(fmt.Sprintf("Error getting notifications: %s", err.Error()))
|
|
return
|
|
}
|
|
|
|
for _, v := range notis {
|
|
timeb, err := v.CreatedAt.MarshalText()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
timestring := string(timeb)
|
|
var label *widget.Label
|
|
if v.Status != nil {
|
|
label = widget.NewLabel(html2text.HTML2Text(v.Status.Content))
|
|
} else {
|
|
label = widget.NewLabel("No content set")
|
|
}
|
|
//label.Truncation = fyne.TextTruncateClip
|
|
label.Wrapping = fyne.TextWrapWord
|
|
avatar_uri := v.Account.Avatar
|
|
u, err := storage.ParseURI(avatar_uri)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
|
|
im := canvas.NewImageFromURI(u)
|
|
im.FillMode = canvas.ImageFillCover
|
|
fyne.Do(func(){Timeline.Add(im)
|
|
ml := widget.NewRichTextFromMarkdown(fmt.Sprintf("%s %s your post", v.Account.Username, v.Type))
|
|
ml.Wrapping = fyne.TextWrapWord
|
|
Timeline.Add(ml)
|
|
Timeline.Add(label)
|
|
tw := widget.NewLabel(timestring)
|
|
tw.Wrapping = fyne.TextWrapWord
|
|
Timeline.Add(tw)
|
|
})
|
|
}
|
|
//Timeline = NewTimeline
|
|
}
|
|
|
|
go ShowNotifications()
|
|
|
|
RefreshNotis := widget.NewButton("Refresh", ShowNotifications)
|
|
|
|
MainWindow.SetContent(container.NewHSplit(container.NewVBox(TootEntry, ReplyBox, VisibilitySelector, widget.NewButton("Post", func() {
|
|
toot := mastodon.Toot{
|
|
Status: TootEntry.Text,
|
|
Visibility: PostVisibility,
|
|
InReplyToID: mastodon.ID(ReplyIDEntry.Text),
|
|
}
|
|
|
|
_, err := Client.PostStatus(context.Background(), &toot)
|
|
if err != nil {
|
|
dialog.ShowError(err, MainWindow)
|
|
}
|
|
}), RefreshNotis), container.NewVScroll(Timeline)))
|
|
MainWindow.ShowAndRun()
|
|
}
|