diff --git a/.gitignore b/.gitignore index e004695..e85e0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +README.md.tinyenc secure-editor-go # ---> macOS diff --git a/main.go b/main.go index d145cb1..d09a180 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,17 @@ import ( "crypto/aes" "crypto/cipher" "encoding/hex" + "encoding/json" "fmt" + "image/color" "os" + "path/filepath" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -18,6 +22,17 @@ const ( Version = "0.1.1" Title = "Secure Editor" AlgorithmAes256Gcm = "aes-256-gcm" + + DefaultWindowWidth = 800.0 + DefaultWindowHeight = 600.0 + + ConfigFileName = "secure-editor-go-config.json" + + // Exit codes: + // 0 -> file was saved and the editor exited + // 2 -> editor closed without saving + ExitCodeOK = 0 + ExitCodeNoSave = 2 ) func decrypt(ciphertext, key, nonce []byte) ([]byte, error) { @@ -74,6 +89,93 @@ func isReadonly() bool { return os.Getenv("READONLY") == "true" } +// windowConfig is persisted to ~/.cache/secure-editor-go-config.json so the +// editor remembers its window size across runs. +type windowConfig struct { + Width float32 `json:"width"` + Height float32 `json:"height"` +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cache", ConfigFileName), nil +} + +func defaultConfig() windowConfig { + return windowConfig{Width: DefaultWindowWidth, Height: DefaultWindowHeight} +} + +func loadConfig() windowConfig { + p, err := configPath() + if err != nil { + return defaultConfig() + } + data, err := os.ReadFile(p) + if err != nil { + return defaultConfig() + } + var c windowConfig + if err := json.Unmarshal(data, &c); err != nil { + return defaultConfig() + } + if c.Width <= 0 || c.Height <= 0 { + return defaultConfig() + } + return c +} + +func saveConfig(size fyne.Size) { + p, err := configPath() + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return + } + c := windowConfig{Width: size.Width, Height: size.Height} + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return + } + _ = os.WriteFile(p, data, 0644) +} + +// clampToScreen shrinks size so it never exceeds the visible desktop area. +// When the desktop size can't be determined it returns size unchanged. +func clampToScreen(size fyne.Size) fyne.Size { + sw, sh := desktopSize() + if sw <= 0 || sh <= 0 { + return size + } + w, h := size.Width, size.Height + if w > sw { + w = sw + } + if h > sh { + h = sh + } + return fyne.NewSize(w, h) +} + +// lightEditorTheme forces a light variant with a pure white input background +// so the editable text area reads as a white editing surface regardless of +// the system appearance setting. Readonly mode keeps the default theme (grey). +type lightEditorTheme struct { + fyne.Theme +} + +func (t *lightEditorTheme) Color(name fyne.ThemeColorName, _ fyne.ThemeVariant) color.Color { + if name == theme.ColorNameInputBackground { + return color.White + } + // Always resolve against the light variant so white input background + // stays paired with dark, readable text. + return t.Theme.Color(name, theme.VariantLight) +} + func main() { args := os.Args if len(args) == 2 && args[1] == "version" { @@ -126,9 +228,19 @@ func main() { isReadonly := isReadonly() secureEditorApp := app.New() + if !isReadonly { + secureEditorApp.Settings().SetTheme(&lightEditorTheme{Theme: theme.DefaultTheme()}) + } window := secureEditorApp.NewWindow(Title) - window.Resize(fyne.NewSize(800, 600)) + + cfg := loadConfig() + window.Resize(clampToScreen(fyne.NewSize(cfg.Width, cfg.Height))) + + // exitCode defaults to "closed without saving". The Save button raises + // it to ExitCodeOK. It is read after the event loop ends. + exitCode := ExitCodeNoSave window.SetOnClosed(func() { + saveConfig(window.Canvas().Size()) secureEditorApp.Quit() }) @@ -147,6 +259,7 @@ func main() { } exitButton := widget.NewButton(" [ Exit ] ", func() { + exitCode = ExitCodeNoSave secureEditorApp.Quit() }) saveButton := widget.NewButton(" [ Save ] ", func() { @@ -161,6 +274,7 @@ func main() { showErrorMessage(err, window) return } + exitCode = ExitCodeOK secureEditorApp.Quit() }) @@ -180,4 +294,6 @@ func main() { window.SetContent(content) window.CenterOnScreen() window.ShowAndRun() + + os.Exit(exitCode) } diff --git a/screen_darwin.go b/screen_darwin.go new file mode 100644 index 0000000..42478db --- /dev/null +++ b/screen_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin + +package main + +import ( + "os/exec" + "strconv" + "strings" +) + +// desktopSize returns the visible desktop size in points (device-independent +// pixels), matching Fyne's coordinate system. It uses osascript instead of +// cgo so the binary stays a plain Go build. Returns (0, 0) if it can't be +// determined, in which case the caller skips clamping. +func desktopSize() (float32, float32) { + // "bounds of window of desktop" -> "0, 0, W, H" in points. + out, err := exec.Command("osascript", "-e", + `tell application "Finder" to get bounds of window of desktop`).Output() + if err != nil { + return 0, 0 + } + parts := strings.Split(strings.TrimSpace(string(out)), ", ") + if len(parts) < 4 { + return 0, 0 + } + w, errW := strconv.ParseFloat(strings.TrimSpace(parts[2]), 32) + h, errH := strconv.ParseFloat(strings.TrimSpace(parts[3]), 32) + if errW != nil || errH != nil { + return 0, 0 + } + return float32(w), float32(h) +} diff --git a/screen_other.go b/screen_other.go new file mode 100644 index 0000000..e9e4937 --- /dev/null +++ b/screen_other.go @@ -0,0 +1,10 @@ +//go:build !darwin + +package main + +// desktopSize is a no-op fallback on non-darwin platforms where we don't have +// a CGo-free way to query the screen. Returns (0, 0) so the caller skips +// clamping and uses the saved/default size as-is. +func desktopSize() (float32, float32) { + return 0, 0 +}