// tefter-desktop — Wails v2 shell around the shared frontend bundle. // Window + tray + global shortcut + single-instance lock; all app logic // (storage, sync) lives in the frontend, so it works fully offline. package main import ( "context" "embed" "flag" "log" "os" "strings" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/mac" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "fyne.io/systray" "golang.design/x/hotkey" ) // Version is stamped via -ldflags "-X main.Version=…". var Version = "dev" //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var trayIcon []byte type appShell struct { ctx context.Context quitOnClose bool hotkeyCombo string endTray func() quitting bool } func main() { quitOnClose := flag.Bool("quit-on-close", envBool("TEFTER_QUIT_ON_CLOSE"), "quit when the window closes instead of hiding to tray") combo := flag.String("hotkey", envOr("TEFTER_HOTKEY", "ctrl+shift+space"), "global show/hide shortcut, e.g. ctrl+shift+space") flag.Parse() s := &appShell{quitOnClose: *quitOnClose, hotkeyCombo: *combo} err := wails.Run(&options.App{ Title: "Tefter", Width: 980, Height: 700, AssetServer: &assetserver.Options{ Assets: assets, }, HideWindowOnClose: !*quitOnClose, OnStartup: s.startup, OnShutdown: s.shutdown, SingleInstanceLock: &options.SingleInstanceLock{ UniqueId: "life.uka.tefter", OnSecondInstanceLaunch: s.onSecondInstance, }, Mac: &mac.Options{ About: &mac.AboutInfo{ Title: "Tefter " + Version, Message: "Self-hosted Notational Velocity style notes", }, }, }) if err != nil { log.Fatal(err) } } func (s *appShell) startup(ctx context.Context) { s.ctx = ctx // Tray: open, sync now, quit (SPEC §7). RunWithExternalLoop coexists with // the Wails main loop. start, end := systray.RunWithExternalLoop(s.trayReady, nil) s.endTray = end start() // Global show/hide shortcut. Best effort: requires X11 on Linux. go s.registerHotkey() } func (s *appShell) shutdown(ctx context.Context) { if s.endTray != nil { s.endTray() } } func (s *appShell) onSecondInstance(data options.SecondInstanceData) { s.showAndFocus() } func (s *appShell) trayReady() { systray.SetIcon(trayIcon) systray.SetTitle("Tefter") systray.SetTooltip("Tefter — notes") mOpen := systray.AddMenuItem("Open", "Show the Tefter window") mSync := systray.AddMenuItem("Sync now", "Trigger a sync") systray.AddSeparator() mQuit := systray.AddMenuItem("Quit", "Quit Tefter") go func() { for { select { case <-mOpen.ClickedCh: s.showAndFocus() case <-mSync.ClickedCh: wruntime.EventsEmit(s.ctx, "tefter:sync") case <-mQuit.ClickedCh: s.quitting = true wruntime.Quit(s.ctx) return } } }() } func (s *appShell) registerHotkey() { mods, key, ok := parseHotkey(s.hotkeyCombo) if !ok { log.Printf("hotkey: cannot parse %q, global shortcut disabled", s.hotkeyCombo) return } hk := hotkey.New(mods, key) if err := hk.Register(); err != nil { log.Printf("hotkey: register %q: %v (global shortcut disabled)", s.hotkeyCombo, err) return } log.Printf("hotkey: %s toggles the window", s.hotkeyCombo) for range hk.Keydown() { s.showAndFocus() } } func (s *appShell) showAndFocus() { wruntime.WindowShow(s.ctx) // The classic NV "always at hand" flow: land in the omnibar. wruntime.EventsEmit(s.ctx, "tefter:focus-omnibar") } func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func envBool(k string) bool { v := strings.ToLower(os.Getenv(k)) return v == "1" || v == "true" || v == "yes" } // parseHotkey turns "ctrl+shift+space" into golang.design/x/hotkey values. // modAlt/modCmd are defined per-OS in hotkey_*.go. func parseHotkey(combo string) ([]hotkey.Modifier, hotkey.Key, bool) { var mods []hotkey.Modifier var key hotkey.Key haveKey := false for _, part := range strings.Split(strings.ToLower(combo), "+") { switch p := strings.TrimSpace(part); p { case "ctrl", "control": mods = append(mods, hotkey.ModCtrl) case "shift": mods = append(mods, hotkey.ModShift) case "alt", "option": mods = append(mods, modAlt) case "cmd", "meta", "super", "win": mods = append(mods, modCmd) case "space": key, haveKey = hotkey.KeySpace, true default: if len(p) == 1 { if k, found := charKeys[rune(p[0])]; found { key, haveKey = k, true } } } } return mods, key, haveKey } var charKeys = map[rune]hotkey.Key{ 'a': hotkey.KeyA, 'b': hotkey.KeyB, 'c': hotkey.KeyC, 'd': hotkey.KeyD, 'e': hotkey.KeyE, 'f': hotkey.KeyF, 'g': hotkey.KeyG, 'h': hotkey.KeyH, 'i': hotkey.KeyI, 'j': hotkey.KeyJ, 'k': hotkey.KeyK, 'l': hotkey.KeyL, 'm': hotkey.KeyM, 'n': hotkey.KeyN, 'o': hotkey.KeyO, 'p': hotkey.KeyP, 'q': hotkey.KeyQ, 'r': hotkey.KeyR, 's': hotkey.KeyS, 't': hotkey.KeyT, 'u': hotkey.KeyU, 'v': hotkey.KeyV, 'w': hotkey.KeyW, 'x': hotkey.KeyX, 'y': hotkey.KeyY, 'z': hotkey.KeyZ, '0': hotkey.Key0, '1': hotkey.Key1, '2': hotkey.Key2, '3': hotkey.Key3, '4': hotkey.Key4, '5': hotkey.Key5, '6': hotkey.Key6, '7': hotkey.Key7, '8': hotkey.Key8, '9': hotkey.Key9, }