Files
tefter/desktop/main.go
Senad Uka 175c89e400 Implement Tefter per SPEC.md: NV-style notes with server, sync, PWA, desktop
- frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked
  diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms
  autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo
  toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then-
  push sync engine with conflict handling, versioned cache-first service
  worker + manifest (installable PWA)
- server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API
  (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with-
  conflict-copies push rules, subcommands: init, token rotate, import
  simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle
- desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray),
  global Ctrl+Shift+Space hotkey, quit-on-close flag
- Simplenote import (CLI + web upload), idempotent via source_id dedupe;
  verified against a real 242-note export
- Makefile (frontend/server/server-all/desktop/test), GitHub release workflow,
  README with systemd/Caddy/backup docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:16:01 +02:00

197 lines
5.2 KiB
Go

// 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,
}