1package handlers
2
3import (
4 "context"
5 "fmt"
6 "path/filepath"
7 "regexp"
8 "strings"
9
10 "github.com/bnema/archup/internal/application/commands"
11 "github.com/bnema/archup/internal/application/dto"
12 "github.com/bnema/archup/internal/config"
13 "github.com/bnema/archup/internal/domain/ports"
14)
15
16// PostInstallHandler handles post-installation tasks
17type PostInstallHandler struct {
18 fs ports.FileSystem
19 httpClient ports.HTTPClient
20 chrExec ports.ChrootExecutor
21 scriptExec ports.ScriptExecutor
22 logger ports.Logger
23 rawURL string
24}
25
26// NewPostInstallHandler creates a new post-installation handler
27func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger, rawURL string) *PostInstallHandler {
28 return &PostInstallHandler{
29 fs: fs,
30 httpClient: httpClient,
31 chrExec: chrExec,
32 scriptExec: scriptExec,
33 logger: logger,
34 rawURL: rawURL,
35 }
36}
37
38// Handle executes post-installation tasks
39func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstallCommand) (*dto.PostInstallResult, error) {
40 h.logger.Info("Starting post-installation tasks", "username", cmd.Username)
41
42 result := &dto.PostInstallResult{
43 Success: false,
44 TasksRun: []string{},
45 ErrorDetail: "",
46 }
47
48 // Run post-boot scripts if requested
49 if cmd.RunPostBootScripts {
50 h.logger.Info("Running post-boot scripts")
51 result.TasksRun = append(result.TasksRun, "post-boot-scripts")
52
53 if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username, cmd.UserEmail); err != nil {
54 h.logger.Error("Failed to setup post-boot scripts", "error", err)
55 result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)
56 return result, err
57 }
58
59 h.logger.Info("Post-boot scripts prepared")
60 }
61
62 // Write Dank Linux flag file if user opted in
63 if cmd.InstallDankLinux {
64 if err := h.writeDankLinuxFlag(cmd.MountPoint); err != nil {
65 result.ErrorDetail = fmt.Sprintf("Failed to write Dank Linux flag: %v", err)
66 return result, fmt.Errorf("failed to write dank linux flag: %w", err)
67 }
68 result.TasksRun = append(result.TasksRun, "dank-linux-flag")
69 h.logger.Info("Dank Linux flag file written")
70 }
71
72 // Setup limine-snapper-sync for btrfs snapshot bootability
73 if err := h.setupSnapperSync(ctx, cmd.MountPoint); err != nil {
74 h.logger.Warn("Failed to setup limine-snapper-sync", "error", err)
75 }
76
77 // Install Plymouth theme if specified
78 if cmd.PlymouthTheme != "" {
79 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
80
81 themeDir := filepath.Join(cmd.MountPoint, "usr", "share", "plymouth", "themes", cmd.PlymouthTheme)
82 if err := h.fs.MkdirAll(themeDir, 0755); err != nil {
83 return result, fmt.Errorf("failed to create Plymouth theme directory: %w", err)
84 }
85
86 for _, file := range config.PlymouthFiles {
87 content, err := h.downloadTemplate(filepath.Join("assets", "plymouth", file))
88 if err != nil {
89 return result, fmt.Errorf("failed to download Plymouth file %s: %w", file, err)
90 }
91 if err := h.fs.WriteFile(filepath.Join(themeDir, file), content, 0644); err != nil {
92 return result, fmt.Errorf("failed to write Plymouth file %s: %w", file, err)
93 }
94 }
95
96 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "plymouth-set-default-theme", cmd.PlymouthTheme); err != nil {
97 return result, fmt.Errorf("failed to set Plymouth theme: %w", err)
98 }
99
100 // Rebuild initramfs so the Plymouth theme takes effect at boot
101 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "mkinitcpio", "-P"); err != nil {
102 return result, fmt.Errorf("failed to rebuild initramfs after Plymouth theme: %w", err)
103 }
104
105 result.TasksRun = append(result.TasksRun, fmt.Sprintf("plymouth-theme-%s", cmd.PlymouthTheme))
106 h.logger.Info("Plymouth theme installed and initramfs rebuilt")
107 }
108
109 if err := h.tunePacmanConfig(cmd.MountPoint); err != nil {
110 h.logger.Warn("Failed to tune pacman.conf", "error", err)
111 }
112
113 if err := h.installLimineHook(cmd.MountPoint, cmd.TargetDisk); err != nil {
114 h.logger.Warn("Failed to install limine hook", "error", err)
115 }
116
117 // Final cleanup and verification
118 result.VerificationWarnings = h.verifyInstallation(cmd.MountPoint, cmd.Encrypted)
119 if len(result.VerificationWarnings) > 0 {
120 h.logger.Warn("Post-install verification warnings", "warnings", result.VerificationWarnings)
121 }
122
123 h.logger.Info("Post-installation tasks completed")
124 result.Success = true
125
126 return result, nil
127}
128
129func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username, email string) error {
130 postBootPath := filepath.Join(mountPoint, "usr", "local", "share", "archup", "post-boot")
131 if err := h.fs.MkdirAll(postBootPath, 0755); err != nil {
132 return fmt.Errorf("failed to create post-boot directory: %w", err)
133 }
134
135 // Write service file with placeholder replacement for email and username
136 serviceDest := filepath.Join(mountPoint, "etc", "systemd", "system", "archup-first-boot.service")
137 if err := h.writeServiceFile("install/mandatory/post-boot/archup-first-boot.service", serviceDest, username, email); err != nil {
138 return fmt.Errorf("failed to write service file: %w", err)
139 }
140
141 for _, script := range config.PostBootScripts {
142 src := filepath.Join("install", "mandatory", "post-boot", script)
143 dst := filepath.Join(postBootPath, script)
144 if err := h.writeFromTemplate(src, dst); err != nil {
145 return fmt.Errorf("failed to write %s: %w", script, err)
146 }
147 // Only set executable bit on shell scripts
148 if strings.HasSuffix(script, ".sh") {
149 if err := h.fs.Chmod(dst, 0755); err != nil {
150 return fmt.Errorf("failed to set permissions for %s: %w", script, err)
151 }
152 }
153 }
154
155 if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", config.PostBootServiceName); err != nil {
156 return fmt.Errorf("failed to enable first-boot service: %w", err)
157 }
158
159 return nil
160}
161
162func (h *PostInstallHandler) writeFromTemplate(src string, dst string) error {
163 content, err := h.tryReadLocal(src)
164 if err != nil {
165 content, err = h.downloadTemplate(src)
166 if err != nil {
167 return err
168 }
169 }
170
171 return h.fs.WriteFile(dst, content, 0644)
172}
173
174// writeServiceFile writes the systemd service file with placeholder replacement
175func (h *PostInstallHandler) writeServiceFile(src, dst, username, email string) error {
176 content, err := h.tryReadLocal(src)
177 if err != nil {
178 content, err = h.downloadTemplate(src)
179 if err != nil {
180 return err
181 }
182 }
183
184 // Replace placeholders with actual values
185 serviceContent := string(content)
186 serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", username)
187 serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", email)
188
189 return h.fs.WriteFile(dst, []byte(serviceContent), 0644)
190}
191
192func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
193 localPath := filepath.Join(config.DefaultInstallDir, path)
194 if exists, err := h.fs.Exists(localPath); err == nil && exists {
195 return h.fs.ReadFile(localPath)
196 }
197 if exists, err := h.fs.Exists(path); err == nil && exists {
198 return h.fs.ReadFile(path)
199 }
200 return nil, fmt.Errorf("local template not found")
201}
202
203// writeDankLinuxFlag writes a flag file to the installed system so that
204// dms-opt-in.sh auto-runs without prompting on first boot.
205func (h *PostInstallHandler) writeDankLinuxFlag(mountPoint string) error {
206 if err := h.fs.MkdirAll(filepath.Join(mountPoint, "var", "lib"), 0755); err != nil {
207 return fmt.Errorf("failed to create /var/lib: %w", err)
208 }
209 flagPath := filepath.Join(mountPoint, "var", "lib", "archup-install-danklinux")
210 return h.fs.WriteFile(flagPath, []byte(""), 0644)
211}
212
213func (h *PostInstallHandler) setupSnapperSync(ctx context.Context, mountPoint string) error {
214 if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman", "-S", "--noconfirm", "--needed", "limine-snapper-sync"); err != nil {
215 return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
216 }
217 if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", "limine-snapper-sync.service"); err != nil {
218 return fmt.Errorf("failed to enable limine-snapper-sync.service: %w", err)
219 }
220 // Sanitize config: comment out enrollment commands when limine-entry-tool is absent
221 if err := h.sanitizeLimineSnapperSyncConfig(mountPoint); err != nil {
222 h.logger.Warn("Failed to sanitize limine-snapper-sync config", "error", err)
223 }
224 return nil
225}
226
227func (h *PostInstallHandler) sanitizeLimineSnapperSyncConfig(mountPoint string) error {
228 confPath := filepath.Join(mountPoint, "etc", "limine-snapper-sync.conf")
229 exists, err := h.fs.Exists(confPath)
230 if err != nil || !exists {
231 return err // config missing, nothing to sanitize
232 }
233
234 // If the binary is present, enrollment workflow is intentionally configured — leave config alone
235 enrollBin := filepath.Join(mountPoint, "usr", "bin", "limine-reset-enroll")
236 if _, err := h.fs.Stat(enrollBin); err == nil {
237 return nil
238 }
239
240 b, err := h.fs.ReadFile(confPath)
241 if err != nil {
242 return fmt.Errorf("failed to read limine-snapper-sync.conf: %w", err)
243 }
244
245 s := string(b)
246 reBefore := regexp.MustCompile(`(?m)^COMMANDS_BEFORE_SAVE=.*$`)
247 reAfter := regexp.MustCompile(`(?m)^COMMANDS_AFTER_SAVE=.*$`)
248 s = reBefore.ReplaceAllString(s, `# COMMANDS_BEFORE_SAVE="" # disabled: limine-entry-tool not installed`)
249 s = reAfter.ReplaceAllString(s, `# COMMANDS_AFTER_SAVE="" # disabled: limine-entry-tool not installed`)
250
251 return h.fs.WriteFile(confPath, []byte(s), 0644)
252}
253
254// limineDiskPlaceholder is the placeholder in limine-update.hook replaced at install time.
255const limineDiskPlaceholder = "DISK_PLACEHOLDER"
256
257func (h *PostInstallHandler) installLimineHook(mountPoint, targetDisk string) error {
258 if targetDisk == "" {
259 return fmt.Errorf("target disk is required for limine hook installation")
260 }
261 hooksDir := filepath.Join(mountPoint, "etc", "pacman.d", "hooks")
262 if err := h.fs.MkdirAll(hooksDir, 0755); err != nil {
263 return fmt.Errorf("failed to create hooks dir: %w", err)
264 }
265 content, err := h.tryReadLocal("install/configs/limine-update.hook")
266 if err != nil {
267 content, err = h.downloadTemplate("install/configs/limine-update.hook")
268 if err != nil {
269 return fmt.Errorf("failed to get limine hook template: %w", err)
270 }
271 }
272 hookContent := strings.ReplaceAll(string(content), limineDiskPlaceholder, targetDisk)
273 return h.fs.WriteFile(filepath.Join(hooksDir, "limine-update.hook"), []byte(hookContent), 0644)
274}
275
276func (h *PostInstallHandler) tunePacmanConfig(mountPoint string) error {
277 confPath := filepath.Join(mountPoint, "etc", "pacman.conf")
278 content, err := h.fs.ReadFile(confPath)
279 if err != nil {
280 return fmt.Errorf("failed to read pacman.conf: %w", err)
281 }
282 conf := string(content)
283 conf = uncommentPacmanOption(conf, "Color")
284 conf = uncommentPacmanOption(conf, "ParallelDownloads")
285 conf = uncommentPacmanOption(conf, "ILoveCandy")
286 return h.fs.WriteFile(confPath, []byte(conf), 0644)
287}
288
289// uncommentPacmanOption uncomments a pacman.conf option, handling optional spaces after #.
290func uncommentPacmanOption(conf, option string) string {
291 re := regexp.MustCompile(`(?m)^#\s*(` + regexp.QuoteMeta(option) + `.*)$`)
292 return re.ReplaceAllString(conf, "$1")
293}
294
295func (h *PostInstallHandler) verifyInstallation(mountPoint string, encrypted bool) []string {
296 warnings := []string{}
297 checks := []struct{ path, name string }{
298 {filepath.Join(mountPoint, "etc", "fstab"), "fstab"},
299 {filepath.Join(mountPoint, "boot", "limine.conf"), "limine.conf"},
300 {filepath.Join(mountPoint, "boot", "EFI", "BOOT", "BOOTX64.EFI"), "EFI boot file"},
301 }
302 if encrypted {
303 checks = append(checks, struct{ path, name string }{filepath.Join(mountPoint, "etc", "crypttab"), "crypttab"})
304 }
305 for _, c := range checks {
306 if _, err := h.fs.Stat(c.path); err != nil {
307 warnings = append(warnings, fmt.Sprintf("missing %s: %s", c.name, c.path))
308 }
309 }
310 return warnings
311}
312
313func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
314 url := fmt.Sprintf("%s/%s", h.rawURL, path)
315 h.logger.Info("Downloading template", "url", url)
316 resp, err := h.httpClient.Get(url)
317 if err != nil {
318 return nil, err
319 }
320 defer func() {
321 if closeErr := resp.Close(); closeErr != nil {
322 h.logger.Warn("Failed to close HTTP response", "error", closeErr)
323 }
324 }()
325 if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
326 return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url)
327 }
328 return resp.Body(), nil
329}