package main import ( "crypto/aes" "crypto/cipher" "encoding/hex" "encoding/json" "fmt" "image/color" "os" "path/filepath" "strings" "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" ) 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) { block, err := aes.NewCipher(key) if err != nil { return nil, err } aesgcm, err := cipher.NewGCM(block) if err != nil { return nil, err } plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err } return plaintext, nil } func encrypt(plaintext, key, nonce []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } aesgcm, err := cipher.NewGCM(block) if err != nil { return nil, err } ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) return ciphertext, nil } func writeFilePreserveMode(filename string, data []byte) error { fi, err := os.Stat(filename) if err != nil { if os.IsNotExist(err) { return os.WriteFile(filename, data, 0666) } return err } err = os.WriteFile(filename, data, 0666) if err != nil { return err } return os.Chmod(filename, fi.Mode()) } func showErrorMessage(err error, parent fyne.Window) { dialog.ShowError(err, parent) } func isReadonly() bool { return os.Getenv("READONLY") == "true" } // fixedPitchEnabled reports whether the user opted into a monospace // (programmer) font. The value comes from the TINY_ENCRYPT_ENABLE_FIXED_PITCH // environment variable; when that is unset it falls back to the file at // ~/.config/envs/TINY_ENCRYPT_ENABLE_FIXED_PITCH (content trimmed). The values // true/on/yes/1 are accepted, case-insensitively. func fixedPitchEnabled() bool { v := os.Getenv("TINY_ENCRYPT_ENABLE_FIXED_PITCH") if v == "" { home, err := os.UserHomeDir() if err != nil { return false } data, err := os.ReadFile(filepath.Join(home, ".config", "envs", "TINY_ENCRYPT_ENABLE_FIXED_PITCH")) if err != nil { return false } v = strings.TrimSpace(string(data)) } switch strings.ToLower(v) { case "true", "on", "yes", "1": return true } return false } // 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" { fmt.Printf("secure-editor-go version: %s\n", Version) os.Exit(0) } if len(args) != 5 { fmt.Printf("Bad encrypt args: %v\n", args) os.Exit(1) } fileName := args[1] algorithm := args[2] if algorithm != AlgorithmAes256Gcm { fmt.Printf("Bad algorithm: %s\n", algorithm) os.Exit(1) } keyBytes, err := hex.DecodeString(args[3]) if err != nil { fmt.Printf("Bad key: %s, failed: %s\n", args[3], err.Error()) os.Exit(1) } if len(keyBytes) != 32 { fmt.Printf("Bad key length: %d\n", len(keyBytes)) os.Exit(1) } nonceBytes, err := hex.DecodeString(args[4]) if err != nil { fmt.Printf("Bad nonce: %s, failed: %s\n", args[4], err.Error()) os.Exit(1) } if len(nonceBytes) != 12 { fmt.Printf("Bad nonce length: %d\n", len(nonceBytes)) os.Exit(1) } fileData, err := os.ReadFile(fileName) if err != nil { fmt.Printf("Read file: %s, failed: %s\n", fileName, err.Error()) os.Exit(1) } plaintext, err := decrypt(fileData, keyBytes, nonceBytes) if err != nil { fmt.Printf("Decrypt file: %s, failed: %s\n", fileName, err.Error()) os.Exit(1) } isReadonly := isReadonly() secureEditorApp := app.New() if !isReadonly { secureEditorApp.Settings().SetTheme(&lightEditorTheme{Theme: theme.DefaultTheme()}) } window := secureEditorApp.NewWindow(Title) 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() }) textEntry := widget.NewMultiLineEntry() textEntry.Wrapping = fyne.TextWrapWord if fixedPitchEnabled() { // Use Fyne's bundled monospace font for a programmer-style editor. textEntry.TextStyle = fyne.TextStyle{Monospace: true} } initialText := string(plaintext) textEntry.SetText(initialText) if isReadonly { // textEntry.Disable() // TODO for font is grey textEntry.OnChanged = func(text string) { if text != initialText { textEntry.SetText(initialText) } } } exitButton := widget.NewButton(" [ Exit ] ", func() { exitCode = ExitCodeNoSave secureEditorApp.Quit() }) saveButton := widget.NewButton(" [ Save ] ", func() { plaintext := textEntry.Text ciphertext, err := encrypt([]byte(plaintext), keyBytes, nonceBytes) if err != nil { showErrorMessage(err, window) return } err = writeFilePreserveMode(fileName, ciphertext) if err != nil { showErrorMessage(err, window) return } exitCode = ExitCodeOK secureEditorApp.Quit() }) buttons := container.NewHBox(exitButton, saveButton) if isReadonly { buttons = container.NewHBox(exitButton) } content := container.NewBorder( nil, // top container.NewBorder(nil, nil, nil, buttons, nil), // bottom nil, // left nil, // right textEntry, // center ) window.SetContent(content) window.CenterOnScreen() window.ShowAndRun() os.Exit(exitCode) }