// 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/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) } 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() 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.TextTruncateEllipsis avatar_uri := v.Account.Avatar u, err := storage.ParseURI(avatar_uri) if err != nil { continue } im := canvas.NewImageFromURI(u) im.FillMode = canvas.ImageFillContain fyne.Do(func(){Timeline.Add(im) Timeline.Add(widget.NewRichTextFromMarkdown(fmt.Sprintf("%s %s your post", v.Account.Username, v.Type))) Timeline.Add(label) Timeline.Add(widget.NewLabel(timestring)) }) } //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() }