- 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>
197 lines
5.2 KiB
Go
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,
|
|
}
|