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 @@ | |||
| 1 | 1 | package assets | |
| 2 | 2 | ||
| 3 | - | import "embed" | |
| 3 | + | import ( | |
| 4 | + | "embed" | |
| 5 | + | _ "embed" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // WebUIAssets contains the embedded webui build output (homepage and blocked pages). | |
| 6 | 9 | // Used by the scheme handler to serve dumb://home and dumb://blocked. | |
| 7 | 10 | // | |
| 8 | 11 | //go:embed webui/* | |
| 9 | 12 | 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 assets | 1 | 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.FS | 12 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "context" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| 10 | 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 | 17 | var ( | |
| 14 | - | purgeForce bool | |
| 15 | - | purgeAll bool | |
| 18 | + | purgeForce bool | |
| 19 | + | purgeAll bool | |
| 20 | + | purgeDesktop bool | |
| 16 | 21 | ) | |
| 17 | 22 | ||
| 18 | 23 | var purgeCmd = &cobra.Command{ | |
| @@ -26,7 +31,8 @@ This will delete: | |||
| 26 | 31 | - ~/.local/state/dumber/ (state, logs) | |
| 27 | 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 | 36 | RunE: runPurge, | |
| 31 | 37 | } | |
| 32 | 38 | ||
| @@ -34,6 +40,7 @@ func init() { | |||
| 34 | 40 | rootCmd.AddCommand(purgeCmd) | |
| 35 | 41 | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt") | |
| 36 | 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 | 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 | 111 | fmt.Println("No dumber directories found.") | |
| 76 | 112 | return nil | |
| 77 | 113 | } | |
| 78 | 114 | ||
| 79 | 115 | // Show what will be deleted | |
| 80 | - | fmt.Println("The following directories will be removed:") | |
| 116 | + | fmt.Println("The following will be removed:") | |
| 81 | 117 | fmt.Println() | |
| 82 | 118 | for _, d := range existing { | |
| 83 | 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 | 132 | fmt.Println() | |
| 86 | 133 | ||
| 87 | 134 | // 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 | 177 | if len(errors) > 0 { | |
| 111 | 178 | fmt.Println() | |
| 112 | 179 | fmt.Println("Errors:") | |
| 113 | 180 | for _, e := range errors { | |
| 114 | 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 | 186 | 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 bool | 18 | + | purgeForce bool |
| 15 | - | purgeAll bool | 19 | + | 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 nil | 112 | return nil | ||
| 77 | } | 113 | } | ||
| 78 | 114 | ||||
| 79 | // Show what will be deleted | 115 | // 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 --force | 134 | // 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 | + | } | |||