Skip to content

Commit 92b3b51

Browse files
authored
feat: autostart support (#9)
1 parent 2d5fa9a commit 92b3b51

13 files changed

Lines changed: 520 additions & 65 deletions

File tree

app/state.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,13 @@ type State struct {
180180
AllBadge widget.Clickable // "All" filter badge
181181

182182
// Settings view state (UI-THREAD-ONLY)
183-
SettingsList widget.List
183+
SettingsList widget.List
184+
AutoStartEnabled bool // Whether autostart is currently on
185+
AutoStartClick widget.Clickable // Toggle button
186+
AutoStartError string // Error message after toggle attempt
187+
AutoStartSuccess bool // Show success message after toggle
188+
AutoStartNeedsUpdate bool // Deferred toggle flag (like Hotkeys.NeedsUpdate)
189+
AutoStartUpdating bool // Guard: set on UI thread before goroutine launch, cleared by goroutine before Invalidate
184190

185191
// Background-to-UI invalidation flag.
186192
// Background goroutines set this (via MarkDirty) alongside Window.Invalidate().

data/syntax/braces.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,6 @@ func (s *ScannerState) Advance(ch byte) bool {
3535
return !s.inSingleQuote && !s.inDoubleQuote
3636
}
3737

38-
// InQuote reports whether the scanner is currently inside a quoted string.
39-
func (s *ScannerState) InQuote() bool {
40-
return s.inSingleQuote || s.inDoubleQuote
41-
}
42-
4338
// IsBalancedBraces checks if braces are balanced in a command,
4439
// respecting quotes.
4540
func IsBalancedBraces(command string) bool {

data/syntax/braces_test.go

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,6 @@ func TestScannerStateAdvance(t *testing.T) {
5555
}
5656
}
5757

58-
func TestScannerStateInQuote(t *testing.T) {
59-
var s ScannerState
60-
61-
if s.InQuote() {
62-
t.Error("expected InQuote() = false for initial state")
63-
}
64-
65-
s.Advance('"')
66-
67-
if !s.InQuote() {
68-
t.Error("expected InQuote() = true after opening double quote")
69-
}
70-
71-
s.Advance('"')
72-
73-
if s.InQuote() {
74-
t.Error("expected InQuote() = false after closing double quote")
75-
}
76-
}
77-
7858
func TestBalancedBraces(t *testing.T) {
7959
tests := []struct {
8060
input string

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/godbus/dbus/v5 v5.2.2
1212
golang.design/x/hotkey v0.4.1
1313
golang.org/x/exp/shiny v0.0.0-20260212183809-81e46e3db34a
14+
golang.org/x/sys v0.41.0
1415
)
1516

1617
require (
@@ -32,6 +33,5 @@ require (
3233
go.uber.org/multierr v1.11.0 // indirect
3334
go.uber.org/zap v1.27.1 // indirect
3435
golang.org/x/image v0.36.0 // indirect
35-
golang.org/x/sys v0.41.0 // indirect
3636
golang.org/x/text v0.34.0 // indirect
3737
)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//go:build darwin
2+
3+
package autostart
4+
5+
import (
6+
"encoding/xml"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
const (
14+
launchAgentLabel = "com.debrief"
15+
plistName = launchAgentLabel + ".plist"
16+
plistDir = "Library/LaunchAgents"
17+
launchAgentsDirPerm = 0o750
18+
plistFilePerm = 0o600
19+
)
20+
21+
// Enable registers the app as a macOS LaunchAgent to start on login.
22+
func Enable() error {
23+
exePath, err := os.Executable()
24+
if err != nil {
25+
return fmt.Errorf("failed to get executable path: %w", err)
26+
}
27+
28+
plistPath, err := launchAgentPath()
29+
if err != nil {
30+
return err
31+
}
32+
33+
dir := filepath.Dir(plistPath)
34+
if err := os.MkdirAll(dir, launchAgentsDirPerm); err != nil {
35+
return fmt.Errorf("failed to create LaunchAgents directory: %w", err)
36+
}
37+
38+
var escaped strings.Builder
39+
if err := xml.EscapeText(&escaped, []byte(exePath)); err != nil {
40+
return fmt.Errorf("failed to escape executable path: %w", err)
41+
}
42+
43+
content := `<?xml version="1.0" encoding="UTF-8"?>
44+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45+
<plist version="1.0">
46+
<dict>
47+
<key>Label</key>
48+
<string>` + launchAgentLabel + `</string>
49+
<key>ProgramArguments</key>
50+
<array>
51+
<string>` + escaped.String() + `</string>
52+
</array>
53+
<key>RunAtLoad</key>
54+
<true/>
55+
</dict>
56+
</plist>
57+
`
58+
59+
if err := os.WriteFile(plistPath, []byte(content), plistFilePerm); err != nil {
60+
return fmt.Errorf("failed to write LaunchAgent plist: %w", err)
61+
}
62+
63+
return nil
64+
}
65+
66+
// Disable removes the LaunchAgent plist to stop the app from starting on login.
67+
func Disable() error {
68+
plistPath, err := launchAgentPath()
69+
if err != nil {
70+
return err
71+
}
72+
73+
if err := os.Remove(plistPath); err != nil {
74+
if os.IsNotExist(err) {
75+
return nil
76+
}
77+
78+
return fmt.Errorf("failed to remove LaunchAgent plist: %w", err)
79+
}
80+
81+
return nil
82+
}
83+
84+
// IsEnabled checks whether the LaunchAgent plist exists.
85+
func IsEnabled() (bool, error) {
86+
plistPath, err := launchAgentPath()
87+
if err != nil {
88+
return false, err
89+
}
90+
91+
_, err = os.Stat(plistPath)
92+
if err != nil {
93+
if os.IsNotExist(err) {
94+
return false, nil
95+
}
96+
97+
return false, fmt.Errorf("failed to check LaunchAgent plist: %w", err)
98+
}
99+
100+
return true, nil
101+
}
102+
103+
func launchAgentPath() (string, error) {
104+
home, err := os.UserHomeDir()
105+
if err != nil {
106+
return "", fmt.Errorf("failed to get home directory: %w", err)
107+
}
108+
109+
return filepath.Join(home, plistDir, plistName), nil
110+
}

infra/autostart/autostart_linux.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//go:build linux
2+
3+
package autostart
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
)
10+
11+
const (
12+
desktopFileName = "debrief.desktop"
13+
autostartDir = "autostart"
14+
autostartDirPerm = 0o750
15+
desktopFilePerm = 0o600
16+
)
17+
18+
// Enable creates an XDG autostart .desktop file so the app starts on login.
19+
func Enable() error {
20+
exePath, err := os.Executable()
21+
if err != nil {
22+
return fmt.Errorf("failed to get executable path: %w", err)
23+
}
24+
25+
desktopPath, err := autostartFilePath()
26+
if err != nil {
27+
return err
28+
}
29+
30+
dir := filepath.Dir(desktopPath)
31+
if err := os.MkdirAll(dir, autostartDirPerm); err != nil {
32+
return fmt.Errorf("failed to create autostart directory: %w", err)
33+
}
34+
35+
content := "[Desktop Entry]\n" +
36+
"Type=Application\n" +
37+
"Name=Debrief\n" +
38+
"Exec=" + exePath + "\n" +
39+
"X-GNOME-Autostart-enabled=true\n" +
40+
"StartupNotify=false\n" +
41+
"Terminal=false\n"
42+
43+
if err := os.WriteFile(desktopPath, []byte(content), desktopFilePerm); err != nil {
44+
return fmt.Errorf("failed to write autostart desktop file: %w", err)
45+
}
46+
47+
return nil
48+
}
49+
50+
// Disable removes the XDG autostart .desktop file.
51+
func Disable() error {
52+
desktopPath, err := autostartFilePath()
53+
if err != nil {
54+
return err
55+
}
56+
57+
if err := os.Remove(desktopPath); err != nil {
58+
if os.IsNotExist(err) {
59+
return nil
60+
}
61+
62+
return fmt.Errorf("failed to remove autostart desktop file: %w", err)
63+
}
64+
65+
return nil
66+
}
67+
68+
// IsEnabled checks whether the XDG autostart .desktop file exists.
69+
func IsEnabled() (bool, error) {
70+
desktopPath, err := autostartFilePath()
71+
if err != nil {
72+
return false, err
73+
}
74+
75+
_, err = os.Stat(desktopPath)
76+
if err != nil {
77+
if os.IsNotExist(err) {
78+
return false, nil
79+
}
80+
81+
return false, fmt.Errorf("failed to check autostart desktop file: %w", err)
82+
}
83+
84+
return true, nil
85+
}
86+
87+
func autostartFilePath() (string, error) {
88+
configDir := os.Getenv("XDG_CONFIG_HOME")
89+
if configDir == "" {
90+
home, err := os.UserHomeDir()
91+
if err != nil {
92+
return "", fmt.Errorf("failed to get home directory: %w", err)
93+
}
94+
95+
configDir = filepath.Join(home, ".config")
96+
}
97+
98+
return filepath.Join(configDir, autostartDir, desktopFileName), nil
99+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//go:build windows
2+
3+
package autostart
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"os"
9+
10+
"golang.org/x/sys/windows/registry"
11+
)
12+
13+
const (
14+
registryPath = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
15+
valueName = "Debrief"
16+
)
17+
18+
// Enable registers the app to start on login via the Windows registry.
19+
func Enable() error {
20+
exePath, err := os.Executable()
21+
if err != nil {
22+
return fmt.Errorf("failed to get executable path: %w", err)
23+
}
24+
25+
key, _, err := registry.CreateKey(registry.CURRENT_USER, registryPath, registry.SET_VALUE)
26+
if err != nil {
27+
return fmt.Errorf("failed to open registry key: %w", err)
28+
}
29+
defer key.Close() //nolint:errcheck // registry key close errors are non-actionable
30+
31+
if err := key.SetStringValue(valueName, exePath); err != nil {
32+
return fmt.Errorf("failed to set registry value: %w", err)
33+
}
34+
35+
return nil
36+
}
37+
38+
// Disable removes the app from login startup in the Windows registry.
39+
func Disable() error {
40+
key, err := registry.OpenKey(registry.CURRENT_USER, registryPath, registry.SET_VALUE)
41+
if err != nil {
42+
return fmt.Errorf("failed to open registry key: %w", err)
43+
}
44+
defer key.Close() //nolint:errcheck // registry key close errors are non-actionable
45+
46+
if err := key.DeleteValue(valueName); err != nil {
47+
if errors.Is(err, registry.ErrNotExist) {
48+
return nil
49+
}
50+
51+
return fmt.Errorf("failed to delete registry value: %w", err)
52+
}
53+
54+
return nil
55+
}
56+
57+
// IsEnabled checks whether the app is registered for login startup.
58+
func IsEnabled() (bool, error) {
59+
key, err := registry.OpenKey(registry.CURRENT_USER, registryPath, registry.QUERY_VALUE)
60+
if err != nil {
61+
return false, nil
62+
}
63+
defer key.Close() //nolint:errcheck // registry key close errors are non-actionable
64+
65+
_, _, err = key.GetStringValue(valueName)
66+
if err != nil {
67+
if errors.Is(err, registry.ErrNotExist) {
68+
return false, nil
69+
}
70+
71+
return false, fmt.Errorf("failed to read registry value: %w", err)
72+
}
73+
74+
return true, nil
75+
}

infra/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type Config struct {
1616

1717
HotkeyPreset int `json:"hotkeyPreset"` // Preset index (0, 1, or 2)
1818

19+
AutoStart bool `json:"autoStart,omitempty"` // Start on computer boot
20+
1921
// Window geometry persisted across restarts (pixels).
2022
// Zero values mean "use default".
2123
WindowW int `json:"windowW,omitempty"`
@@ -27,6 +29,7 @@ func DefaultConfig() *Config {
2729
return &Config{
2830
Version: SettingsVersion,
2931
HotkeyPreset: 0,
32+
AutoStart: true,
3033
}
3134
}
3235

0 commit comments

Comments
 (0)