detached 151b026

feat(cli): add setup commands for desktop integration

commit
151b026baf7c9eae0a3030e51650ad48bf176779
author
bnema · · 2025-12-18 20:29 UTC
parent
3de22f5
signature
unsigned
6 files · +780 -7
M assets/webui_embed.go +9 -1
@@ -1,9 +1,17 @@
11 package assets
22
3-import "embed"
3+import (
4+ "embed"
5+ _ "embed"
6+)
47
58 // WebUIAssets contains the embedded webui build output (homepage and blocked pages).
69 // Used by the scheme handler to serve dumb://home and dumb://blocked.
710 //
811 //go:embed webui/*
912 var WebUIAssets embed.FS
13+
14+// LogoSVG contains the dumber logo as SVG for desktop icon installation.
15+//
16+//go:embed logo.svg
17+var LogoSVG []byte
@@ -1,9 +1,17 @@
1 package assets1 package assets
2 2
3-import "embed"3+import (
4+ "embed"
5+ _ "embed"
6+)
4 7
5 // WebUIAssets contains the embedded webui build output (homepage and blocked pages).8 // WebUIAssets contains the embedded webui build output (homepage and blocked pages).
6 // Used by the scheme handler to serve dumb://home and dumb://blocked.9 // Used by the scheme handler to serve dumb://home and dumb://blocked.
7 //10 //
8 //go:embed webui/*11 //go:embed webui/*
9 var WebUIAssets embed.FS12 var WebUIAssets embed.FS
13+
14+// LogoSVG contains the dumber logo as SVG for desktop icon installation.
15+//
16+//go:embed logo.svg
17+var LogoSVG []byte
A internal/application/port/desktop.go +45 -0
@@ -0,0 +1,45 @@
1+package port
2+
3+import "context"
4+
5+// DesktopIntegrationStatus represents the current state of desktop integration.
6+type DesktopIntegrationStatus struct {
7+ DesktopFileInstalled bool
8+ DesktopFilePath string
9+ IconInstalled bool
10+ IconFilePath string
11+ IsDefaultBrowser bool
12+ ExecutablePath string
13+}
14+
15+// DesktopIntegration provides desktop environment integration operations.
16+type DesktopIntegration interface {
17+ // GetStatus checks the current desktop integration state.
18+ GetStatus(ctx context.Context) (*DesktopIntegrationStatus, error)
19+
20+ // InstallDesktopFile writes the desktop file to XDG applications directory.
21+ // Returns the path where the file was installed.
22+ // Idempotent: safe to call multiple times.
23+ InstallDesktopFile(ctx context.Context) (string, error)
24+
25+ // InstallIcon writes the icon file to XDG icons directory.
26+ // Returns the path where the icon was installed.
27+ // Idempotent: safe to call multiple times.
28+ InstallIcon(ctx context.Context, svgData []byte) (string, error)
29+
30+ // RemoveDesktopFile removes the desktop file from XDG applications directory.
31+ // Idempotent: returns nil if file doesn't exist.
32+ RemoveDesktopFile(ctx context.Context) error
33+
34+ // RemoveIcon removes the icon file from XDG icons directory.
35+ // Idempotent: returns nil if file doesn't exist.
36+ RemoveIcon(ctx context.Context) error
37+
38+ // SetAsDefaultBrowser sets dumber as the default web browser using xdg-settings.
39+ // Returns error if desktop file is not installed.
40+ SetAsDefaultBrowser(ctx context.Context) error
41+
42+ // UnsetAsDefaultBrowser resets default browser if dumber is currently default.
43+ // Idempotent: returns nil if not currently default.
44+ UnsetAsDefaultBrowser(ctx context.Context) error
45+}
@@ -0,0 +1,45 @@
1+package port
2+
3+import "context"
4+
5+// DesktopIntegrationStatus represents the current state of desktop integration.
6+type DesktopIntegrationStatus struct {
7+ DesktopFileInstalled bool
8+ DesktopFilePath string
9+ IconInstalled bool
10+ IconFilePath string
11+ IsDefaultBrowser bool
12+ ExecutablePath string
13+}
14+
15+// DesktopIntegration provides desktop environment integration operations.
16+type DesktopIntegration interface {
17+ // GetStatus checks the current desktop integration state.
18+ GetStatus(ctx context.Context) (*DesktopIntegrationStatus, error)
19+
20+ // InstallDesktopFile writes the desktop file to XDG applications directory.
21+ // Returns the path where the file was installed.
22+ // Idempotent: safe to call multiple times.
23+ InstallDesktopFile(ctx context.Context) (string, error)
24+
25+ // InstallIcon writes the icon file to XDG icons directory.
26+ // Returns the path where the icon was installed.
27+ // Idempotent: safe to call multiple times.
28+ InstallIcon(ctx context.Context, svgData []byte) (string, error)
29+
30+ // RemoveDesktopFile removes the desktop file from XDG applications directory.
31+ // Idempotent: returns nil if file doesn't exist.
32+ RemoveDesktopFile(ctx context.Context) error
33+
34+ // RemoveIcon removes the icon file from XDG icons directory.
35+ // Idempotent: returns nil if file doesn't exist.
36+ RemoveIcon(ctx context.Context) error
37+
38+ // SetAsDefaultBrowser sets dumber as the default web browser using xdg-settings.
39+ // Returns error if desktop file is not installed.
40+ SetAsDefaultBrowser(ctx context.Context) error
41+
42+ // UnsetAsDefaultBrowser resets default browser if dumber is currently default.
43+ // Idempotent: returns nil if not currently default.
44+ UnsetAsDefaultBrowser(ctx context.Context) error
45+}
A internal/application/usecase/setup_desktop.go +171 -0
@@ -0,0 +1,171 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// InstallDesktopInput contains the input for the install operation.
11+type InstallDesktopInput struct {
12+ IconData []byte
13+}
14+
15+// InstallDesktopUseCase installs the desktop file for system integration.
16+type InstallDesktopUseCase struct {
17+ desktop port.DesktopIntegration
18+}
19+
20+// NewInstallDesktopUseCase creates a new InstallDesktopUseCase.
21+func NewInstallDesktopUseCase(desktop port.DesktopIntegration) *InstallDesktopUseCase {
22+ return &InstallDesktopUseCase{desktop: desktop}
23+}
24+
25+// InstallDesktopOutput contains the result of the install operation.
26+type InstallDesktopOutput struct {
27+ DesktopPath string
28+ IconPath string
29+ WasDesktopExisting bool
30+ WasIconExisting bool
31+}
32+
33+// Execute installs the desktop file and icon.
34+func (uc *InstallDesktopUseCase) Execute(ctx context.Context, input InstallDesktopInput) (*InstallDesktopOutput, error) {
35+ log := logging.FromContext(ctx)
36+
37+ // Check current status first
38+ status, err := uc.desktop.GetStatus(ctx)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ output := &InstallDesktopOutput{
44+ WasDesktopExisting: status.DesktopFileInstalled,
45+ WasIconExisting: status.IconInstalled,
46+ }
47+
48+ // Install desktop file
49+ desktopPath, err := uc.desktop.InstallDesktopFile(ctx)
50+ if err != nil {
51+ return nil, err
52+ }
53+ output.DesktopPath = desktopPath
54+
55+ // Install icon if data provided
56+ if len(input.IconData) > 0 {
57+ iconPath, err := uc.desktop.InstallIcon(ctx, input.IconData)
58+ if err != nil {
59+ return nil, err
60+ }
61+ output.IconPath = iconPath
62+ }
63+
64+ log.Info().
65+ Str("desktop_path", output.DesktopPath).
66+ Str("icon_path", output.IconPath).
67+ Bool("was_desktop_existing", output.WasDesktopExisting).
68+ Bool("was_icon_existing", output.WasIconExisting).
69+ Msg("desktop install complete")
70+
71+ return output, nil
72+}
73+
74+// SetDefaultBrowserUseCase sets dumber as the default web browser.
75+type SetDefaultBrowserUseCase struct {
76+ desktop port.DesktopIntegration
77+}
78+
79+// NewSetDefaultBrowserUseCase creates a new SetDefaultBrowserUseCase.
80+func NewSetDefaultBrowserUseCase(desktop port.DesktopIntegration) *SetDefaultBrowserUseCase {
81+ return &SetDefaultBrowserUseCase{desktop: desktop}
82+}
83+
84+// SetDefaultOutput contains the result of the set-default operation.
85+type SetDefaultOutput struct {
86+ WasAlreadyDefault bool
87+}
88+
89+// Execute sets dumber as the default browser.
90+func (uc *SetDefaultBrowserUseCase) Execute(ctx context.Context) (*SetDefaultOutput, error) {
91+ log := logging.FromContext(ctx)
92+
93+ // Check current status
94+ status, err := uc.desktop.GetStatus(ctx)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ if status.IsDefaultBrowser {
100+ log.Debug().Msg("dumber is already default browser")
101+ return &SetDefaultOutput{WasAlreadyDefault: true}, nil
102+ }
103+
104+ // Set as default
105+ if err := uc.desktop.SetAsDefaultBrowser(ctx); err != nil {
106+ return nil, err
107+ }
108+
109+ return &SetDefaultOutput{WasAlreadyDefault: false}, nil
110+}
111+
112+// RemoveDesktopUseCase removes desktop integration files.
113+type RemoveDesktopUseCase struct {
114+ desktop port.DesktopIntegration
115+}
116+
117+// NewRemoveDesktopUseCase creates a new RemoveDesktopUseCase.
118+func NewRemoveDesktopUseCase(desktop port.DesktopIntegration) *RemoveDesktopUseCase {
119+ return &RemoveDesktopUseCase{desktop: desktop}
120+}
121+
122+// RemoveDesktopOutput contains the result of the remove operation.
123+type RemoveDesktopOutput struct {
124+ WasDesktopInstalled bool
125+ WasIconInstalled bool
126+ WasDefault bool
127+ RemovedDesktopPath string
128+ RemovedIconPath string
129+}
130+
131+// Execute removes desktop integration.
132+func (uc *RemoveDesktopUseCase) Execute(ctx context.Context) (*RemoveDesktopOutput, error) {
133+ log := logging.FromContext(ctx)
134+
135+ // Check current status
136+ status, err := uc.desktop.GetStatus(ctx)
137+ if err != nil {
138+ return nil, err
139+ }
140+
141+ output := &RemoveDesktopOutput{
142+ WasDesktopInstalled: status.DesktopFileInstalled,
143+ WasIconInstalled: status.IconInstalled,
144+ WasDefault: status.IsDefaultBrowser,
145+ RemovedDesktopPath: status.DesktopFilePath,
146+ RemovedIconPath: status.IconFilePath,
147+ }
148+
149+ // Unset as default browser first (if applicable)
150+ if status.IsDefaultBrowser {
151+ _ = uc.desktop.UnsetAsDefaultBrowser(ctx)
152+ }
153+
154+ // Remove desktop file
155+ if err := uc.desktop.RemoveDesktopFile(ctx); err != nil {
156+ return nil, err
157+ }
158+
159+ // Remove icon file
160+ if err := uc.desktop.RemoveIcon(ctx); err != nil {
161+ return nil, err
162+ }
163+
164+ log.Info().
165+ Bool("was_desktop_installed", output.WasDesktopInstalled).
166+ Bool("was_icon_installed", output.WasIconInstalled).
167+ Bool("was_default", output.WasDefault).
168+ Msg("desktop integration removed")
169+
170+ return output, nil
171+}
@@ -0,0 +1,171 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// InstallDesktopInput contains the input for the install operation.
11+type InstallDesktopInput struct {
12+ IconData []byte
13+}
14+
15+// InstallDesktopUseCase installs the desktop file for system integration.
16+type InstallDesktopUseCase struct {
17+ desktop port.DesktopIntegration
18+}
19+
20+// NewInstallDesktopUseCase creates a new InstallDesktopUseCase.
21+func NewInstallDesktopUseCase(desktop port.DesktopIntegration) *InstallDesktopUseCase {
22+ return &InstallDesktopUseCase{desktop: desktop}
23+}
24+
25+// InstallDesktopOutput contains the result of the install operation.
26+type InstallDesktopOutput struct {
27+ DesktopPath string
28+ IconPath string
29+ WasDesktopExisting bool
30+ WasIconExisting bool
31+}
32+
33+// Execute installs the desktop file and icon.
34+func (uc *InstallDesktopUseCase) Execute(ctx context.Context, input InstallDesktopInput) (*InstallDesktopOutput, error) {
35+ log := logging.FromContext(ctx)
36+
37+ // Check current status first
38+ status, err := uc.desktop.GetStatus(ctx)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ output := &InstallDesktopOutput{
44+ WasDesktopExisting: status.DesktopFileInstalled,
45+ WasIconExisting: status.IconInstalled,
46+ }
47+
48+ // Install desktop file
49+ desktopPath, err := uc.desktop.InstallDesktopFile(ctx)
50+ if err != nil {
51+ return nil, err
52+ }
53+ output.DesktopPath = desktopPath
54+
55+ // Install icon if data provided
56+ if len(input.IconData) > 0 {
57+ iconPath, err := uc.desktop.InstallIcon(ctx, input.IconData)
58+ if err != nil {
59+ return nil, err
60+ }
61+ output.IconPath = iconPath
62+ }
63+
64+ log.Info().
65+ Str("desktop_path", output.DesktopPath).
66+ Str("icon_path", output.IconPath).
67+ Bool("was_desktop_existing", output.WasDesktopExisting).
68+ Bool("was_icon_existing", output.WasIconExisting).
69+ Msg("desktop install complete")
70+
71+ return output, nil
72+}
73+
74+// SetDefaultBrowserUseCase sets dumber as the default web browser.
75+type SetDefaultBrowserUseCase struct {
76+ desktop port.DesktopIntegration
77+}
78+
79+// NewSetDefaultBrowserUseCase creates a new SetDefaultBrowserUseCase.
80+func NewSetDefaultBrowserUseCase(desktop port.DesktopIntegration) *SetDefaultBrowserUseCase {
81+ return &SetDefaultBrowserUseCase{desktop: desktop}
82+}
83+
84+// SetDefaultOutput contains the result of the set-default operation.
85+type SetDefaultOutput struct {
86+ WasAlreadyDefault bool
87+}
88+
89+// Execute sets dumber as the default browser.
90+func (uc *SetDefaultBrowserUseCase) Execute(ctx context.Context) (*SetDefaultOutput, error) {
91+ log := logging.FromContext(ctx)
92+
93+ // Check current status
94+ status, err := uc.desktop.GetStatus(ctx)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ if status.IsDefaultBrowser {
100+ log.Debug().Msg("dumber is already default browser")
101+ return &SetDefaultOutput{WasAlreadyDefault: true}, nil
102+ }
103+
104+ // Set as default
105+ if err := uc.desktop.SetAsDefaultBrowser(ctx); err != nil {
106+ return nil, err
107+ }
108+
109+ return &SetDefaultOutput{WasAlreadyDefault: false}, nil
110+}
111+
112+// RemoveDesktopUseCase removes desktop integration files.
113+type RemoveDesktopUseCase struct {
114+ desktop port.DesktopIntegration
115+}
116+
117+// NewRemoveDesktopUseCase creates a new RemoveDesktopUseCase.
118+func NewRemoveDesktopUseCase(desktop port.DesktopIntegration) *RemoveDesktopUseCase {
119+ return &RemoveDesktopUseCase{desktop: desktop}
120+}
121+
122+// RemoveDesktopOutput contains the result of the remove operation.
123+type RemoveDesktopOutput struct {
124+ WasDesktopInstalled bool
125+ WasIconInstalled bool
126+ WasDefault bool
127+ RemovedDesktopPath string
128+ RemovedIconPath string
129+}
130+
131+// Execute removes desktop integration.
132+func (uc *RemoveDesktopUseCase) Execute(ctx context.Context) (*RemoveDesktopOutput, error) {
133+ log := logging.FromContext(ctx)
134+
135+ // Check current status
136+ status, err := uc.desktop.GetStatus(ctx)
137+ if err != nil {
138+ return nil, err
139+ }
140+
141+ output := &RemoveDesktopOutput{
142+ WasDesktopInstalled: status.DesktopFileInstalled,
143+ WasIconInstalled: status.IconInstalled,
144+ WasDefault: status.IsDefaultBrowser,
145+ RemovedDesktopPath: status.DesktopFilePath,
146+ RemovedIconPath: status.IconFilePath,
147+ }
148+
149+ // Unset as default browser first (if applicable)
150+ if status.IsDefaultBrowser {
151+ _ = uc.desktop.UnsetAsDefaultBrowser(ctx)
152+ }
153+
154+ // Remove desktop file
155+ if err := uc.desktop.RemoveDesktopFile(ctx); err != nil {
156+ return nil, err
157+ }
158+
159+ // Remove icon file
160+ if err := uc.desktop.RemoveIcon(ctx); err != nil {
161+ return nil, err
162+ }
163+
164+ log.Info().
165+ Bool("was_desktop_installed", output.WasDesktopInstalled).
166+ Bool("was_icon_installed", output.WasIconInstalled).
167+ Bool("was_default", output.WasDefault).
168+ Msg("desktop integration removed")
169+
170+ return output, nil
171+}
M internal/cli/cmd/purge.go +73 -6
@@ -2,17 +2,22 @@ package cmd
22
33 import (
44 "bufio"
5+ "context"
56 "fmt"
67 "os"
78 "path/filepath"
89 "strings"
910
1011 "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1115 )
1216
1317 var (
14- purgeForce bool
15- purgeAll bool
18+ purgeForce bool
19+ purgeAll bool
20+ purgeDesktop bool
1621 )
1722
1823 var purgeCmd = &cobra.Command{
@@ -26,7 +31,8 @@ This will delete:
2631 - ~/.local/state/dumber/ (state, logs)
2732 - ~/.cache/dumber/ (cache)
2833
29-Use --force to skip confirmation prompt.`,
34+Use --force to skip confirmation prompt.
35+Use --desktop to also remove the desktop file from ~/.local/share/applications/`,
3036 RunE: runPurge,
3137 }
3238
@@ -34,6 +40,7 @@ func init() {
3440 rootCmd.AddCommand(purgeCmd)
3541 purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt")
3642 purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)")
43+ purgeCmd.Flags().BoolVar(&purgeDesktop, "desktop", false, "also remove desktop file")
3744 }
3845
3946 func runPurge(cmd *cobra.Command, args []string) error {
@@ -71,17 +78,57 @@ func runPurge(cmd *cobra.Command, args []string) error {
7178 }
7279 }
7380
74- if len(existing) == 0 {
81+ // Check desktop integration status if --desktop flag is set
82+ var desktopStatus *struct {
83+ desktopInstalled bool
84+ iconInstalled bool
85+ isDefault bool
86+ desktopPath string
87+ iconPath string
88+ }
89+ if purgeDesktop {
90+ adapter := desktop.New()
91+ status, err := adapter.GetStatus(context.Background())
92+ if err == nil && (status.DesktopFileInstalled || status.IconInstalled) {
93+ desktopStatus = &struct {
94+ desktopInstalled bool
95+ iconInstalled bool
96+ isDefault bool
97+ desktopPath string
98+ iconPath string
99+ }{
100+ desktopInstalled: status.DesktopFileInstalled,
101+ iconInstalled: status.IconInstalled,
102+ isDefault: status.IsDefaultBrowser,
103+ desktopPath: status.DesktopFilePath,
104+ iconPath: status.IconFilePath,
105+ }
106+ }
107+ }
108+
109+ // Check if there's anything to remove
110+ if len(existing) == 0 && desktopStatus == nil {
75111 fmt.Println("No dumber directories found.")
76112 return nil
77113 }
78114
79115 // Show what will be deleted
80- fmt.Println("The following directories will be removed:")
116+ fmt.Println("The following will be removed:")
81117 fmt.Println()
82118 for _, d := range existing {
83119 fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size))
84120 }
121+ if desktopStatus != nil {
122+ if desktopStatus.desktopInstalled {
123+ fmt.Printf(" \u2022 %s (desktop file)\n", desktopStatus.desktopPath)
124+ }
125+ if desktopStatus.iconInstalled {
126+ fmt.Printf(" \u2022 %s (icon)\n", desktopStatus.iconPath)
127+ }
128+ if desktopStatus.isDefault {
129+ fmt.Println(" \u26a0 Dumber is currently the default browser")
130+ }
131+ }
85132 fmt.Println()
86133
87134 // Confirm unless --force
@@ -107,13 +154,33 @@ func runPurge(cmd *cobra.Command, args []string) error {
107154 }
108155 }
109156
157+ // Remove desktop integration
158+ if desktopStatus != nil {
159+ adapter := desktop.New()
160+ uc := usecase.NewRemoveDesktopUseCase(adapter)
161+ result, err := uc.Execute(context.Background())
162+ if err != nil {
163+ errors = append(errors, fmt.Sprintf("desktop integration: %v", err))
164+ } else {
165+ if result.WasDesktopInstalled {
166+ fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath)
167+ }
168+ if result.WasIconInstalled {
169+ fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath)
170+ }
171+ if result.WasDefault {
172+ fmt.Println(" \u26a0 Was default browser - please set a new default")
173+ }
174+ }
175+ }
176+
110177 if len(errors) > 0 {
111178 fmt.Println()
112179 fmt.Println("Errors:")
113180 for _, e := range errors {
114181 fmt.Printf(" \u2717 %s\n", e)
115182 }
116- return fmt.Errorf("failed to remove some directories")
183+ return fmt.Errorf("failed to remove some items")
117184 }
118185
119186 fmt.Println()
@@ -2,17 +2,22 @@ package cmd
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "context"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "strings"9 "strings"
9 10
10 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
11 )15 )
12 16
13 var (17 var (
14- purgeForce bool18+ purgeForce bool
15- purgeAll bool19+ purgeAll bool
20+ purgeDesktop bool
16 )21 )
17 22
18 var purgeCmd = &cobra.Command{23 var purgeCmd = &cobra.Command{
@@ -26,7 +31,8 @@ This will delete:
26 - ~/.local/state/dumber/ (state, logs)31 - ~/.local/state/dumber/ (state, logs)
27 - ~/.cache/dumber/ (cache)32 - ~/.cache/dumber/ (cache)
28 33
29-Use --force to skip confirmation prompt.`,34+Use --force to skip confirmation prompt.
35+Use --desktop to also remove the desktop file from ~/.local/share/applications/`,
30 RunE: runPurge,36 RunE: runPurge,
31 }37 }
32 38
@@ -34,6 +40,7 @@ func init() {
34 rootCmd.AddCommand(purgeCmd)40 rootCmd.AddCommand(purgeCmd)
35 purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt")41 purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt")
36 purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)")42 purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)")
43+ purgeCmd.Flags().BoolVar(&purgeDesktop, "desktop", false, "also remove desktop file")
37 }44 }
38 45
39 func runPurge(cmd *cobra.Command, args []string) error {46 func runPurge(cmd *cobra.Command, args []string) error {
@@ -71,17 +78,57 @@ func runPurge(cmd *cobra.Command, args []string) error {
71 }78 }
72 }79 }
73 80
74- if len(existing) == 0 {81+ // Check desktop integration status if --desktop flag is set
82+ var desktopStatus *struct {
83+ desktopInstalled bool
84+ iconInstalled bool
85+ isDefault bool
86+ desktopPath string
87+ iconPath string
88+ }
89+ if purgeDesktop {
90+ adapter := desktop.New()
91+ status, err := adapter.GetStatus(context.Background())
92+ if err == nil && (status.DesktopFileInstalled || status.IconInstalled) {
93+ desktopStatus = &struct {
94+ desktopInstalled bool
95+ iconInstalled bool
96+ isDefault bool
97+ desktopPath string
98+ iconPath string
99+ }{
100+ desktopInstalled: status.DesktopFileInstalled,
101+ iconInstalled: status.IconInstalled,
102+ isDefault: status.IsDefaultBrowser,
103+ desktopPath: status.DesktopFilePath,
104+ iconPath: status.IconFilePath,
105+ }
106+ }
107+ }
108+
109+ // Check if there's anything to remove
110+ if len(existing) == 0 && desktopStatus == nil {
75 fmt.Println("No dumber directories found.")111 fmt.Println("No dumber directories found.")
76 return nil112 return nil
77 }113 }
78 114
79 // Show what will be deleted115 // Show what will be deleted
80- fmt.Println("The following directories will be removed:")116+ fmt.Println("The following will be removed:")
81 fmt.Println()117 fmt.Println()
82 for _, d := range existing {118 for _, d := range existing {
83 fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size))119 fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size))
84 }120 }
121+ if desktopStatus != nil {
122+ if desktopStatus.desktopInstalled {
123+ fmt.Printf(" \u2022 %s (desktop file)\n", desktopStatus.desktopPath)
124+ }
125+ if desktopStatus.iconInstalled {
126+ fmt.Printf(" \u2022 %s (icon)\n", desktopStatus.iconPath)
127+ }
128+ if desktopStatus.isDefault {
129+ fmt.Println(" \u26a0 Dumber is currently the default browser")
130+ }
131+ }
85 fmt.Println()132 fmt.Println()
86 133
87 // Confirm unless --force134 // Confirm unless --force
@@ -107,13 +154,33 @@ func runPurge(cmd *cobra.Command, args []string) error {
107 }154 }
108 }155 }
109 156
157+ // Remove desktop integration
158+ if desktopStatus != nil {
159+ adapter := desktop.New()
160+ uc := usecase.NewRemoveDesktopUseCase(adapter)
161+ result, err := uc.Execute(context.Background())
162+ if err != nil {
163+ errors = append(errors, fmt.Sprintf("desktop integration: %v", err))
164+ } else {
165+ if result.WasDesktopInstalled {
166+ fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath)
167+ }
168+ if result.WasIconInstalled {
169+ fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath)
170+ }
171+ if result.WasDefault {
172+ fmt.Println(" \u26a0 Was default browser - please set a new default")
173+ }
174+ }
175+ }
176+
110 if len(errors) > 0 {177 if len(errors) > 0 {
111 fmt.Println()178 fmt.Println()
112 fmt.Println("Errors:")179 fmt.Println("Errors:")
113 for _, e := range errors {180 for _, e := range errors {
114 fmt.Printf(" \u2717 %s\n", e)181 fmt.Printf(" \u2717 %s\n", e)
115 }182 }
116- return fmt.Errorf("failed to remove some directories")183+ return fmt.Errorf("failed to remove some items")
117 }184 }
118 185
119 fmt.Println()186 fmt.Println()
A internal/cli/cmd/setup.go +138 -0
@@ -0,0 +1,138 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/spf13/cobra"
8+
9+ "github.com/bnema/dumber/assets"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/infrastructure/desktop"
12+)
13+
14+var setupCmd = &cobra.Command{
15+ Use: "setup",
16+ Short: "Setup desktop integration",
17+ Long: `Setup dumber's integration with the desktop environment.
18+
19+Subcommands:
20+ install - Install desktop file to ~/.local/share/applications/
21+ default - Set dumber as the default web browser`,
22+}
23+
24+var setupInstallCmd = &cobra.Command{
25+ Use: "install",
26+ Short: "Install desktop file",
27+ Long: `Install the dumber.desktop file to the user's applications directory.
28+
29+This registers dumber with the desktop environment, making it appear in
30+application menus and available as a browser choice.
31+
32+Location: $XDG_DATA_HOME/applications/dumber.desktop
33+ (typically ~/.local/share/applications/dumber.desktop)
34+
35+This command is idempotent - safe to run multiple times.`,
36+ RunE: runSetupInstall,
37+}
38+
39+var setupDefaultCmd = &cobra.Command{
40+ Use: "default",
41+ Short: "Set dumber as default browser",
42+ Long: `Set dumber as the default web browser using xdg-settings.
43+
44+This makes dumber handle http:// and https:// links from other applications.
45+
46+Requires the desktop file to be installed first (run 'dumber setup install').
47+
48+This command is idempotent - safe to run multiple times.`,
49+ RunE: runSetupDefault,
50+}
51+
52+func init() {
53+ rootCmd.AddCommand(setupCmd)
54+ setupCmd.AddCommand(setupInstallCmd)
55+ setupCmd.AddCommand(setupDefaultCmd)
56+}
57+
58+func runSetupInstall(cmd *cobra.Command, args []string) error {
59+ app := GetApp()
60+ if app == nil {
61+ return fmt.Errorf("app not initialized")
62+ }
63+
64+ theme := app.Theme
65+ adapter := desktop.New()
66+ uc := usecase.NewInstallDesktopUseCase(adapter)
67+
68+ input := usecase.InstallDesktopInput{
69+ IconData: assets.LogoSVG,
70+ }
71+
72+ result, err := uc.Execute(app.Ctx(), input)
73+ if err != nil {
74+ fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), err.Error())
75+ return err
76+ }
77+
78+ // Desktop file status
79+ if result.WasDesktopExisting {
80+ fmt.Printf("%s Desktop file updated at %s\n",
81+ theme.SuccessStyle.Render("\u2713"),
82+ theme.Highlight.Render(result.DesktopPath))
83+ } else {
84+ fmt.Printf("%s Desktop file installed to %s\n",
85+ theme.SuccessStyle.Render("\u2713"),
86+ theme.Highlight.Render(result.DesktopPath))
87+ }
88+
89+ // Icon status
90+ if result.IconPath != "" {
91+ if result.WasIconExisting {
92+ fmt.Printf("%s Icon updated at %s\n",
93+ theme.SuccessStyle.Render("\u2713"),
94+ theme.Highlight.Render(result.IconPath))
95+ } else {
96+ fmt.Printf("%s Icon installed to %s\n",
97+ theme.SuccessStyle.Render("\u2713"),
98+ theme.Highlight.Render(result.IconPath))
99+ }
100+ }
101+
102+ fmt.Println()
103+ fmt.Println(theme.Subtle.Render("Run 'dumber setup default' to set as default browser"))
104+
105+ return nil
106+}
107+
108+func runSetupDefault(cmd *cobra.Command, args []string) error {
109+ app := GetApp()
110+ if app == nil {
111+ return fmt.Errorf("app not initialized")
112+ }
113+
114+ theme := app.Theme
115+ adapter := desktop.New()
116+ uc := usecase.NewSetDefaultBrowserUseCase(adapter)
117+
118+ result, err := uc.Execute(app.Ctx())
119+ if err != nil {
120+ errMsg := err.Error()
121+ fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), errMsg)
122+ if strings.Contains(errMsg, "desktop file not installed") {
123+ fmt.Println()
124+ fmt.Println(theme.Subtle.Render("Hint: Run 'dumber setup install' first"))
125+ }
126+ return err
127+ }
128+
129+ if result.WasAlreadyDefault {
130+ fmt.Printf("%s Dumber is already the default browser\n",
131+ theme.SuccessStyle.Render("\u2713"))
132+ } else {
133+ fmt.Printf("%s Dumber is now the default browser\n",
134+ theme.SuccessStyle.Render("\u2713"))
135+ }
136+
137+ return nil
138+}
@@ -0,0 +1,138 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/spf13/cobra"
8+
9+ "github.com/bnema/dumber/assets"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/infrastructure/desktop"
12+)
13+
14+var setupCmd = &cobra.Command{
15+ Use: "setup",
16+ Short: "Setup desktop integration",
17+ Long: `Setup dumber's integration with the desktop environment.
18+
19+Subcommands:
20+ install - Install desktop file to ~/.local/share/applications/
21+ default - Set dumber as the default web browser`,
22+}
23+
24+var setupInstallCmd = &cobra.Command{
25+ Use: "install",
26+ Short: "Install desktop file",
27+ Long: `Install the dumber.desktop file to the user's applications directory.
28+
29+This registers dumber with the desktop environment, making it appear in
30+application menus and available as a browser choice.
31+
32+Location: $XDG_DATA_HOME/applications/dumber.desktop
33+ (typically ~/.local/share/applications/dumber.desktop)
34+
35+This command is idempotent - safe to run multiple times.`,
36+ RunE: runSetupInstall,
37+}
38+
39+var setupDefaultCmd = &cobra.Command{
40+ Use: "default",
41+ Short: "Set dumber as default browser",
42+ Long: `Set dumber as the default web browser using xdg-settings.
43+
44+This makes dumber handle http:// and https:// links from other applications.
45+
46+Requires the desktop file to be installed first (run 'dumber setup install').
47+
48+This command is idempotent - safe to run multiple times.`,
49+ RunE: runSetupDefault,
50+}
51+
52+func init() {
53+ rootCmd.AddCommand(setupCmd)
54+ setupCmd.AddCommand(setupInstallCmd)
55+ setupCmd.AddCommand(setupDefaultCmd)
56+}
57+
58+func runSetupInstall(cmd *cobra.Command, args []string) error {
59+ app := GetApp()
60+ if app == nil {
61+ return fmt.Errorf("app not initialized")
62+ }
63+
64+ theme := app.Theme
65+ adapter := desktop.New()
66+ uc := usecase.NewInstallDesktopUseCase(adapter)
67+
68+ input := usecase.InstallDesktopInput{
69+ IconData: assets.LogoSVG,
70+ }
71+
72+ result, err := uc.Execute(app.Ctx(), input)
73+ if err != nil {
74+ fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), err.Error())
75+ return err
76+ }
77+
78+ // Desktop file status
79+ if result.WasDesktopExisting {
80+ fmt.Printf("%s Desktop file updated at %s\n",
81+ theme.SuccessStyle.Render("\u2713"),
82+ theme.Highlight.Render(result.DesktopPath))
83+ } else {
84+ fmt.Printf("%s Desktop file installed to %s\n",
85+ theme.SuccessStyle.Render("\u2713"),
86+ theme.Highlight.Render(result.DesktopPath))
87+ }
88+
89+ // Icon status
90+ if result.IconPath != "" {
91+ if result.WasIconExisting {
92+ fmt.Printf("%s Icon updated at %s\n",
93+ theme.SuccessStyle.Render("\u2713"),
94+ theme.Highlight.Render(result.IconPath))
95+ } else {
96+ fmt.Printf("%s Icon installed to %s\n",
97+ theme.SuccessStyle.Render("\u2713"),
98+ theme.Highlight.Render(result.IconPath))
99+ }
100+ }
101+
102+ fmt.Println()
103+ fmt.Println(theme.Subtle.Render("Run 'dumber setup default' to set as default browser"))
104+
105+ return nil
106+}
107+
108+func runSetupDefault(cmd *cobra.Command, args []string) error {
109+ app := GetApp()
110+ if app == nil {
111+ return fmt.Errorf("app not initialized")
112+ }
113+
114+ theme := app.Theme
115+ adapter := desktop.New()
116+ uc := usecase.NewSetDefaultBrowserUseCase(adapter)
117+
118+ result, err := uc.Execute(app.Ctx())
119+ if err != nil {
120+ errMsg := err.Error()
121+ fmt.Printf("%s %s\n", theme.ErrorStyle.Render("\u2717"), errMsg)
122+ if strings.Contains(errMsg, "desktop file not installed") {
123+ fmt.Println()
124+ fmt.Println(theme.Subtle.Render("Hint: Run 'dumber setup install' first"))
125+ }
126+ return err
127+ }
128+
129+ if result.WasAlreadyDefault {
130+ fmt.Printf("%s Dumber is already the default browser\n",
131+ theme.SuccessStyle.Render("\u2713"))
132+ } else {
133+ fmt.Printf("%s Dumber is now the default browser\n",
134+ theme.SuccessStyle.Render("\u2713"))
135+ }
136+
137+ return nil
138+}
A internal/infrastructure/desktop/adapter.go +344 -0
@@ -0,0 +1,344 @@
1+// Package desktop provides desktop environment integration for Linux (XDG).
2+package desktop
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+const (
17+ appName = "dumber"
18+ desktopFileName = "dumber.desktop"
19+ iconFileName = "dumber.svg"
20+ filePerm = 0644
21+ dirPerm = 0755
22+)
23+
24+// desktopFileTemplate is the freedesktop.org desktop entry format.
25+// %s placeholder for executable path.
26+const desktopFileTemplate = `[Desktop Entry]
27+Version=1.1
28+Type=Application
29+Name=Dumber
30+GenericName=Dumber Browser
31+Comment=It's a browser but dumber
32+Exec=%s browse %%U
33+Icon=dumber
34+Terminal=false
35+Categories=Network;WebBrowser;
36+MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
37+StartupNotify=true
38+StartupWMClass=dumber
39+`
40+
41+// Adapter implements port.DesktopIntegration using XDG tools.
42+type Adapter struct {
43+ xdgSettingsPath string
44+ updateDesktopDB string
45+}
46+
47+// New creates a new desktop integration adapter.
48+func New() port.DesktopIntegration {
49+ a := &Adapter{}
50+
51+ // Detect xdg-settings
52+ if path, err := exec.LookPath("xdg-settings"); err == nil {
53+ a.xdgSettingsPath = path
54+ }
55+
56+ // Detect update-desktop-database (optional)
57+ if path, err := exec.LookPath("update-desktop-database"); err == nil {
58+ a.updateDesktopDB = path
59+ }
60+
61+ return a
62+}
63+
64+// getApplicationsDir returns the XDG applications directory.
65+func getApplicationsDir() (string, error) {
66+ dataHome := os.Getenv("XDG_DATA_HOME")
67+ if dataHome == "" {
68+ home, err := os.UserHomeDir()
69+ if err != nil {
70+ return "", fmt.Errorf("get home dir: %w", err)
71+ }
72+ dataHome = filepath.Join(home, ".local", "share")
73+ }
74+ return filepath.Join(dataHome, "applications"), nil
75+}
76+
77+// getDesktopFilePath returns the full path to the desktop file.
78+func getDesktopFilePath() (string, error) {
79+ appDir, err := getApplicationsDir()
80+ if err != nil {
81+ return "", err
82+ }
83+ return filepath.Join(appDir, desktopFileName), nil
84+}
85+
86+// getIconFilePath returns the full path to the icon file.
87+// Uses hicolor theme scalable apps directory.
88+func getIconFilePath() (string, error) {
89+ dataHome := os.Getenv("XDG_DATA_HOME")
90+ if dataHome == "" {
91+ home, err := os.UserHomeDir()
92+ if err != nil {
93+ return "", fmt.Errorf("get home dir: %w", err)
94+ }
95+ dataHome = filepath.Join(home, ".local", "share")
96+ }
97+ return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil
98+}
99+
100+// getExecutablePath returns the path to the dumber executable.
101+func getExecutablePath() (string, error) {
102+ // First try the running executable
103+ execPath, err := os.Executable()
104+ if err == nil {
105+ // Resolve symlinks
106+ resolved, err := filepath.EvalSymlinks(execPath)
107+ if err == nil {
108+ execPath = resolved
109+ }
110+ return execPath, nil
111+ }
112+
113+ // Fallback to PATH lookup
114+ path, err := exec.LookPath(appName)
115+ if err != nil {
116+ return "", fmt.Errorf("cannot find %s executable: %w", appName, err)
117+ }
118+ return path, nil
119+}
120+
121+// GetStatus checks the current desktop integration state.
122+func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) {
123+ log := logging.FromContext(ctx)
124+ status := &port.DesktopIntegrationStatus{}
125+
126+ // Check desktop file
127+ desktopPath, err := getDesktopFilePath()
128+ if err != nil {
129+ return nil, err
130+ }
131+ status.DesktopFilePath = desktopPath
132+
133+ if _, err := os.Stat(desktopPath); err == nil {
134+ status.DesktopFileInstalled = true
135+ }
136+
137+ // Check icon file
138+ iconPath, err := getIconFilePath()
139+ if err != nil {
140+ return nil, err
141+ }
142+ status.IconFilePath = iconPath
143+
144+ if _, err := os.Stat(iconPath); err == nil {
145+ status.IconInstalled = true
146+ }
147+
148+ // Check executable
149+ execPath, err := getExecutablePath()
150+ if err == nil {
151+ status.ExecutablePath = execPath
152+ }
153+
154+ // Check default browser
155+ if a.xdgSettingsPath != "" {
156+ out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
157+ if err == nil {
158+ currentDefault := strings.TrimSpace(string(out))
159+ status.IsDefaultBrowser = currentDefault == desktopFileName
160+ }
161+ }
162+
163+ log.Debug().
164+ Bool("desktop_installed", status.DesktopFileInstalled).
165+ Bool("icon_installed", status.IconInstalled).
166+ Bool("default", status.IsDefaultBrowser).
167+ Str("desktop_path", status.DesktopFilePath).
168+ Str("icon_path", status.IconFilePath).
169+ Str("exec_path", status.ExecutablePath).
170+ Msg("desktop integration status")
171+
172+ return status, nil
173+}
174+
175+// InstallDesktopFile writes the desktop file to XDG applications directory.
176+func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) {
177+ log := logging.FromContext(ctx)
178+
179+ execPath, err := getExecutablePath()
180+ if err != nil {
181+ return "", err
182+ }
183+
184+ desktopPath, err := getDesktopFilePath()
185+ if err != nil {
186+ return "", err
187+ }
188+
189+ // Ensure applications directory exists
190+ appDir := filepath.Dir(desktopPath)
191+ if err := os.MkdirAll(appDir, dirPerm); err != nil {
192+ return "", fmt.Errorf("create applications dir: %w", err)
193+ }
194+
195+ // Generate desktop file content
196+ content := fmt.Sprintf(desktopFileTemplate, execPath)
197+
198+ // Write desktop file
199+ if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil {
200+ return "", fmt.Errorf("write desktop file: %w", err)
201+ }
202+
203+ log.Info().Str("path", desktopPath).Msg("desktop file installed")
204+
205+ // Update desktop database (optional, helps with some DEs)
206+ if a.updateDesktopDB != "" {
207+ if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil {
208+ log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)")
209+ }
210+ }
211+
212+ return desktopPath, nil
213+}
214+
215+// RemoveDesktopFile removes the desktop file from XDG applications directory.
216+func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
217+ log := logging.FromContext(ctx)
218+
219+ desktopPath, err := getDesktopFilePath()
220+ if err != nil {
221+ return err
222+ }
223+
224+ // Check if exists
225+ if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
226+ log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)")
227+ return nil
228+ }
229+
230+ if err := os.Remove(desktopPath); err != nil {
231+ return fmt.Errorf("remove desktop file: %w", err)
232+ }
233+
234+ log.Info().Str("path", desktopPath).Msg("desktop file removed")
235+
236+ // Update desktop database
237+ if a.updateDesktopDB != "" {
238+ appDir := filepath.Dir(desktopPath)
239+ _ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run()
240+ }
241+
242+ return nil
243+}
244+
245+// InstallIcon writes the icon file to XDG icons directory.
246+func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
247+ log := logging.FromContext(ctx)
248+
249+ iconPath, err := getIconFilePath()
250+ if err != nil {
251+ return "", err
252+ }
253+
254+ // Ensure icons directory exists
255+ iconDir := filepath.Dir(iconPath)
256+ if err := os.MkdirAll(iconDir, dirPerm); err != nil {
257+ return "", fmt.Errorf("create icons dir: %w", err)
258+ }
259+
260+ // Write icon file
261+ if err := os.WriteFile(iconPath, svgData, filePerm); err != nil {
262+ return "", fmt.Errorf("write icon file: %w", err)
263+ }
264+
265+ log.Info().Str("path", iconPath).Msg("icon file installed")
266+
267+ return iconPath, nil
268+}
269+
270+// RemoveIcon removes the icon file from XDG icons directory.
271+func (a *Adapter) RemoveIcon(ctx context.Context) error {
272+ log := logging.FromContext(ctx)
273+
274+ iconPath, err := getIconFilePath()
275+ if err != nil {
276+ return err
277+ }
278+
279+ // Check if exists
280+ if _, err := os.Stat(iconPath); os.IsNotExist(err) {
281+ log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)")
282+ return nil
283+ }
284+
285+ if err := os.Remove(iconPath); err != nil {
286+ return fmt.Errorf("remove icon file: %w", err)
287+ }
288+
289+ log.Info().Str("path", iconPath).Msg("icon file removed")
290+
291+ return nil
292+}
293+
294+// SetAsDefaultBrowser sets dumber as the default web browser.
295+func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
296+ log := logging.FromContext(ctx)
297+
298+ if a.xdgSettingsPath == "" {
299+ return fmt.Errorf("xdg-settings not found (install xdg-utils)")
300+ }
301+
302+ // Check if desktop file is installed
303+ desktopPath, err := getDesktopFilePath()
304+ if err != nil {
305+ return err
306+ }
307+ if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
308+ return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
309+ }
310+
311+ // Set default browser
312+ cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFileName)
313+ if out, err := cmd.CombinedOutput(); err != nil {
314+ return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
315+ }
316+
317+ log.Info().Msg("dumber set as default browser")
318+ return nil
319+}
320+
321+// UnsetAsDefaultBrowser resets default browser if dumber is currently default.
322+func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
323+ log := logging.FromContext(ctx)
324+
325+ if a.xdgSettingsPath == "" {
326+ return nil // No xdg-settings, nothing to do
327+ }
328+
329+ // Check current default
330+ out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
331+ if err != nil {
332+ return nil // Can't determine, skip
333+ }
334+
335+ currentDefault := strings.TrimSpace(string(out))
336+ if currentDefault != desktopFileName {
337+ log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset")
338+ return nil
339+ }
340+
341+ // xdg-settings doesn't have a "reset" - user must set another browser
342+ log.Warn().Msg("dumber was default browser - user should set a new default")
343+ return nil
344+}
@@ -0,0 +1,344 @@
1+// Package desktop provides desktop environment integration for Linux (XDG).
2+package desktop
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+const (
17+ appName = "dumber"
18+ desktopFileName = "dumber.desktop"
19+ iconFileName = "dumber.svg"
20+ filePerm = 0644
21+ dirPerm = 0755
22+)
23+
24+// desktopFileTemplate is the freedesktop.org desktop entry format.
25+// %s placeholder for executable path.
26+const desktopFileTemplate = `[Desktop Entry]
27+Version=1.1
28+Type=Application
29+Name=Dumber
30+GenericName=Dumber Browser
31+Comment=It's a browser but dumber
32+Exec=%s browse %%U
33+Icon=dumber
34+Terminal=false
35+Categories=Network;WebBrowser;
36+MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
37+StartupNotify=true
38+StartupWMClass=dumber
39+`
40+
41+// Adapter implements port.DesktopIntegration using XDG tools.
42+type Adapter struct {
43+ xdgSettingsPath string
44+ updateDesktopDB string
45+}
46+
47+// New creates a new desktop integration adapter.
48+func New() port.DesktopIntegration {
49+ a := &Adapter{}
50+
51+ // Detect xdg-settings
52+ if path, err := exec.LookPath("xdg-settings"); err == nil {
53+ a.xdgSettingsPath = path
54+ }
55+
56+ // Detect update-desktop-database (optional)
57+ if path, err := exec.LookPath("update-desktop-database"); err == nil {
58+ a.updateDesktopDB = path
59+ }
60+
61+ return a
62+}
63+
64+// getApplicationsDir returns the XDG applications directory.
65+func getApplicationsDir() (string, error) {
66+ dataHome := os.Getenv("XDG_DATA_HOME")
67+ if dataHome == "" {
68+ home, err := os.UserHomeDir()
69+ if err != nil {
70+ return "", fmt.Errorf("get home dir: %w", err)
71+ }
72+ dataHome = filepath.Join(home, ".local", "share")
73+ }
74+ return filepath.Join(dataHome, "applications"), nil
75+}
76+
77+// getDesktopFilePath returns the full path to the desktop file.
78+func getDesktopFilePath() (string, error) {
79+ appDir, err := getApplicationsDir()
80+ if err != nil {
81+ return "", err
82+ }
83+ return filepath.Join(appDir, desktopFileName), nil
84+}
85+
86+// getIconFilePath returns the full path to the icon file.
87+// Uses hicolor theme scalable apps directory.
88+func getIconFilePath() (string, error) {
89+ dataHome := os.Getenv("XDG_DATA_HOME")
90+ if dataHome == "" {
91+ home, err := os.UserHomeDir()
92+ if err != nil {
93+ return "", fmt.Errorf("get home dir: %w", err)
94+ }
95+ dataHome = filepath.Join(home, ".local", "share")
96+ }
97+ return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil
98+}
99+
100+// getExecutablePath returns the path to the dumber executable.
101+func getExecutablePath() (string, error) {
102+ // First try the running executable
103+ execPath, err := os.Executable()
104+ if err == nil {
105+ // Resolve symlinks
106+ resolved, err := filepath.EvalSymlinks(execPath)
107+ if err == nil {
108+ execPath = resolved
109+ }
110+ return execPath, nil
111+ }
112+
113+ // Fallback to PATH lookup
114+ path, err := exec.LookPath(appName)
115+ if err != nil {
116+ return "", fmt.Errorf("cannot find %s executable: %w", appName, err)
117+ }
118+ return path, nil
119+}
120+
121+// GetStatus checks the current desktop integration state.
122+func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) {
123+ log := logging.FromContext(ctx)
124+ status := &port.DesktopIntegrationStatus{}
125+
126+ // Check desktop file
127+ desktopPath, err := getDesktopFilePath()
128+ if err != nil {
129+ return nil, err
130+ }
131+ status.DesktopFilePath = desktopPath
132+
133+ if _, err := os.Stat(desktopPath); err == nil {
134+ status.DesktopFileInstalled = true
135+ }
136+
137+ // Check icon file
138+ iconPath, err := getIconFilePath()
139+ if err != nil {
140+ return nil, err
141+ }
142+ status.IconFilePath = iconPath
143+
144+ if _, err := os.Stat(iconPath); err == nil {
145+ status.IconInstalled = true
146+ }
147+
148+ // Check executable
149+ execPath, err := getExecutablePath()
150+ if err == nil {
151+ status.ExecutablePath = execPath
152+ }
153+
154+ // Check default browser
155+ if a.xdgSettingsPath != "" {
156+ out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
157+ if err == nil {
158+ currentDefault := strings.TrimSpace(string(out))
159+ status.IsDefaultBrowser = currentDefault == desktopFileName
160+ }
161+ }
162+
163+ log.Debug().
164+ Bool("desktop_installed", status.DesktopFileInstalled).
165+ Bool("icon_installed", status.IconInstalled).
166+ Bool("default", status.IsDefaultBrowser).
167+ Str("desktop_path", status.DesktopFilePath).
168+ Str("icon_path", status.IconFilePath).
169+ Str("exec_path", status.ExecutablePath).
170+ Msg("desktop integration status")
171+
172+ return status, nil
173+}
174+
175+// InstallDesktopFile writes the desktop file to XDG applications directory.
176+func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) {
177+ log := logging.FromContext(ctx)
178+
179+ execPath, err := getExecutablePath()
180+ if err != nil {
181+ return "", err
182+ }
183+
184+ desktopPath, err := getDesktopFilePath()
185+ if err != nil {
186+ return "", err
187+ }
188+
189+ // Ensure applications directory exists
190+ appDir := filepath.Dir(desktopPath)
191+ if err := os.MkdirAll(appDir, dirPerm); err != nil {
192+ return "", fmt.Errorf("create applications dir: %w", err)
193+ }
194+
195+ // Generate desktop file content
196+ content := fmt.Sprintf(desktopFileTemplate, execPath)
197+
198+ // Write desktop file
199+ if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil {
200+ return "", fmt.Errorf("write desktop file: %w", err)
201+ }
202+
203+ log.Info().Str("path", desktopPath).Msg("desktop file installed")
204+
205+ // Update desktop database (optional, helps with some DEs)
206+ if a.updateDesktopDB != "" {
207+ if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil {
208+ log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)")
209+ }
210+ }
211+
212+ return desktopPath, nil
213+}
214+
215+// RemoveDesktopFile removes the desktop file from XDG applications directory.
216+func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
217+ log := logging.FromContext(ctx)
218+
219+ desktopPath, err := getDesktopFilePath()
220+ if err != nil {
221+ return err
222+ }
223+
224+ // Check if exists
225+ if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
226+ log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)")
227+ return nil
228+ }
229+
230+ if err := os.Remove(desktopPath); err != nil {
231+ return fmt.Errorf("remove desktop file: %w", err)
232+ }
233+
234+ log.Info().Str("path", desktopPath).Msg("desktop file removed")
235+
236+ // Update desktop database
237+ if a.updateDesktopDB != "" {
238+ appDir := filepath.Dir(desktopPath)
239+ _ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run()
240+ }
241+
242+ return nil
243+}
244+
245+// InstallIcon writes the icon file to XDG icons directory.
246+func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
247+ log := logging.FromContext(ctx)
248+
249+ iconPath, err := getIconFilePath()
250+ if err != nil {
251+ return "", err
252+ }
253+
254+ // Ensure icons directory exists
255+ iconDir := filepath.Dir(iconPath)
256+ if err := os.MkdirAll(iconDir, dirPerm); err != nil {
257+ return "", fmt.Errorf("create icons dir: %w", err)
258+ }
259+
260+ // Write icon file
261+ if err := os.WriteFile(iconPath, svgData, filePerm); err != nil {
262+ return "", fmt.Errorf("write icon file: %w", err)
263+ }
264+
265+ log.Info().Str("path", iconPath).Msg("icon file installed")
266+
267+ return iconPath, nil
268+}
269+
270+// RemoveIcon removes the icon file from XDG icons directory.
271+func (a *Adapter) RemoveIcon(ctx context.Context) error {
272+ log := logging.FromContext(ctx)
273+
274+ iconPath, err := getIconFilePath()
275+ if err != nil {
276+ return err
277+ }
278+
279+ // Check if exists
280+ if _, err := os.Stat(iconPath); os.IsNotExist(err) {
281+ log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)")
282+ return nil
283+ }
284+
285+ if err := os.Remove(iconPath); err != nil {
286+ return fmt.Errorf("remove icon file: %w", err)
287+ }
288+
289+ log.Info().Str("path", iconPath).Msg("icon file removed")
290+
291+ return nil
292+}
293+
294+// SetAsDefaultBrowser sets dumber as the default web browser.
295+func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
296+ log := logging.FromContext(ctx)
297+
298+ if a.xdgSettingsPath == "" {
299+ return fmt.Errorf("xdg-settings not found (install xdg-utils)")
300+ }
301+
302+ // Check if desktop file is installed
303+ desktopPath, err := getDesktopFilePath()
304+ if err != nil {
305+ return err
306+ }
307+ if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
308+ return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
309+ }
310+
311+ // Set default browser
312+ cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFileName)
313+ if out, err := cmd.CombinedOutput(); err != nil {
314+ return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
315+ }
316+
317+ log.Info().Msg("dumber set as default browser")
318+ return nil
319+}
320+
321+// UnsetAsDefaultBrowser resets default browser if dumber is currently default.
322+func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
323+ log := logging.FromContext(ctx)
324+
325+ if a.xdgSettingsPath == "" {
326+ return nil // No xdg-settings, nothing to do
327+ }
328+
329+ // Check current default
330+ out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
331+ if err != nil {
332+ return nil // Can't determine, skip
333+ }
334+
335+ currentDefault := strings.TrimSpace(string(out))
336+ if currentDefault != desktopFileName {
337+ log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset")
338+ return nil
339+ }
340+
341+ // xdg-settings doesn't have a "reset" - user must set another browser
342+ log.Warn().Msg("dumber was default browser - user should set a new default")
343+ return nil
344+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close