package main import ( "os" "sync" "context" "fmt" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" "path/filepath" "github.com/BurntSushi/toml" "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" "mellium.im/xmpp/jid" "time" _ "embed" "encoding/xml" "math/rand/v2" "runtime" "encoding/base64" ) var loadedConfig lambdaConfig var empty_dialog *gtk.Label // var msgs *gtk.ListBox var content *gtk.Widgetter // var tabs map[string]*chatTab = make(map[string]*chatTab) var tabs sync.Map var current string var scroller *gtk.ScrolledWindow var memberList *gtk.ScrolledWindow var menu *gtk.Box //go:embed style.css var styleCSS string var client xmpp.Sender var clientroot *xmpp.Client var uiQueue = make(chan func(), 100) var window *gtk.ApplicationWindow // stores members of mucs var mucmembers sync.Map // stores devices of users var userdevices sync.Map //go:embed debug.png var defaultAvatarBytes []byte var defaultAvatarB64 string = base64.StdEncoding.EncodeToString(defaultAvatarBytes) //go:embed assets/owner.png var ownerMedalBytes []byte var ownerMedalB64 string = base64.StdEncoding.EncodeToString(ownerMedalBytes) //go:embed assets/admin.png var adminMedalBytes []byte var adminMedalB64 string = base64.StdEncoding.EncodeToString(adminMedalBytes) //go:embed assets/member.png var memberMedalBytes []byte var memberMedalB64 string = base64.StdEncoding.EncodeToString(memberMedalBytes) //go:embed assets/noaffiliation.png var noneMedalBytes []byte var noneMedalB64 string = base64.StdEncoding.EncodeToString(noneMedalBytes) //go:embed assets/outcast.png var outcastMedalBytes []byte var outcastMedalB64 string = base64.StdEncoding.EncodeToString(outcastMedalBytes) //go:embed assets/cancel.png var cancelBytes []byte var cancelB64 string = base64.StdEncoding.EncodeToString(cancelBytes) var clientAssets map[string]gdk.Paintabler = make(map[string]gdk.Paintabler) var lockedJIDs map[string]bool = make(map[string]bool) func init() { go func() { for fn := range uiQueue { glib.IdleAdd(func() bool { fn() return false }) time.Sleep(10 * time.Millisecond) // Small delay between updates } }() loader := gdkpixbuf.NewPixbufLoader() defaultAvatarData, _ := base64.StdEncoding.DecodeString(defaultAvatarB64) loader.Write(defaultAvatarData) loader.Close() clientAssets["DefaultAvatar"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) loader = gdkpixbuf.NewPixbufLoader() ownerMedalData, _ := base64.StdEncoding.DecodeString(ownerMedalB64) loader.Write(ownerMedalData) loader.Close() clientAssets["owner"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) loader = gdkpixbuf.NewPixbufLoader() cancelData, _ := base64.StdEncoding.DecodeString(cancelB64) loader.Write(cancelData) loader.Close() clientAssets["cancel"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) loader = gdkpixbuf.NewPixbufLoader() adminMedalData, _ := base64.StdEncoding.DecodeString(adminMedalB64) loader.Write(adminMedalData) loader.Close() clientAssets["admin"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) loader = gdkpixbuf.NewPixbufLoader() memberMedalData, _ := base64.StdEncoding.DecodeString(memberMedalB64) loader.Write(memberMedalData) loader.Close() clientAssets["member"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) loader = gdkpixbuf.NewPixbufLoader() noneMedalData, _ := base64.StdEncoding.DecodeString(noneMedalB64) loader.Write(noneMedalData) loader.Close() clientAssets["none"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) loader = gdkpixbuf.NewPixbufLoader() outcastMedalData, _ := base64.StdEncoding.DecodeString(outcastMedalB64) loader.Write(outcastMedalData) loader.Close() clientAssets["outcast"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) } func main() { p, err := ensureConfig() if err != nil { panic(err) } b, err := os.ReadFile(filepath.Join(p, "lambda.toml")) if err != nil { dropToSignInPage(err) return // panic(err) } _, err = toml.Decode(string(b), &loadedConfig) if err != nil { panic(err) } // Put 4 random characters at the end chars := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZλ" str := "" for range 4 { str = str + string(chars[rand.IntN(len(chars))]) } config := xmpp.Config{ TransportConfiguration: xmpp.TransportConfiguration{ Address: loadedConfig.Server, }, Jid: loadedConfig.Username + "/lambda." + str, Credential: xmpp.Password(loadedConfig.Password), Insecure: loadedConfig.Insecure, // StreamLogger: os.Stdout, } router := xmpp.NewRouter() router.NewRoute().IQNamespaces(stanza.NSDiscoInfo).HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { iq, ok := p.(*stanza.IQ) if !ok || iq.Type != stanza.IQTypeGet { return } iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) if err != nil { panic(err) } identity := stanza.Identity{ Name: "Lambda", Category: "client", // TODO: Allow spoofing on user request Type: "pc", } payload := stanza.DiscoInfo{ XMLName: xml.Name{ Space: stanza.NSDiscoInfo, Local: "query", }, Identity: []stanza.Identity{identity}, Features: []stanza.Feature{ {Var: stanza.NSDiscoInfo}, {Var: stanza.NSDiscoItems}, {Var: "jabber:iq:version"}, {Var: "urn:xmpp:delegation:1"}, {Var: "http://jabber.org/protocol/muc"}, {Var: "urn:xmpp:reply:0"}, {Var: "λ"}, }, } iqResp.Payload = &payload s.Send(iqResp) }) router.NewRoute().IQNamespaces("jabber:iq:version").HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { iq, ok := p.(*stanza.IQ) if !ok || iq.Type != stanza.IQTypeGet { return } iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) if err != nil { panic(err) } v := &stanza.Version{} v = v.SetInfo("Lambda", lambda_version, runtime.GOOS) // TODO: Allow spoofing on user request iqResp.Payload = v s.Send(iqResp) }) router.HandleFunc("message", func(s xmpp.Sender, p stanza.Packet) { m, ok := p.(stanza.Message) if !ok { return } e := stanza.PubSubEvent{} ok = m.Get(&e) if ok { fmt.Println(e) } /* if m.Body == "" { return } */ originator := jid.MustParse(m.From).Bare().String() glib.IdleAdd(func() { //uiQueue <- func() { b := gtk.NewBox(gtk.OrientationVertical, 0) ba, ok := generateMessageWidget(p).(*gtk.Box) if ok { b = ba } tab, ok := tabs.Load(originator) typed_tab := tab.(*chatTab) if ok { typed_tab.msgs.Append(b) scrollToBottomAfterUpdate(scroller) } else { fmt.Println("Got message when the tab does not exist!") } //} }) }) router.HandleFunc("presence", func(s xmpp.Sender, p stanza.Packet) { presence, ok := p.(stanza.Presence) if !ok { return } if presence.Error != *new(stanza.Err) { return } var mu MucUser var ocu OccupantID ok = presence.Get(&mu) if ok { // This is a presence stanza from a user in a MUC presence.Get(&ocu) from, _ := stanza.NewJid(presence.From) muc := from.Bare() _, ok = mucmembers.Load(muc) if !ok { mucmembers.Store(muc, mucUnit{}) } unit, ok := mucmembers.Load(muc) if !ok { return } typed_unit := unit.(mucUnit) if presence.Type != "unavailable" { typed_unit.Members.Store(ocu.ID, presence) } else { typed_unit.Members.Delete(ocu.ID) glib.IdleAdd(func() { b := gtk.NewLabel("") ba, ok := generatePresenceWidget(p).(*gtk.Label) if ok { b = ba } tab, ok := tabs.Load(muc) typed_tab := tab.(*chatTab) if ok { typed_tab.msgs.Append(b) scrollToBottomAfterUpdate(scroller) } else { fmt.Println("Got message when the tab does not exist!") } }) } mucmembers.Store(muc, typed_unit) } else { // This is a presence stanza from a regular user // The code is basically the exact same as above, we just don't check for mucuser user := jid.MustParse(presence.From).Bare().String() _, ok := userdevices.Load(user) _, mok := mucmembers.Load(user) if !ok && !mok { // FIXME: The initial muc presence gets picked up from this check ok := createTab(user, false) if ok && !lockedJIDs[user]{ userdevices.Store(user, userUnit{}) b := gtk.NewButtonWithLabel(user) b.ConnectClicked(func() { b.AddCSSClass("accent") switchToTab(user, &window.Window) }) menu.Append(b) } } unit, ok := userdevices.Load(user) if !ok { return } resource := jid.MustParse(presence.From).Resourcepart() typed_unit := unit.(userUnit) if presence.Type != "unavailable" { typed_unit.Devices.Store(resource, presence) } else { typed_unit.Devices.Delete(resource) } userdevices.Store(user, typed_unit) lockedJIDs[user] = true } time.Sleep(1 * time.Second) }) c, err := xmpp.NewClient(&config, router, func(err error) { showErrorDialog(err) panic(err) }) if err != nil { showErrorDialog(err) panic(err) } client = c clientroot = c cm := xmpp.NewStreamManager(c, func(c xmpp.Sender) { fmt.Println("XMPP client connected") /* */ }) go func() { time.Sleep(3 * time.Second) err = cm.Run() if err != nil { showErrorDialog(err) panic(err) } }() app := gtk.NewApplication("net.sunglocto.lambda", gio.ApplicationFlagsNone) app.ConnectActivate(func() { activate(app) }) if code := app.Run(os.Args); code > 0 { os.Exit(code) } } func activate(app *gtk.Application) { // Load the CSS and apply it globally. gtk.StyleContextAddProviderForDisplay( gdk.DisplayGetDefault(), loadCSS(styleCSS), gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) window = gtk.NewApplicationWindow(app) the_menu := gio.NewMenu() fileMenu := gio.NewMenu() fileMenu.Append("Join MUC", "app.join") fileMenu.Append("Start DM", "app.dm") joinAction := gio.NewSimpleAction("join", nil) joinAction.ConnectActivate(func(p *glib.Variant) { box := gtk.NewBox(gtk.OrientationVertical, 0) jid_box := gtk.NewBox(gtk.OrientationHorizontal, 0) nick_box := gtk.NewBox(gtk.OrientationHorizontal, 0) jid_entry := gtk.NewEntry() nick_entry := gtk.NewEntry() jid_entry.SetHAlign(gtk.AlignEnd) jid_entry.SetHExpand(true) nick_entry.SetHAlign(gtk.AlignEnd) nick_entry.SetHExpand(true) nick_entry.SetText(loadedConfig.Nick) jid_box.Append(gtk.NewLabel("MUC JID:")) jid_box.Append(jid_entry) nick_box.Append(gtk.NewLabel("Nick:")) nick_box.Append(nick_entry) box.Append(jid_box) box.Append(nick_box) btn := gtk.NewButtonWithLabel("Submit") btn.SetVAlign(gtk.AlignBaseline) box.Append(btn) win := gtk.NewWindow() win.SetTitle("Join MUC") win.SetDefaultSize(200, 200) win.SetChild(box) btn.ConnectClicked(func() { t := jid_entry.Text() _, ok := tabs.Load(t) if !ok { err := joinMuc(client, clientroot.Session.BindJid, t, nick_entry.Text()) if err != nil { panic(err) } createTab(t, true) b := gtk.NewButtonWithLabel(t) b.ConnectClicked(func() { b.AddCSSClass("accent") switchToTab(t, &window.Window) }) menu.Append(b) } win.SetVisible(false) }) win.SetTransientFor(win) win.Present() }) app.AddAction(joinAction) the_menu.AppendSubmenu("File", fileMenu) the_menuBar := gtk.NewPopoverMenuBarFromModel(the_menu) app.SetMenubar(gio.NewMenu()) window.SetTitle("Lambda") window.Window.AddCSSClass("ssd") window.Window.SetDefaultSize(500, 500) menu = gtk.NewBox(gtk.OrientationVertical, 0) empty_dialog = gtk.NewLabel("You are not focused on any chats.") empty_dialog.SetVExpand(true) scroller = gtk.NewScrolledWindow() memberList = gtk.NewScrolledWindow() scroller.SetHExpand(true) memberList.SetHExpand(true) box := gtk.NewBox(gtk.OrientationVertical, 0) box.Append(the_menuBar) // scroller.SetChild(empty_dialog) scroller.SetChild(empty_dialog) menu_scroll := gtk.NewScrolledWindow() menu_scroll.SetHExpand(true) menu_scroll.SetChild(menu) // box.Append(menu_scroll) chatbox := gtk.NewBox(gtk.OrientationHorizontal, 0) // chatbox.Append(menu_scroll) chat_pane := gtk.NewPaned(gtk.OrientationHorizontal) chat_pane.SetStartChild(scroller) chat_pane.SetEndChild(memberList) chat_pane.SetPosition(225) main_pane := gtk.NewPaned(gtk.OrientationHorizontal) main_pane.SetStartChild(menu_scroll) main_pane.SetEndChild(chat_pane) main_pane.SetPosition(135) chatbox.Append(main_pane) box.Append(chatbox) entry_box := gtk.NewBox(gtk.OrientationHorizontal, 0) en := gtk.NewEntry() en.SetPlaceholderText("Say something, what else are you gonna do here?") b := gtk.NewButtonWithLabel("Send") sendtxt := func() { t := en.Text() if t == "" { dialog := >k.AlertDialog{} dialog.SetDetail("detail") dialog.SetMessage("message") dialog.SetButtons([]string{"yes, no"}) dialog.Choose(context.TODO(), &window.Window, nil) } message_type := stanza.MessageTypeChat tab, ok := tabs.Load(current) if !ok { return } typed_tab := tab.(*chatTab) if typed_tab.isMuc { message_type = stanza.MessageTypeGroupchat } err := sendMessage(client, current, message_type, t, "", "") if err != nil { panic(err) // TODO: Show error message via GTK } en.SetText("") scrollToBottomAfterUpdate(scroller) } en.Connect("activate", sendtxt) b.ConnectClicked(sendtxt) en.SetHExpand(true) entry_box.Append(en) entry_box.Append(b) box.Append(entry_box) window.SetChild(box) window.SetVisible(true) } func loadCSS(content string) *gtk.CSSProvider { prov := gtk.NewCSSProvider() prov.LoadFromString(content) return prov }