From 8f1dc1607a6b6f65dd9f7dc54e7353286cc27346 Mon Sep 17 00:00:00 2001 From: graphixa Date: Sat, 12 Sep 2026 00:57:18 +1000 Subject: [PATCH 01/23] Update test for installed fonts on Windows to handle cases with no user-installed fonts for CI runs --- internal/platform/list_installed_fonts_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/platform/list_installed_fonts_test.go b/internal/platform/list_installed_fonts_test.go index 5c7a172..266d0ba 100644 --- a/internal/platform/list_installed_fonts_test.go +++ b/internal/platform/list_installed_fonts_test.go @@ -56,7 +56,9 @@ func TestListInstalledFonts_WindowsUserAndSystemDirs(t *testing.T) { if err != nil { t.Fatalf("ListInstalledFonts %s (%s): %v", scope, dir, err) } - if len(names) == 0 { + // Fresh Windows accounts (including CI runners) often have no user-installed + // fonts. System fonts should always be present. + if scope == MachineScope && len(names) == 0 { t.Fatalf("%s font dir %s: expected installed fonts", scope, dir) } } From 702f3b1e30b022f67c19e178124a3549baf0d607 Mon Sep 17 00:00:00 2001 From: graphixa Date: Sat, 12 Sep 2026 15:59:23 +1000 Subject: [PATCH 02/23] Improve add reliability for install, download, and exits. Make package outcomes drive non-zero exits, isolate staging, roll back partial installs with safe force replace, and classify downloads with cancellable external tools and consistent checksums. --- cmd/add.go | 478 ++++++++++++-------- cmd/add_reliability_test.go | 389 ++++++++++++++++ cmd/browse_model.go | 3 +- cmd/import.go | 5 + cmd/root.go | 18 +- go.mod | 2 +- internal/components/preview.go | 16 +- internal/components/progress_bar.go | 106 ++++- internal/components/table_custom.go | 7 +- internal/components/unified_form.go | 7 +- internal/installations/lock.go | 67 +++ internal/installations/lock_test.go | 59 +++ internal/installations/lock_unix.go | 16 + internal/installations/lock_windows.go | 38 ++ internal/installations/recovery.go | 78 ++++ internal/installations/recovery_test.go | 40 ++ internal/installations/registry.go | 77 ++-- internal/network/classify.go | 238 ++++++++++ internal/network/classify_test.go | 100 ++++ internal/network/download_fallbacks.go | 298 +++++++++--- internal/network/download_fallbacks_test.go | 48 ++ internal/network/download_http_client.go | 4 +- internal/network/exec.go | 195 ++++++++ internal/network/exec_test.go | 116 +++++ internal/network/exec_unix.go | 37 ++ internal/network/exec_windows.go | 46 ++ internal/platform/darwin.go | 29 +- internal/platform/linux.go | 23 +- internal/platform/mutation.go | 120 +++++ internal/platform/mutation_test.go | 113 +++++ internal/platform/name_table_test.go | 3 +- internal/platform/platform.go | 23 + internal/platform/temp.go | 146 +++++- internal/platform/temp_test.go | 117 +++++ internal/platform/windows.go | 65 +-- internal/repo/checksum.go | 126 ++++++ internal/repo/checksum_test.go | 64 +++ internal/repo/download_candidates_test.go | 10 +- internal/repo/download_reliability_test.go | 183 ++++++++ internal/repo/font.go | 353 +++++++++------ internal/shared/errors.go | 44 +- internal/testutil/minifont.go | 81 ++++ main.go | 7 +- 43 files changed, 3421 insertions(+), 574 deletions(-) create mode 100644 cmd/add_reliability_test.go create mode 100644 internal/installations/lock.go create mode 100644 internal/installations/lock_test.go create mode 100644 internal/installations/lock_unix.go create mode 100644 internal/installations/lock_windows.go create mode 100644 internal/installations/recovery.go create mode 100644 internal/installations/recovery_test.go create mode 100644 internal/network/classify.go create mode 100644 internal/network/classify_test.go create mode 100644 internal/network/exec.go create mode 100644 internal/network/exec_test.go create mode 100644 internal/network/exec_unix.go create mode 100644 internal/network/exec_windows.go create mode 100644 internal/platform/mutation.go create mode 100644 internal/platform/mutation_test.go create mode 100644 internal/platform/temp_test.go create mode 100644 internal/repo/checksum.go create mode 100644 internal/repo/checksum_test.go create mode 100644 internal/repo/download_reliability_test.go create mode 100644 internal/testutil/minifont.go diff --git a/cmd/add.go b/cmd/add.go index 794bcc2..5ddfd81 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -486,10 +487,11 @@ func showMultipleMatchesAndExit(fontName string, matches []repo.FontMatch) { } var addCmd = &cobra.Command{ - Use: "add [ ...]", - Aliases: []string{"install"}, - Short: "Install fonts from configured sources", - SilenceUsage: true, + Use: "add [ ...]", + Aliases: []string{"install"}, + Short: "Install fonts from configured sources", + SilenceUsage: true, + SilenceErrors: true, Long: `Install one or multiple fonts in a single command. Fonts can be specified by name (e.g., "Roboto") or Font ID (e.g., "google.roboto"). @@ -510,7 +512,7 @@ Use --scope to set installation location: fmt.Printf("%s\n", ui.RenderError("A font ID is required")) fmt.Printf("%s\n", ui.Text.Render("Use 'fontget add --help' for more information.")) fmt.Println() - return nil + return shared.AlreadyPrinted(fmt.Errorf("a font ID is required")) } return nil }, @@ -528,7 +530,7 @@ Use --scope to set installation location: // Double check args to prevent panic if len(args) == 0 || strings.TrimSpace(args[0]) == "" { - return nil // Args validator will have already shown the help + return shared.AlreadyPrinted(fmt.Errorf("a font ID is required")) } // Create font manager @@ -573,7 +575,7 @@ Use --scope to set installation location: // Check elevation if err := cmdutils.CheckElevation(cmd, fontManager, installScope); err != nil { if errors.Is(err, cmdutils.ErrElevationRequired) { - return nil // Already printed user-friendly message + return shared.AlreadyPrinted(err) } output.GetVerbose().Error("%v", err) output.GetDebug().Error("checkElevation() failed: %v", err) @@ -602,7 +604,7 @@ Use --scope to set installation location: // Check for multiple matches (would have been handled in resolveAndValidateFonts) if fontsToInstall == nil { - return nil // Multiple matches case - already shown + return shared.AlreadyPrinted(fmt.Errorf("multiple fonts match; specify a font ID")) } // Check if flags are set @@ -614,31 +616,25 @@ Use --scope to set installation location: if len(fontsToInstall) == 0 { if len(notFoundFonts) > 0 { if IsDebug() { - // In debug mode, show technical details to console output.GetDebug().Error("No fonts found to install. The following font(s) were not found in any source:") for _, fontName := range notFoundFonts { output.GetDebug().Error(" - %s", fontName) } } else { - // In normal/verbose mode, show user-friendly message with suggestions - // Always ensure output is shown, even if suggestions fail defer func() { if r := recover(); r != nil { - // If table rendering panics, at least show the error message fmt.Fprintf(os.Stdout, "Font(s) not found: %v\n", notFoundFonts) fmt.Fprintf(os.Stdout, "Try using the search command to find available fonts.\n") } }() - // Ensure output is visible - print directly to stdout os.Stdout.Sync() showGroupedFontNotFoundWithSuggestions(notFoundFonts) os.Stdout.Sync() } - } else { - // No fonts to install and no not-found fonts - this shouldn't happen, but handle gracefully - fmt.Printf("%s\n", ui.ErrorText.Render("No fonts specified or found.")) + return shared.AlreadyPrinted(&shared.FontNotFoundError{FontName: strings.Join(notFoundFonts, ", ")}) } - return nil + fmt.Printf("%s\n", ui.ErrorText.Render("No fonts specified or found.")) + return shared.AlreadyPrinted(fmt.Errorf("no fonts specified or found")) } // Verbose-level information for users - show operational details before progress bar @@ -665,53 +661,72 @@ Use --scope to set installation location: // No need for separate header - the progress bar will show the title - // For debug mode: bypass TUI and use plain text output for easier parsing/logging - if IsDebug() { - return installFontsInDebugMode(fontManager, fontsToInstall, installScope, force, fontDir, status, scope) + staging, err := platform.NewOperationStaging() + if err != nil { + return fmt.Errorf("failed to create operation staging: %w", err) } + defer func() { + if cleanupErr := staging.Cleanup(); cleanupErr != nil { + output.GetDebug().State("Failed to cleanup operation staging: %v", cleanupErr) + } + }() + + opCtx := cmd.Context() + if opCtx == nil { + opCtx = context.Background() + } + + packagesFailed := 0 + cancelled := false - // Create operation items for unified progress - one item per font family operationItems := setupInstallationProgressBar(fontsToInstall) - // Determine title based on scope title := OpInstallingFonts if installScope == platform.MachineScope { title = OpInstallingFontsAllUsers } - // Add blank line before progress bar (per spacing guidelines) - // Only add if verbose mode is not enabled (verbose section already ends with blank line) if !output.IsVerboseOutputEnabled() { fmt.Println() } - // Run unified progress for download and install + suppressVerboseDownloads := components.UseInteractiveRenderer() && !IsDebug() + progressErr := components.RunProgressBar( title, operationItems, - verbose, // Verbose mode: show operational details and file/variant listings - debug, // Debug mode: show technical details + verbose, + debug, func(send func(msg tea.Msg), cancelChan <-chan struct{}) error { - // Process each font group (one per font family) + ctx, cancel := context.WithCancel(opCtx) + defer cancel() + go func() { + select { + case <-cancelChan: + cancel() + case <-ctx.Done(): + } + }() + for itemIndex, fontGroup := range fontsToInstall { - // Start downloading - update status to show we're working on this item + if err := ctx.Err(); err != nil { + cancelled = true + return shared.ErrOperationCancelled + } + send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", Message: "Downloading from " + fontGroup.SourceName, }) - // Update progress based on items started (not completed yet) - // This shows progress as we work through items, but won't reach 100% until done percent := float64(itemIndex) / float64(len(fontsToInstall)) * 100 send(components.ProgressUpdateMsg{Percent: percent}) - // Install the font using the installFont helper lastStep := "" lastPctBucket := -1 onProgress := func(step string, stepPct float64) { - // Avoid spamming the UI: only update on step change or ~5% within-step progress. - bucket := int(shared.Clamp01(stepPct) * 20.0) // 0..20 + bucket := int(shared.Clamp01(stepPct) * 20.0) if step == lastStep && bucket == lastPctBucket { return } @@ -733,18 +748,33 @@ Use --scope to set installation location: }) } result, err := installFont( + ctx, fontGroup.Fonts, fontGroup.FontID, fontManager, installScope, force, fontDir, - true, // suppress per-file verbose download lines while Bubble Tea owns stdout + staging, + suppressVerboseDownloads, onProgress, ) if err != nil { - status.Failed += result.Failed + packagesFailed++ + if result != nil { + if result.Status != InstallStatusFailed { + result.Status = InstallStatusFailed + } + if result.Failed == 0 && result.Success == 0 { + status.Failed++ + } else { + status.Failed += result.Failed + } + status.Errors = append(status.Errors, result.Errors...) + } else { + status.Failed++ + } GetLogger().Error("Failed to process font %s: %v", fontGroup.FontName, err) errorMsg := err.Error() send(components.ItemUpdateMsg{ @@ -753,35 +783,23 @@ Use --scope to set installation location: Message: "Operation failed", ErrorMessage: errorMsg, }) + if errors.Is(err, shared.ErrOperationCancelled) || errors.Is(err, context.Canceled) { + cancelled = true + return shared.ErrOperationCancelled + } continue } - // Update status + if result.Status == InstallStatusFailed { + packagesFailed++ + } + status.Installed += result.Success status.Skipped += result.Skipped status.Failed += result.Failed status.Errors = append(status.Errors, result.Errors...) - // Store details for verbose mode - need to categorize files - // Result.Details contains: installed files, then skipped, then failed - installedCount := result.Success - skippedCount := result.Skipped - failedCount := result.Failed - - var installedFiles, skippedFiles, failedFiles []string - idx := 0 - if installedCount > 0 && idx < len(result.Details) { - installedFiles = result.Details[idx : idx+installedCount] - idx += installedCount - } - if skippedCount > 0 && idx < len(result.Details) { - skippedFiles = result.Details[idx : idx+skippedCount] - idx += skippedCount - } - if failedCount > 0 && idx < len(result.Details) { - failedFiles = result.Details[idx : idx+failedCount] - } - + installedFiles, skippedFiles, failedFiles := processInstallResult(result) fontDetails := FontOperationDetails{ FontName: fontGroup.FontName, SourceName: fontGroup.SourceName, @@ -793,23 +811,15 @@ Use --scope to set installation location: } operationDetails = append(operationDetails, fontDetails) - // Determine status based on results finalStatus := result.Status - - // Build variants list - show in verbose mode (one line per manifest variant; avoids mixing basenames with human labels) var variantsWithStatus []string if verbose { variantsWithStatus = variantLinesForVerboseProgress(fontGroup.Fonts) } - // Default mode: don't show variants in TUI (variants shown in debug mode only) - - // Get first error message if status is failed var errorMsg string if finalStatus == InstallStatusFailed && len(result.Errors) > 0 { errorMsg = result.Errors[0] } - - // Determine scope label for display scopeLabel := InstallScopeLabelUser if installScope == platform.MachineScope { scopeLabel = InstallScopeLabelMachine @@ -818,13 +828,12 @@ Use --scope to set installation location: send(components.ItemUpdateMsg{ Index: itemIndex, Status: finalStatus, - Message: "Installed", // Message is overridden by View() based on status + Message: "Installed", ErrorMessage: errorMsg, Variants: variantsWithStatus, Scope: scopeLabel, }) - // Update progress percentage - now based on actual completion send(components.ProgressUpdateMsg{Percent: OverallInstallPercent(itemIndex, len(fontsToInstall), installStepCompleted, 1)}) } @@ -833,23 +842,18 @@ Use --scope to set installation location: ) if progressErr != nil { - // Check if it was a cancellation - if errors.Is(progressErr, shared.ErrOperationCancelled) { + if errors.Is(progressErr, shared.ErrOperationCancelled) || cancelled { fmt.Printf("%s\n", ui.WarningText.Render("Installation cancelled.")) fmt.Println() - return nil // Don't return error for cancellation + return shared.AlreadyPrinted(shared.ErrOperationCancelled) } GetLogger().Error("Failed to install fonts: %v", progressErr) return progressErr } - // Show not found fonts right after progress bar output (before status report) handleNotFoundFonts(notFoundFonts, IsDebug()) - // Note: Error messages for failed installations are already shown in the progress bar - // No need to duplicate them here - verbose mode should be user-friendly, not technical - - // Print status report after progress bar completes (this should be last) + showSummary := output.IsVerboseOutputEnabled() || packagesFailed > 0 || status.Failed > 0 || len(notFoundFonts) > 0 || !components.UseInteractiveRenderer() output.PrintStatusReport(output.StatusReport{ Success: status.Installed, Skipped: status.Skipped, @@ -857,13 +861,20 @@ Use --scope to set installation location: SuccessLabel: "Installed", SkippedLabel: "Skipped", FailedLabel: "Failed", - }, output.IsVerboseOutputEnabled()) + }, showSummary) GetLogger().Info("Installation complete - Installed: %d, Skipped: %d, Failed: %d", status.Installed, status.Skipped, status.Failed) - // Don't return error for installation failures since we already show detailed status report - // This prevents duplicate error messages while maintaining proper exit codes + if len(notFoundFonts) > 0 { + packagesFailed++ + } + if packagesFailed > 0 || status.Failed > 0 { + return shared.AlreadyPrinted(&shared.FontInstallationError{ + FailedCount: packagesFailed, + TotalCount: len(fontsToInstall) + len(notFoundFonts), + }) + } return nil }, } @@ -959,13 +970,15 @@ func installFontsInDebugMode(fontManager platform.FontManager, fontsToInstall [] output.GetDebug().State("Installing font %s in %s (directory: %s)", fontGroup.FontName, scopeLabel, fontDir) result, err := installFont( + context.Background(), fontGroup.Fonts, fontGroup.FontID, fontManager, installScope, force, fontDir, - false, // debug path: allow per-file verbose download lines + nil, + false, nil, ) @@ -1081,19 +1094,31 @@ func cloneDownloadOptsForProgress(downloadOpts *repo.DownloadFontOptions, archiv } // downloadFontVariants downloads all variants of a font family -func downloadFontVariants(fontFiles []repo.FontFile, tempDir string, archiveSourcePrefix, archiveFontID string, downloadOpts *repo.DownloadFontOptions, onProgress StepProgressFunc) ([]string, error) { +func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, staging *platform.OperationStaging, fontID string, archiveSourcePrefix string, downloadOpts *repo.DownloadFontOptions, onProgress StepProgressFunc) ([]string, error) { start := time.Now() var allFontPaths []string - // Download each variant - only log errors and unusual cases total := len(fontFiles) for i, fontFile := range fontFiles { + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + } if onProgress != nil && total > 0 { onProgress(installStepDownload, float64(i)/float64(total)) } opts := downloadOpts + if opts == nil { + opts = &repo.DownloadFontOptions{} + } else { + cpy := *opts + opts = &cpy + } + opts.Context = ctx + opts.ArchiveSourcePrefix = archiveSourcePrefix + opts.ArchiveFontID = fontID if onProgress != nil { - opts = cloneDownloadOptsForProgress(downloadOpts, archiveSourcePrefix, archiveFontID) opts.OnBytesDownloaded = func(doneBytes int64, totalBytes int64) { if totalBytes > 0 { onProgress(installStepDownload, float64(doneBytes)/float64(totalBytes)) @@ -1104,29 +1129,22 @@ func downloadFontVariants(fontFiles []repo.FontFile, tempDir string, archiveSour onProgress(installStepExtract, float64(done)/float64(total)) return } - // Unknown totals (e.g., tar streams): use a soft-saturating curve so the UI moves. onProgress(installStepExtract, float64(done)/float64(done+12)) } - } else if archiveSourcePrefix != "" || archiveFontID != "" { - if opts == nil { - opts = &repo.DownloadFontOptions{ - ArchiveSourcePrefix: archiveSourcePrefix, - ArchiveFontID: archiveFontID, - } - } else { - opts.ArchiveSourcePrefix = archiveSourcePrefix - opts.ArchiveFontID = archiveFontID - } + } + + variantDir, err := staging.VariantDir(fontID, fontFile.Variant) + if err != nil { + return nil, err } output.GetDebug().State("Calling repo.DownloadAndExtractFont() for variant: %s from %s", fontFile.Variant, fontFile.DownloadURL) - fontPaths, err := repo.DownloadAndExtractFont(&fontFile, tempDir, opts) + fontPaths, err := repo.DownloadAndExtractFont(&fontFile, variantDir, opts) if err != nil { output.GetDebug().State("repo.DownloadAndExtractFont() failed for variant %s: %v", fontFile.Variant, err) return nil, err } allFontPaths = append(allFontPaths, fontPaths...) - // Only log if multiple files extracted (unusual case worth noting) if len(fontPaths) > 1 { output.GetDebug().State("Extracted %d file(s) from variant: %s", len(fontPaths), fontFile.Variant) } @@ -1141,7 +1159,7 @@ func downloadFontVariants(fontFiles []repo.FontFile, tempDir string, archiveSour } // installDownloadedFonts installs downloaded font files to system -func installDownloadedFonts(fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress StepProgressFunc) (installed, skipped, failed int, details []string, errors []string, downloadSize int64) { +func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress StepProgressFunc) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { start := time.Now() var installedFiles []string var skippedFiles []string @@ -1151,64 +1169,87 @@ func installDownloadedFonts(fontPaths []string, fontManager platform.FontManager total := len(fontPaths) for i, fontPath := range fontPaths { + if ctx != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + break + } + } if onProgress != nil && total > 0 { onProgress(installStepInstall, float64(i)/float64(total)) } fontDisplayName := filepath.Base(fontPath) - // Get file size before we potentially remove it - if fileInfo, err := os.Stat(fontPath); err == nil { + if fileInfo, statErr := os.Stat(fontPath); statErr == nil { downloadSize += fileInfo.Size() } - // Check if font is already installed (unless force flag is set) if !force { expectedPath := filepath.Join(fontDir, fontDisplayName) - if _, err := os.Stat(expectedPath); err == nil { + if _, statErr := os.Stat(expectedPath); statErr == nil { output.GetDebug().State("Font already installed, skipping: %s", fontDisplayName) skipped++ - os.Remove(fontPath) // Clean up temp file + _ = os.Remove(fontPath) skippedFiles = append(skippedFiles, fontDisplayName) continue } } - // Install the font (defer OS cache / Windows font notification until after batch) + var mut platform.FileMutation + batchOpts.Mutation = &mut + if testInstallFailPoint == "register" { + batchOpts.FailPoint = platform.InstallFailRegister + } else { + batchOpts.FailPoint = "" + } + output.GetDebug().State("Installing font file: %s to %s (scope: %s)", fontDisplayName, fontDir, installScope) installErr := fontManager.InstallFont(fontPath, installScope, force, batchOpts) + if mut.DestPath != "" { + mutations = append(mutations, mut) + } + if installErr != nil { - // Actual installation failure - os.Remove(fontPath) // Clean up temp file + _ = os.Remove(fontPath) failed++ errorMsg := makeUserFriendlyError(fontDisplayName, installErr) - errors = append(errors, errorMsg) + errs = append(errs, errorMsg) failedFiles = append(failedFiles, fontDisplayName) output.GetDebug().Error("fontManager.InstallFont() failed for %s: %v", fontDisplayName, installErr) - continue + err = installErr + break + } + + if testInstallFailPoint == "after-first" && len(mutations) == 1 { + failed++ + err = fmt.Errorf("injected failure after first file mutation") + failedFiles = append(failedFiles, fontDisplayName) + break + } + if testInstallFailPoint == "after-later" && len(mutations) >= 2 { + failed++ + err = fmt.Errorf("injected failure after later file mutation") + failedFiles = append(failedFiles, fontDisplayName) + break } - // Validate that the installed file is actually a parsable font. Font sources can occasionally return - // HTML/WAF challenge pages (or other non-font payloads) under a .ttf name; we don't want to claim - // success and leave junk in the Fonts directory that `list` will then skip as invalid. installedPath := filepath.Join(fontDir, fontDisplayName) if _, statErr := os.Stat(installedPath); statErr == nil { if _, metaErr := platform.ExtractFontMetadata(installedPath); metaErr != nil { - _ = os.Remove(installedPath) - os.Remove(fontPath) // Clean up temp file + _ = os.Remove(fontPath) failed++ errorMsg := makeUserFriendlyError(fontDisplayName, fmt.Errorf("installed file is not a valid font: %w", metaErr)) - errors = append(errors, errorMsg) + errs = append(errs, errorMsg) failedFiles = append(failedFiles, fontDisplayName) - output.GetDebug().Warning("Installed file failed validation and was removed: %s (%v)", fontDisplayName, metaErr) - continue + output.GetDebug().Warning("Installed file failed validation: %s (%v)", fontDisplayName, metaErr) + err = metaErr + break } } output.GetDebug().State("Successfully installed font: %s", fontDisplayName) - - // Clean up temp file - os.Remove(fontPath) + _ = os.Remove(fontPath) installed++ installedFiles = append(installedFiles, fontDisplayName) } @@ -1217,8 +1258,7 @@ func installDownloadedFonts(fontPaths []string, fontManager platform.FontManager onProgress(installStepInstall, 1) } - // Single cache refresh / font-change notification after all copies (avoids pkill fontd / fc-cache / WM_FONTCHANGE per file) - if installed > 0 { + if err == nil && installed > 0 { if onProgress != nil { onProgress(installStepFinalize, 0) } @@ -1227,9 +1267,7 @@ func installDownloadedFonts(fontPaths []string, fontManager platform.FontManager isDarwinNonCritical := strings.Contains(strings.ToLower(errStr), "failed to refresh font cache (non-critical") if isDarwinNonCritical { output.GetDebug().Warning("Font cache refresh failed (non-critical on macOS 14+): %v", flushErr) - output.GetDebug().State("Fonts installed successfully; cache refresh is optional. Fonts may appear after app restart.") } else { - // Linux: fc-cache failure is serious for discovery; Windows: notify failure is serious output.GetDebug().Error("Post-install font cache flush failed: %v", flushErr) } } @@ -1238,15 +1276,17 @@ func installDownloadedFonts(fontPaths []string, fontManager platform.FontManager } } - // Store categorized details: installed, then skipped, then failed details = append(details, installedFiles...) details = append(details, skippedFiles...) details = append(details, failedFiles...) output.GetDebug().State("installDownloadedFonts: installed=%d skipped=%d failed=%d total=%dms", installed, skipped, failed, time.Since(start).Milliseconds()) - return installed, skipped, failed, details, errors, downloadSize + return installed, skipped, failed, details, errs, downloadSize, mutations, err } +// testInstallFailPoint injects package-level failures in tests: after-first, after-later, register, provenance. +var testInstallFailPoint string + // buildInstallResult builds InstallResult from installation outcomes func buildInstallResult(status string, message string, installed, skipped, failed int, details []string, errors []string, downloadSize int64) *InstallResult { return &InstallResult{ @@ -1279,36 +1319,36 @@ func buildInstallResult(status string, message string, installed, skipped, faile // - InstallResult: Contains success/skipped/failed counts and details // - error: Installation error if the operation fails func installFont( + ctx context.Context, fontFiles []repo.FontFile, fontID string, fontManager platform.FontManager, installScope platform.InstallationScope, force bool, fontDir string, + staging *platform.OperationStaging, suppressVerboseDownloads bool, onProgress StepProgressFunc, ) (*InstallResult, error) { + if ctx == nil { + ctx = context.Background() + } if onProgress != nil { onProgress(installStepPrecheck, 0) } - // Check if font is already installed BEFORE downloading (unless force flag is set) - // This saves bandwidth by skipping downloads for already-installed fonts if !force && fontID != "" && len(fontFiles) > 0 { - // Get font name from first font file (all files in a family should have the same name) fontName := "" - if len(fontFiles) > 0 && fontFiles[0].Name != "" { + if fontFiles[0].Name != "" { fontName = fontFiles[0].Name } alreadyInstalled, checkErr := checkFontsAlreadyInstalled(fontID, fontName, installScope, fontManager) if checkErr != nil { - // Log warning but continue with installation (fail-safe behavior) GetLogger().Warn("Failed to check if font is already installed (ID: %s): %v. Proceeding with installation.", fontID, checkErr) } else if alreadyInstalled { if onProgress != nil { onProgress(installStepPrecheck, 1) onProgress(installStepCompleted, 1) } - // Font is already installed - skip download and mark all variants as skipped output.GetDebug().State("Font %s (ID: %s) is already installed, skipping download", fontName, fontID) var details []string for _, fontFile := range fontFiles { @@ -1321,76 +1361,149 @@ func installFont( onProgress(installStepPrecheck, 1) } - // Download all variants of this font family - tempDir, err := platform.GetTempFontsDir() - if err != nil { - return buildInstallResult(InstallStatusFailed, "Failed to create temp directory", 0, 0, len(fontFiles), nil, nil, 0), fmt.Errorf("failed to create temp directory: %w", err) - } - output.GetDebug().State("Temp directory: %s", tempDir) - - // Ensure cleanup happens even if download fails - defer func() { - if cleanupErr := platform.CleanupTempFontsDir(); cleanupErr != nil { - output.GetDebug().State("Failed to cleanup temp directory: %v", cleanupErr) - // Don't fail the installation if cleanup fails, just log it + if staging == nil { + created, stErr := platform.NewOperationStaging() + if stErr != nil { + return buildInstallResult(InstallStatusFailed, "Failed to create temp directory", 0, 0, len(fontFiles), nil, nil, 0), stErr } - }() + staging = created + defer func() { _ = staging.Cleanup() }() + } downloadOpts := (*repo.DownloadFontOptions)(nil) if suppressVerboseDownloads { - downloadOpts = &repo.DownloadFontOptions{SuppressVerboseProgressLine: true} + downloadOpts = &repo.DownloadFontOptions{SuppressVerboseProgressLine: true, Context: ctx} + } else { + downloadOpts = &repo.DownloadFontOptions{Context: ctx} } archivePrefix := archiveSourcePrefixFromFontID(fontID) - if archivePrefix != "" || fontID != "" { - if downloadOpts == nil { - downloadOpts = &repo.DownloadFontOptions{ - ArchiveSourcePrefix: archivePrefix, - ArchiveFontID: fontID, - } - } else { - downloadOpts.ArchiveSourcePrefix = archivePrefix - downloadOpts.ArchiveFontID = fontID - } - } if onProgress != nil { onProgress(installStepDownload, 0) } - allFontPaths, downloadErr := downloadFontVariants(fontFiles, tempDir, archivePrefix, fontID, downloadOpts, onProgress) + allFontPaths, downloadErr := downloadFontVariants(ctx, fontFiles, staging, fontID, archivePrefix, downloadOpts, onProgress) if downloadErr != nil { return buildInstallResult(InstallStatusFailed, "Download failed", 0, 0, len(fontFiles), nil, nil, 0), downloadErr } - // Install downloaded fonts - installed, skipped, failed, details, errors, downloadSize := installDownloadedFonts( - allFontPaths, fontManager, installScope, fontDir, force, onProgress) + unlockDest, lockErr := installations.LockDestination(ctx, fontDir) + if lockErr != nil { + return buildInstallResult(InstallStatusFailed, "Failed to lock destination", 0, 0, len(fontFiles), nil, nil, 0), lockErr + } + defer unlockDest() + + installed, skipped, failed, details, instErrs, downloadSize, mutations, installErr := installDownloadedFonts( + ctx, allFontPaths, fontManager, installScope, fontDir, force, onProgress) + + if installErr != nil || failed > 0 { + rbErr := rollbackPackageMutations(ctx, fontID, string(installScope), mutations, installErr) + res := buildInstallResult(InstallStatusFailed, "Installation failed", 0, skipped, failed, details, instErrs, downloadSize) + if rbErr != nil { + return res, rbErr + } + if installErr != nil { + return res, installErr + } + return res, fmt.Errorf("package install incomplete") + } - // Determine final status status := InstallStatusCompleted message := "Installed" - if failed > 0 && installed == 0 { - status = InstallStatusFailed - message = "Installation failed" - } else if skipped > 0 && installed == 0 && failed == 0 { + if skipped > 0 && installed == 0 && failed == 0 { status = InstallStatusSkipped message = "Already installed" } - res := buildInstallResult(status, message, installed, skipped, failed, details, errors, downloadSize) - tryRecordInstallationRegistry(fontID, fontFiles, installScope, fontDir, res) + res := buildInstallResult(status, message, installed, skipped, failed, details, instErrs, downloadSize) + if status == InstallStatusCompleted && installed > 0 { + if testInstallFailPoint == "provenance" { + rbErr := rollbackPackageMutations(ctx, fontID, string(installScope), mutations, fmt.Errorf("injected provenance failure")) + res.Status = InstallStatusFailed + res.Success = 0 + if rbErr != nil { + return res, rbErr + } + return res, fmt.Errorf("injected provenance failure") + } + if recErr := tryRecordInstallationRegistry(fontID, fontFiles, installScope, fontDir, res); recErr != nil { + rbErr := rollbackPackageMutations(ctx, fontID, string(installScope), mutations, recErr) + res.Status = InstallStatusFailed + res.Success = 0 + if rbErr != nil { + return res, rbErr + } + return res, recErr + } + for _, mut := range mutations { + _ = platform.CommitMutation(mut) + } + } return res, nil } -// tryRecordInstallationRegistry writes install provenance after a fully successful install. -func tryRecordInstallationRegistry(fontID string, fontFiles []repo.FontFile, installScope platform.InstallationScope, fontDir string, result *InstallResult) { +func rollbackPackageMutations(ctx context.Context, fontID, scope string, mutations []platform.FileMutation, cause error) error { + recCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) + defer cancel() + _ = recCtx + + var outstanding []string + var completed []string + var recErrs []string + if cause != nil { + recErrs = append(recErrs, cause.Error()) + } + var backupPaths []string + var affected []string + for i := len(mutations) - 1; i >= 0; i-- { + mut := mutations[i] + affected = append(affected, mut.DestPath) + if mut.BackupPath != "" { + backupPaths = append(backupPaths, mut.BackupPath) + } + if err := platform.RollbackMutation(mut); err != nil { + outstanding = append(outstanding, "restore "+mut.DestPath) + recErrs = append(recErrs, err.Error()) + continue + } + completed = append(completed, "restore "+mut.DestPath) + if mut.BackupPath != "" { + _ = os.Remove(mut.BackupPath) + } + } + if len(outstanding) == 0 { + return nil + } + path, saveErr := installations.SaveRecoveryRecord(installations.RecoveryRecord{ + PackageID: fontID, + Scope: scope, + AffectedPaths: affected, + BackupPaths: backupPaths, + CompletedSteps: completed, + OutstandingSteps: outstanding, + Errors: recErrs, + }) + msg := fmt.Sprintf("recovery required for %s; backups retained", fontID) + if path != "" { + msg = fmt.Sprintf("%s; recovery record: %s", msg, path) + if len(backupPaths) > 0 { + msg = fmt.Sprintf("%s; backups: %s", msg, strings.Join(backupPaths, ", ")) + } + fmt.Printf("%s\n", ui.ErrorText.Render(msg)) + } + if saveErr != nil { + return fmt.Errorf("%w: %v: %v", shared.ErrRecoveryRequired, cause, saveErr) + } + return fmt.Errorf("%w: %s", shared.ErrRecoveryRequired, msg) +} + +func tryRecordInstallationRegistry(fontID string, fontFiles []repo.FontFile, installScope platform.InstallationScope, fontDir string, result *InstallResult) error { if fontID == "" || result == nil { - return + return nil } if result.Status != InstallStatusCompleted || result.Success <= 0 || result.Failed != 0 { - return + return nil } if result.Success > len(result.Details) { - output.GetDebug().Warning("installation registry: details shorter than success count, skipping record") - return + return fmt.Errorf("installation registry: details shorter than success count") } catalogName := "" variantByBasename := make(map[string]string) @@ -1443,11 +1556,10 @@ func tryRecordInstallationRegistry(fontID string, fontFiles []repo.FontFile, ins }) } if len(files) != nonEmptyBasenames { - output.GetDebug().Error("installation registry: incomplete metadata (%d/%d faces); not updating registry for %q", len(files), nonEmptyBasenames, fontID) - return + return fmt.Errorf("installation registry: incomplete metadata (%d/%d faces)", len(files), nonEmptyBasenames) } if len(files) == 0 { - return + return fmt.Errorf("installation registry: no files to record") } installSrc := "" if meta, metaErr := repo.MatchRepositoryFontByID(fontID); metaErr == nil && meta != nil { @@ -1455,20 +1567,16 @@ func tryRecordInstallationRegistry(fontID string, fontFiles []repo.FontFile, ins } else if metaErr != nil { output.GetDebug().Warning("installation registry: catalog lookup for installation_source failed: %v", metaErr) } - err := installations.RecordInstallation(installations.RecordParams{ - FontID: fontID, - CatalogName: catalogName, - InstallationSource: installSrc, - Scope: string(installScope), - FontGetVersion: version.GetVersion(), - Files: files, + return installations.RecordInstallation(installations.RecordParams{ + FontID: fontID, + CatalogName: catalogName, + InstallationSource: installSrc, + Scope: string(installScope), + FontGetVersion: version.GetVersion(), + Files: files, }) - if err != nil { - output.GetDebug().Error("installation registry record failed: %v", err) - } } -// makeUserFriendlyError converts technical error messages to user-friendly explanations func makeUserFriendlyError(fontName string, err error) string { errStr := strings.ToLower(err.Error()) diff --git a/cmd/add_reliability_test.go b/cmd/add_reliability_test.go new file mode 100644 index 0000000..70b4a6e --- /dev/null +++ b/cmd/add_reliability_test.go @@ -0,0 +1,389 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "fontget/internal/platform" + "fontget/internal/repo" + "fontget/internal/shared" + "fontget/internal/testutil" +) + +func TestAddMissingFontIDExitNonZero(t *testing.T) { + rootCmd.SetArgs([]string{"add"}) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + err := rootCmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "font ID is required") { + t.Fatalf("unexpected err: %v", err) + } +} + +func TestAddHelpExitZeroSubprocess(t *testing.T) { + bin := buildTestFontget(t) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "add", "--help") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("help failed: %v\n%s", err, out) + } + if cmd.ProcessState.ExitCode() != 0 { + t.Fatalf("help exit %d", cmd.ProcessState.ExitCode()) + } +} + +func TestAddNoArgsSubprocessNonZero(t *testing.T) { + bin := buildTestFontget(t) + home := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "add") + cmd.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "FONTGET_ACCEPT_AGREEMENTS=1", + "FONTGET_ACCEPT_DEFAULTS=1", + ) + cmd.Stdin = bytes.NewReader(nil) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + t.Fatal("expected non-zero exit") + } + if cmd.ProcessState.ExitCode() == 0 { + t.Fatal("exit 0") + } + combined := stdout.String() + stderr.String() + if strings.Contains(combined, "?1049") { + t.Fatalf("interactive escape sequences in piped output: %q", combined) + } +} + +func TestAddDebugMissingIDNonZero(t *testing.T) { + bin := buildTestFontget(t) + home := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "add", "--debug") + cmd.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "FONTGET_ACCEPT_AGREEMENTS=1", + "FONTGET_ACCEPT_DEFAULTS=1", + ) + cmd.Stdin = bytes.NewReader(nil) + if err := cmd.Run(); err == nil { + t.Fatal("expected non-zero") + } +} + +var ( + testBinOnce sync.Once + testBinPath string + testBinErr error +) + +func buildTestFontget(t *testing.T) string { + t.Helper() + testBinOnce.Do(func() { + dir, err := os.MkdirTemp("", "fontget-reltest-*") + if err != nil { + testBinErr = err + return + } + name := "fontget-test" + if runtime.GOOS == "windows" { + name += ".exe" + } + out := filepath.Join(dir, name) + root, err := filepath.Abs("..") + if err != nil { + testBinErr = err + return + } + cmd := exec.Command("go", "build", "-o", out, ".") + cmd.Dir = root + b, err := cmd.CombinedOutput() + if err != nil { + testBinErr = err + return + } + _ = b + testBinPath = out + }) + if testBinErr != nil { + t.Fatalf("go build: %v", testBinErr) + } + return testBinPath +} + +func TestDisplayedErrorSkipsDuplicate(t *testing.T) { + err := shared.AlreadyPrinted(errors.New("shown")) + var displayed *shared.DisplayedError + if !errors.As(err, &displayed) { + t.Fatal("expected DisplayedError") + } +} + +type copyFontManager struct { + dir string +} + +func (m *copyFontManager) FlushFontCache(scope platform.InstallationScope) error { return nil } +func (m *copyFontManager) InstallFont(fontPath string, scope platform.InstallationScope, force bool, opts *platform.InstallFontOptions) error { + if opts == nil { + opts = &platform.InstallFontOptions{} + } + mut, err := platform.PlaceFontFile(fontPath, filepath.Join(m.dir, filepath.Base(fontPath)), force, opts) + if err != nil { + return err + } + if opts.FailPoint == platform.InstallFailRegister { + _ = platform.RollbackMutation(mut) + return errors.New("injected failure at register") + } + return nil +} +func (m *copyFontManager) RemoveFont(fontName string, scope platform.InstallationScope, opts *platform.RemoveFontOptions) error { + return os.Remove(filepath.Join(m.dir, fontName)) +} +func (m *copyFontManager) GetFontDir(scope platform.InstallationScope) string { return m.dir } +func (m *copyFontManager) RequiresElevation(scope platform.InstallationScope) bool { + return false +} +func (m *copyFontManager) IsElevated() (bool, error) { return true, nil } +func (m *copyFontManager) GetElevationCommand() (string, []string, error) { + return "", nil, nil +} + +func TestInstallRollbackAfterFirstMutation(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + fm := ©FontManager{dir: fontDir} + + a := testutil.MinimalTTF("Alpha", "Regular") + b := testutil.MinimalTTF("Beta", "Regular") + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + pathA := filepath.Join(staging.Root, "Alpha-Regular.ttf") + pathB := filepath.Join(staging.Root, "Beta-Regular.ttf") + if err := os.WriteFile(pathA, a, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathB, b, 0644); err != nil { + t.Fatal(err) + } + + testInstallFailPoint = "after-first" + t.Cleanup(func() { testInstallFailPoint = "" }) + + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil) + if err == nil { + t.Fatal("expected injected failure") + } + if rb := rollbackPackageMutations(context.Background(), "test.pkg", "user", mutations, err); rb != nil { + t.Fatalf("rollback: %v", rb) + } + entries, _ := os.ReadDir(fontDir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + t.Fatalf("rolled-back dest must not keep created fonts, found %s", e.Name()) + } +} + +func TestInstallRollbackRestoresReplacedBytes(t *testing.T) { + fontDir := t.TempDir() + old := testutil.MinimalTTF("OldFam", "Regular") + neu := testutil.MinimalTTF("NewFam", "Regular") + dst := filepath.Join(fontDir, "Face.ttf") + if err := os.WriteFile(dst, old, 0644); err != nil { + t.Fatal(err) + } + src := filepath.Join(t.TempDir(), "Face.ttf") + if err := os.WriteFile(src, neu, 0644); err != nil { + t.Fatal(err) + } + mut, err := platform.PlaceFontFile(src, dst, true, nil) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dst) + if !bytes.Equal(got, neu) { + t.Fatal("replace did not write new bytes") + } + if err := platform.RollbackMutation(mut); err != nil { + t.Fatal(err) + } + got, _ = os.ReadFile(dst) + if !bytes.Equal(got, old) { + t.Fatal("rollback did not restore old bytes") + } +} + +func TestPackageFailureDoesNotCountRolledBackAsInstalled(t *testing.T) { + res := buildInstallResult(InstallStatusFailed, "Installation failed", 0, 0, 1, nil, nil, 0) + if res.Success != 0 || res.Status != InstallStatusFailed { + t.Fatalf("%+v", res) + } +} + +func TestInstallRollbackAfterLaterMutation(t *testing.T) { + fontDir := t.TempDir() + fm := ©FontManager{dir: fontDir} + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + pathA := filepath.Join(staging.Root, "Alpha-Regular.ttf") + pathB := filepath.Join(staging.Root, "Beta-Regular.ttf") + if err := os.WriteFile(pathA, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathB, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { + t.Fatal(err) + } + testInstallFailPoint = "after-later" + t.Cleanup(func() { testInstallFailPoint = "" }) + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil) + if err == nil { + t.Fatal("expected injected failure") + } + if len(mutations) < 2 { + t.Fatalf("need two mutations before later fail, got %d", len(mutations)) + } + if rb := rollbackPackageMutations(context.Background(), "test.pkg", "user", mutations, err); rb != nil { + t.Fatalf("rollback: %v", rb) + } + entries, _ := os.ReadDir(fontDir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + t.Fatalf("rolled-back dest must not keep created fonts, found %s", e.Name()) + } +} + +func TestInstallRegisterFailRollsBack(t *testing.T) { + fontDir := t.TempDir() + fm := ©FontManager{dir: fontDir} + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + src := filepath.Join(staging.Root, "Face.ttf") + if err := os.WriteFile(src, testutil.MinimalTTF("Face", "Regular"), 0644); err != nil { + t.Fatal(err) + } + testInstallFailPoint = "register" + t.Cleanup(func() { testInstallFailPoint = "" }) + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{src}, fm, platform.UserScope, fontDir, false, nil) + if err == nil { + t.Fatal("expected register failure") + } + if rb := rollbackPackageMutations(context.Background(), "test.pkg", "user", mutations, err); rb != nil { + t.Fatalf("rollback: %v", rb) + } + entries, _ := os.ReadDir(fontDir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + t.Fatalf("register failure left %s", e.Name()) + } +} + +func TestInstallProvenanceFailRollsBack(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + payload := testutil.MinimalTTF("ProvFam", "Regular") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "font/ttf") + _, _ = w.Write(payload) + })) + t.Cleanup(srv.Close) + + fontDir := t.TempDir() + fm := ©FontManager{dir: fontDir} + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + testInstallFailPoint = "provenance" + t.Cleanup(func() { testInstallFailPoint = "" }) + + files := []repo.FontFile{{ + Name: "ProvFam", + Variant: "Regular", + Path: "ProvFam-Regular.ttf", + DownloadURL: srv.URL + "/ProvFam-Regular.ttf", + }} + res, err := installFont(context.Background(), files, "test.prov", fm, platform.UserScope, false, fontDir, staging, true, nil) + if err == nil { + t.Fatal("expected provenance failure") + } + if res == nil || res.Status == InstallStatusCompleted || res.Success != 0 { + t.Fatalf("must not report complete after provenance failure: %+v", res) + } + entries, _ := os.ReadDir(fontDir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + t.Fatalf("provenance failure left installed file %s", e.Name()) + } +} + +func TestRollbackFailureWritesRecoveryRecord(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + dir := t.TempDir() + dest := filepath.Join(dir, "Face.ttf") + if err := os.Mkdir(dest, 0755); err != nil { + t.Fatal(err) + } + backup := dest + ".fontget-bak" + if err := os.WriteFile(backup, []byte("old-bytes"), 0644); err != nil { + t.Fatal(err) + } + mut := platform.FileMutation{DestPath: dest, BackupPath: backup, Replaced: true} + err := rollbackPackageMutations(context.Background(), "test.pkg", "user", []platform.FileMutation{mut}, errors.New("install failed")) + if err == nil || !errors.Is(err, shared.ErrRecoveryRequired) { + t.Fatalf("want ErrRecoveryRequired, got %v", err) + } + if _, statErr := os.Stat(backup); statErr != nil { + t.Fatalf("backup must be retained: %v", statErr) + } + recDir := filepath.Join(home, ".fontget", "recovery") + entries, _ := os.ReadDir(recDir) + if len(entries) == 0 { + t.Fatal("expected recovery record file") + } +} diff --git a/cmd/browse_model.go b/cmd/browse_model.go index 292ffeb..762820b 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "strings" "sync" @@ -562,7 +563,7 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea ch <- browseOpProgressMsg{phase: step, percent: OverallInstallPercent(0, 1, step, stepPct)} } - ir, ierr := installFont(res.Fonts, res.FontID, fm, scope, force, fontDir, true, onProgress) + ir, ierr := installFont(context.Background(), res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress) ch <- installFinishedMsg{result: ir, err: ierr, fontID: fontID} }() return m.waitForOpMsg() diff --git a/cmd/import.go b/cmd/import.go index c1ee87a..00025b1 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "errors" "fmt" @@ -635,12 +636,14 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni }) } result, err := installFont( + cmd.Context(), fontGroup.Fonts, fontGroup.FontID, fontManager, installScope, force, fontDir, + nil, true, onProgress, ) @@ -746,12 +749,14 @@ func importFontsInDebugMode(fontManager platform.FontManager, fontsToInstall []F output.GetDebug().State("Calling installFont(%s, %s, %s, %v, %s)", fontGroup.FontID, scopeLabel, fontDir, force, "...") result, err := installFont( + context.Background(), fontGroup.Fonts, fontGroup.FontID, fontManager, installScope, force, fontDir, + nil, false, nil, ) diff --git a/cmd/root.go b/cmd/root.go index a2c6466..c6573bf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "fontget/internal/components" @@ -427,24 +428,13 @@ Examples: // Execute runs the root command func Execute() error { - // Set up signal handling for graceful shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigChan - // Force exit on interrupt - os.Exit(1) - }() - - err := rootCmd.Execute() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + err := rootCmd.ExecuteContext(ctx) if err != nil { - // Check if it's our custom error type if _, ok := err.(*shared.FontInstallationError); ok { - // Just return the error without showing help return err } - // For other errors, let Cobra handle them return err } return nil diff --git a/go.mod b/go.mod index d2bef5e..1ccd07e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 golang.org/x/image v0.39.0 + golang.org/x/sys v0.47.0 golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -37,6 +38,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/internal/components/preview.go b/internal/components/preview.go index b08a4ab..ae01c11 100644 --- a/internal/components/preview.go +++ b/internal/components/preview.go @@ -236,14 +236,14 @@ func (m *PreviewModel) View(width int) string { // Make it look like fontget info with multiple sections // Labels are colored, but content values use terminal default (no color) cardContent := strings.Builder{} - cardContent.WriteString(previewStyles.CardLabel.Render("Name:") + " " + "Example Font") - cardContent.WriteString("\n") - cardContent.WriteString(previewStyles.CardLabel.Render("ID:") + " " + "example.font") - cardContent.WriteString("\n") - cardContent.WriteString("\n") // Empty line for spacing - cardContent.WriteString(previewStyles.CardLabel.Render("Category:") + " " + "Sans Serif") - cardContent.WriteString("\n") - cardContent.WriteString(previewStyles.CardLabel.Render("Tags:") + " " + "modern, clean") + cardContent.WriteString(previewStyles.CardLabel.Render("Name:")) + cardContent.WriteString(" Example Font\n") + cardContent.WriteString(previewStyles.CardLabel.Render("ID:")) + cardContent.WriteString(" example.font\n\n") + cardContent.WriteString(previewStyles.CardLabel.Render("Category:")) + cardContent.WriteString(" Sans Serif\n") + cardContent.WriteString(previewStyles.CardLabel.Render("Tags:")) + cardContent.WriteString(" modern, clean") // Render card with preview theme's CardTitle style (not global ui.CardTitle) cardWidth := width - 2 diff --git a/internal/components/progress_bar.go b/internal/components/progress_bar.go index 20d26e8..c14be86 100644 --- a/internal/components/progress_bar.go +++ b/internal/components/progress_bar.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "os" "strings" "time" @@ -12,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/term" ) // OperationItem represents a single item in the progress display @@ -41,6 +43,7 @@ type ProgressBarModel struct { program *tea.Program statusReport *StatusReportData cancelChan chan struct{} // Channel to signal cancellation + opDone chan struct{} } // Message types for communication @@ -114,6 +117,7 @@ func NewProgressBar(title string, items []OperationItem, verboseMode bool, debug ProgressBar: prog, Spinner: spin, cancelChan: make(chan struct{}), + opDone: make(chan struct{}), } } @@ -128,14 +132,25 @@ func (m ProgressBarModel) Init() tea.Cmd { // startOperation runs the actual work in background func (m ProgressBarModel) startOperation() tea.Cmd { return func() tea.Msg { - // Run the operation in a goroutine to avoid blocking go func() { + defer func() { + if m.opDone != nil { + select { + case <-m.opDone: + default: + close(m.opDone) + } + } + }() err := m.operationFunc(m.program) - // If cancelled, return a cancellation error - if m.cancelled { + select { + case <-m.cancelChan: err = shared.ErrOperationCancelled + default: + } + if m.program != nil { + m.program.Send(operationCompleteMsg{err: err}) } - m.program.Send(operationCompleteMsg{err: err}) }() return nil } @@ -152,22 +167,17 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If already completed, any key quits return m, tea.Quit } else { - // If still running, mark as interrupted and quit immediately - // Don't process any more operationCompleteMsg messages m.quitting = true m.cancelled = true m.err = shared.ErrOperationCancelled - // Signal cancellation via channel if it exists if m.cancelChan != nil { select { case <-m.cancelChan: - // Already closed default: close(m.cancelChan) } } - // Quit immediately - don't wait for operation - return m, tea.Quit + return m, waitForOperationQuit(m.opDone) } } // If operation is complete, any key press should quit @@ -599,33 +609,84 @@ func operationTickCmd() tea.Cmd { }) } -// RunProgressBar runs the progress display with the given operation +func waitForOperationQuit(done <-chan struct{}) tea.Cmd { + return func() tea.Msg { + if done != nil { + <-done + } + return quitMsg{} + } +} + +// UseInteractiveRenderer is true only when both stdin and stdout are terminals. +func UseInteractiveRenderer() bool { + return term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) +} + +// RunProgressBar runs the progress display with the given operation. +// Interactive Bubble Tea is used only when stdin and stdout are terminals and debug is off. func RunProgressBar(title string, items []OperationItem, verboseMode bool, debugMode bool, operation func(send func(msg tea.Msg), cancelChan <-chan struct{}) error) error { - // Initialize the model - model := NewProgressBar(title, items, verboseMode, debugMode) + if debugMode || !UseInteractiveRenderer() { + return runPlainProgressBar(items, operation) + } + return runInteractiveProgressBar(title, items, verboseMode, debugMode, operation) +} - // Create the Bubble Tea program - p := tea.NewProgram(model) +func runPlainProgressBar(items []OperationItem, operation func(send func(msg tea.Msg), cancelChan <-chan struct{}) error) error { + cancelChan := make(chan struct{}) + send := func(msg tea.Msg) { + update, ok := msg.(ItemUpdateMsg) + if !ok || update.Index < 0 || update.Index >= len(items) { + return + } + name := items[update.Index].Name + if update.Name != "" { + name = update.Name + } + switch update.Status { + case "failed": + if update.ErrorMessage != "" { + fmt.Printf("%s: failed: %s\n", name, update.ErrorMessage) + } else { + fmt.Printf("%s: failed\n", name) + } + case "completed": + fmt.Printf("%s: installed\n", name) + case "skipped": + fmt.Printf("%s: skipped\n", name) + } + } + return operation(send, cancelChan) +} - // Store the program reference so operation can send messages +func runInteractiveProgressBar(title string, items []OperationItem, verboseMode bool, debugMode bool, operation func(send func(msg tea.Msg), cancelChan <-chan struct{}) error) error { + model := NewProgressBar(title, items, verboseMode, debugMode) + p := tea.NewProgram(model) model.program = p - - // Wrap the operation to work with the program model.operationFunc = func(program *tea.Program) error { - // Call the operation with a send function that uses program.Send - // Also pass cancelChan so operation can check for cancellation return operation(func(msg tea.Msg) { + select { + case <-model.cancelChan: + return + default: + } program.Send(msg) }, model.cancelChan) } - // Run the program finalModel, err := p.Run() if err != nil { + select { + case <-model.cancelChan: + default: + close(model.cancelChan) + } + if model.opDone != nil { + <-model.opDone + } return err } - // Check if there was an operation error or cancellation if m, ok := finalModel.(ProgressBarModel); ok { if m.cancelled { return shared.ErrOperationCancelled @@ -634,6 +695,5 @@ func RunProgressBar(title string, items []OperationItem, verboseMode bool, debug return m.err } } - return nil } diff --git a/internal/components/table_custom.go b/internal/components/table_custom.go index 3b2ef71..fac0f87 100644 --- a/internal/components/table_custom.go +++ b/internal/components/table_custom.go @@ -205,17 +205,18 @@ func (ct *CustomTable) handleNavigation(key string) { return } var newCursor int - if key == "up" { + switch key { + case "up": newCursor = ct.cursor - 1 if newCursor < 0 { newCursor = 0 } - } else if key == "down" { + case "down": newCursor = ct.cursor + 1 if newCursor >= len(ct.rows) { newCursor = len(ct.rows) - 1 } - } else { + default: return // Not a navigation key } diff --git a/internal/components/unified_form.go b/internal/components/unified_form.go index 0e4e376..6f1ccc9 100644 --- a/internal/components/unified_form.go +++ b/internal/components/unified_form.go @@ -184,7 +184,8 @@ func (m *UnifiedFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle enter based on focused component if m.FocusedIdx < len(m.Components) { comp := &m.Components[m.FocusedIdx] - if comp.Type == ComponentButtonGroup { + switch comp.Type { + case ComponentButtonGroup: // Button group handles enter internally action := comp.ButtonGroup.HandleKey(key) if action != "" { @@ -198,12 +199,12 @@ func (m *UnifiedFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } } - } else if comp.Type == ComponentCheckboxList { + case ComponentCheckboxList: // Toggle checkbox if comp.CheckboxList.Cursor >= 0 && comp.CheckboxList.Cursor < len(comp.CheckboxList.Items) { comp.CheckboxList.Items[comp.CheckboxList.Cursor].Checked = !comp.CheckboxList.Items[comp.CheckboxList.Cursor].Checked } - } else { + default: // Text input - validate and submit if m.validateForm() { if m.OnSubmit != nil { diff --git a/internal/installations/lock.go b/internal/installations/lock.go new file mode 100644 index 0000000..0d13991 --- /dev/null +++ b/internal/installations/lock.go @@ -0,0 +1,67 @@ +package installations + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "fontget/internal/shared" +) + +func registryLockPath() string { + return RegistryPath() + ".lock" +} + +func withRegistryFileLock(ctx context.Context, fn func() error) error { + if ctx == nil { + ctx = context.Background() + } + unlock, err := lockFile(ctx, registryLockPath()) + if err != nil { + return err + } + defer unlock() + return fn() +} + +func lockFile(ctx context.Context, path string) (func(), error) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("%w: lock dir: %v", shared.ErrLocalFailure, err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("%w: open lock: %v", shared.ErrLocalFailure, err) + } + deadline := time.Now().Add(30 * time.Second) + for { + if err := ctx.Err(); err != nil { + _ = f.Close() + return nil, err + } + if err := tryExclusiveLock(f); err == nil { + return func() { + _ = unlockFile(f) + _ = f.Close() + }, nil + } + if time.Now().After(deadline) { + _ = f.Close() + return nil, fmt.Errorf("timed out waiting for lock %s", path) + } + t := time.NewTimer(50 * time.Millisecond) + select { + case <-ctx.Done(): + t.Stop() + _ = f.Close() + return nil, ctx.Err() + case <-t.C: + } + } +} + +// LockDestination serializes conflicting destination mutations in fontDir. Not held during downloads. +func LockDestination(ctx context.Context, fontDir string) (func(), error) { + return lockFile(ctx, filepath.Join(fontDir, ".fontget-install.lock")) +} diff --git a/internal/installations/lock_test.go b/internal/installations/lock_test.go new file mode 100644 index 0000000..fa06858 --- /dev/null +++ b/internal/installations/lock_test.go @@ -0,0 +1,59 @@ +package installations + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestLockDestinationSerializesOverlappingWork(t *testing.T) { + dir := t.TempDir() + var overlapping int + var maxOverlapping int + var mu sync.Mutex + var wg sync.WaitGroup + const n = 4 + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + unlock, err := LockDestination(context.Background(), dir) + if err != nil { + t.Errorf("lock: %v", err) + return + } + mu.Lock() + overlapping++ + if overlapping > maxOverlapping { + maxOverlapping = overlapping + } + mu.Unlock() + time.Sleep(20 * time.Millisecond) + mu.Lock() + overlapping-- + mu.Unlock() + unlock() + }() + } + wg.Wait() + if maxOverlapping != 1 { + t.Fatalf("overlapping holders=%d want 1", maxOverlapping) + } +} + +func TestLockDestinationCancelWhileWaiting(t *testing.T) { + dir := t.TempDir() + unlock, err := LockDestination(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + _, err = LockDestination(ctx, dir) + if err == nil { + t.Fatal("expected cancelled wait while dest is held") + } +} diff --git a/internal/installations/lock_unix.go b/internal/installations/lock_unix.go new file mode 100644 index 0000000..78adf9d --- /dev/null +++ b/internal/installations/lock_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package installations + +import ( + "os" + "syscall" +) + +func tryExclusiveLock(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +func unlockFile(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/installations/lock_windows.go b/internal/installations/lock_windows.go new file mode 100644 index 0000000..62e1a6e --- /dev/null +++ b/internal/installations/lock_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package installations + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modkernel32.NewProc("LockFileEx") + procUnlockFileEx = modkernel32.NewProc("UnlockFileEx") +) + +const ( + lockfileExclusiveLock = 0x0002 + lockfileFailImmediately = 0x0001 +) + +func tryExclusiveLock(f *os.File) error { + var ol syscall.Overlapped + r1, _, err := procLockFileEx.Call(f.Fd(), uintptr(lockfileExclusiveLock|lockfileFailImmediately), 0, 1, 0, uintptr(unsafe.Pointer(&ol))) + if r1 == 0 { + return err + } + return nil +} + +func unlockFile(f *os.File) error { + var ol syscall.Overlapped + r1, _, err := procUnlockFileEx.Call(f.Fd(), 0, 1, 0, uintptr(unsafe.Pointer(&ol))) + if r1 == 0 { + return err + } + return nil +} diff --git a/internal/installations/recovery.go b/internal/installations/recovery.go new file mode 100644 index 0000000..ee52d47 --- /dev/null +++ b/internal/installations/recovery.go @@ -0,0 +1,78 @@ +package installations + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "fontget/internal/config" + "fontget/internal/shared" +) + +const recoveryDirName = "recovery" + +// RecoveryRecord describes incomplete rollback so a human can restore fonts manually. +// It must not contain secrets or signed download URLs. +type RecoveryRecord struct { + OperationID string `json:"operation_id"` + PackageID string `json:"package_id"` + Scope string `json:"scope"` + AffectedPaths []string `json:"affected_paths"` + BackupPaths []string `json:"backup_paths"` + CompletedSteps []string `json:"completed_steps"` + OutstandingSteps []string `json:"outstanding_steps"` + Errors []string `json:"errors"` + CreatedAt time.Time `json:"created_at"` +} + +func recoveryDir() string { + return filepath.Join(config.GetAppConfigDir(), recoveryDirName) +} + +// SaveRecoveryRecord persists recovery information. Backups listed in the record must be left in place. +func SaveRecoveryRecord(rec RecoveryRecord) (string, error) { + if rec.OperationID == "" { + rec.OperationID = fmt.Sprintf("%d", time.Now().UTC().UnixNano()) + } + if rec.CreatedAt.IsZero() { + rec.CreatedAt = time.Now().UTC() + } + dir := recoveryDir() + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("%w: create recovery dir: %v", shared.ErrRecoveryRequired, err) + } + name := rec.OperationID + if rec.PackageID != "" { + name = rec.OperationID + "-" + sanitizeFilePart(rec.PackageID) + } + path := filepath.Join(dir, name+".json") + payload, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return "", fmt.Errorf("%w: marshal recovery: %v", shared.ErrRecoveryRequired, err) + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + return "", fmt.Errorf("%w: write recovery: %v", shared.ErrRecoveryRequired, err) + } + return path, nil +} + +func sanitizeFilePart(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + out = append(out, r) + default: + out = append(out, '_') + } + } + if len(out) > 80 { + out = out[:80] + } + if len(out) == 0 { + return "package" + } + return string(out) +} diff --git a/internal/installations/recovery_test.go b/internal/installations/recovery_test.go new file mode 100644 index 0000000..f1b66c0 --- /dev/null +++ b/internal/installations/recovery_test.go @@ -0,0 +1,40 @@ +package installations + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "fontget/internal/testutil" +) + +func TestSaveRecoveryRecord(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + + path, err := SaveRecoveryRecord(RecoveryRecord{ + OperationID: "op1", + PackageID: "google.roboto", + Scope: "user", + AffectedPaths: []string{"/fonts/Roboto.ttf"}, + BackupPaths: []string{"/fonts/Roboto.ttf.fontget-bak"}, + CompletedSteps: []string{"restore Roboto.ttf"}, + OutstandingSteps: []string{"re-register Roboto.ttf"}, + Errors: []string{"register failed"}, + }) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, "google.roboto") || !strings.Contains(body, "Roboto.ttf.fontget-bak") { + t.Fatalf("missing fields: %s", body) + } + if filepath.Dir(path) != recoveryDir() { + t.Fatalf("unexpected dir %s", path) + } +} diff --git a/internal/installations/registry.go b/internal/installations/registry.go index 1d28cdc..f2f3707 100644 --- a/internal/installations/registry.go +++ b/internal/installations/registry.go @@ -2,6 +2,7 @@ package installations import ( + "context" "encoding/json" "fmt" "os" @@ -141,9 +142,15 @@ func RegistryPath() string { // Load reads the registry from disk. Missing file yields an empty registry (no error). // Invalid JSON returns an error (caller should not overwrite without user intent). func Load() (*Registry, error) { - mu.Lock() - defer mu.Unlock() - return loadUnlocked() + var reg *Registry + err := withRegistryFileLock(context.Background(), func() error { + mu.Lock() + defer mu.Unlock() + var loadErr error + reg, loadErr = loadUnlocked() + return loadErr + }) + return reg, err } func loadUnlocked() (*Registry, error) { @@ -200,9 +207,11 @@ func Save(reg *Registry) error { if reg == nil { return fmt.Errorf("nil registry") } - mu.Lock() - defer mu.Unlock() - return saveUnlocked(reg) + return withRegistryFileLock(context.Background(), func() error { + mu.Lock() + defer mu.Unlock() + return saveUnlocked(reg) + }) } func saveUnlocked(reg *Registry) error { @@ -252,26 +261,28 @@ func RecordInstallation(p RecordParams) error { } key := strings.ToLower(strings.TrimSpace(p.FontID)) - mu.Lock() - defer mu.Unlock() + return withRegistryFileLock(context.Background(), func() error { + mu.Lock() + defer mu.Unlock() - reg, err := loadUnlocked() - if err != nil { - return err - } + reg, err := loadUnlocked() + if err != nil { + return err + } - flat := normalizeInstalledFiles(p.Files) - inst := &Installation{ - FontID: p.FontID, - CatalogName: strings.TrimSpace(p.CatalogName), - InstallationSource: strings.TrimSpace(p.InstallationSource), - Scope: strings.TrimSpace(p.Scope), - InstalledAt: time.Now().UTC(), - FontGetVersion: strings.TrimSpace(p.FontGetVersion), - Families: GroupInstalledFiles(flat), - } - reg.Installations[key] = inst - return saveUnlocked(reg) + flat := normalizeInstalledFiles(p.Files) + inst := &Installation{ + FontID: p.FontID, + CatalogName: strings.TrimSpace(p.CatalogName), + InstallationSource: strings.TrimSpace(p.InstallationSource), + Scope: strings.TrimSpace(p.Scope), + InstalledAt: time.Now().UTC(), + FontGetVersion: strings.TrimSpace(p.FontGetVersion), + Families: GroupInstalledFiles(flat), + } + reg.Installations[key] = inst + return saveUnlocked(reg) + }) } func normalizeInstalledFiles(in []InstalledFontFile) []InstalledFontFile { @@ -391,14 +402,16 @@ func RemoveInstallation(fontID string) error { if key == "" { return nil } - mu.Lock() - defer mu.Unlock() - reg, err := loadUnlocked() - if err != nil { - return err - } - delete(reg.Installations, key) - return saveUnlocked(reg) + return withRegistryFileLock(context.Background(), func() error { + mu.Lock() + defer mu.Unlock() + reg, err := loadUnlocked() + if err != nil { + return err + } + delete(reg.Installations, key) + return saveUnlocked(reg) + }) } // FindByFontID returns the installation for fontID or nil. diff --git a/internal/network/classify.go b/internal/network/classify.go new file mode 100644 index 0000000..ff0aeef --- /dev/null +++ b/internal/network/classify.go @@ -0,0 +1,238 @@ +package network + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + // maxErrorBodyDrain bounds how much of a failed HTTP body we discard before closing. + maxErrorBodyDrain = 64 << 10 + // defaultMaxRetryAfterWait is the allowed wait budget for honoring Retry-After. + defaultMaxRetryAfterWait = 60 * time.Second +) + +// Download classification sentinels. Callers should use errors.Is. +var ( + ErrCandidateUnavailable = errors.New("download candidate unavailable") + ErrRateLimited = errors.New("download rate limited") + ErrLocalFailure = errors.New("local filesystem failure") +) + +// MaxRetryAfterWait is the allowed wait budget for Retry-After. Tests may lower it. +var MaxRetryAfterWait = defaultMaxRetryAfterWait + +// DownloadAction is the centralized decision after a transport result. +type DownloadAction int + +const ( + // ActionSuccess means the payload is ready for completion/validation. + ActionSuccess DownloadAction = iota + // ActionAdvanceCandidate means this URL is gone; try the next candidate, no external tools. + ActionAdvanceCandidate + // ActionRetrySame means retry the same URL with the same transport (bounded). + ActionRetrySame + // ActionRateLimit means wait (if within budget) or fail as rate-limited; do not change tools. + ActionRateLimit + // ActionExternalFallback means a recognised challenge and external tools are allowed. + ActionExternalFallback + // ActionFailCandidate means a permanent candidate failure without blind same-URL tool retries. + ActionFailCandidate + // ActionFailPackage means stop the package (checksum, cancel, unassociated digest). + ActionFailPackage + // ActionFailLocal means a local I/O/permission error; switching transports cannot repair it. + ActionFailLocal +) + +// HTTPStatusError carries a classified HTTP failure without requiring string matching. +type HTTPStatusError struct { + StatusCode int + URL string + RetryAfter time.Duration +} + +func (e *HTTPStatusError) Error() string { + if e == nil { + return "http status error" + } + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, RedactDownloadURL(e.URL)) +} + +func (e *HTTPStatusError) Unwrap() error { + if e == nil { + return nil + } + switch e.StatusCode { + case http.StatusNotFound, http.StatusGone: + return ErrCandidateUnavailable + case http.StatusTooManyRequests: + return ErrRateLimited + default: + return nil + } +} + +// ClassifyHTTPStatus decides the next download action for a native or external HTTP status. +func ClassifyHTTPStatus(statusCode int, botChallenge bool) DownloadAction { + if botChallenge { + return ActionExternalFallback + } + switch statusCode { + case http.StatusOK: + return ActionSuccess + case http.StatusNotFound, http.StatusGone: + return ActionAdvanceCandidate + case http.StatusTooManyRequests: + return ActionRateLimit + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return ActionRetrySame + default: + if statusCode >= 400 && statusCode < 500 { + return ActionFailCandidate + } + if statusCode >= 500 { + return ActionRetrySame + } + return ActionFailCandidate + } +} + +// ClassifyDownloadError maps a transport error to an action when no HTTP status is available. +func ClassifyDownloadError(err error) DownloadAction { + if err == nil { + return ActionSuccess + } + if isCancelErr(err) { + return ActionFailPackage + } + if errors.Is(err, ErrCandidateUnavailable) { + return ActionAdvanceCandidate + } + if errors.Is(err, ErrRateLimited) { + return ActionRateLimit + } + if isLocalIOError(err) { + return ActionFailLocal + } + var httpErr *HTTPStatusError + if errors.As(err, &httpErr) && httpErr != nil { + return ClassifyHTTPStatus(httpErr.StatusCode, false) + } + return ActionExternalFallback +} + +func isCancelErr(err error) bool { + if err == nil { + return false + } + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +func isLocalIOError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrLocalFailure) { + return true + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return true + } + var linkErr *os.LinkError + if errors.As(err, &linkErr) { + return true + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "permission denied") || strings.Contains(msg, "access is denied") { + return true + } + if strings.Contains(msg, "no space left") || strings.Contains(msg, "disk full") { + return true + } + if strings.Contains(msg, "read-only file system") { + return true + } + return false +} + +// DrainAndCloseBody discards up to maxErrorBodyDrain bytes and closes the body. +func DrainAndCloseBody(body io.ReadCloser) { + if body == nil { + return + } + _, _ = io.Copy(io.Discard, io.LimitReader(body, maxErrorBodyDrain)) + _ = body.Close() +} + +// ParseRetryAfter returns the wait duration from a Retry-After header. +func ParseRetryAfter(h http.Header, now time.Time) (time.Duration, bool) { + if h == nil { + return 0, false + } + raw := strings.TrimSpace(h.Get("Retry-After")) + if raw == "" { + return 0, false + } + if secs, err := strconv.Atoi(raw); err == nil { + if secs < 0 { + return 0, false + } + return time.Duration(secs) * time.Second, true + } + if when, err := http.ParseTime(raw); err == nil { + d := when.Sub(now) + if d < 0 { + return 0, false + } + return d, true + } + return 0, false +} + +// RetryAfterWithinBudget reports whether wait is allowed. If wait exceeds the budget, callers +// must return a rate-limit error rather than retrying early. +func RetryAfterWithinBudget(wait time.Duration) bool { + if wait <= 0 { + return true + } + return wait <= MaxRetryAfterWait +} + +// RedactDownloadURL strips credentials and sensitive query values from user-facing messages. +func RedactDownloadURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" { + return raw + } + u.User = nil + q := u.Query() + if len(q) > 0 { + for key := range q { + lk := strings.ToLower(key) + switch lk { + case "token", "key", "signature", "sig", "access_token", "auth", "password", "secret": + q.Set(key, "REDACTED") + } + } + u.RawQuery = q.Encode() + } + return u.String() +} + +// NewHTTPStatusError builds a classified HTTP error with the original URL (redacted in Error()). +func NewHTTPStatusError(status int, rawURL string, retryAfter time.Duration) *HTTPStatusError { + return &HTTPStatusError{StatusCode: status, URL: rawURL, RetryAfter: retryAfter} +} diff --git a/internal/network/classify_test.go b/internal/network/classify_test.go new file mode 100644 index 0000000..ff8b5aa --- /dev/null +++ b/internal/network/classify_test.go @@ -0,0 +1,100 @@ +package network + +import ( + "context" + "errors" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func TestClassifyHTTPStatus(t *testing.T) { + cases := []struct { + code int + bot bool + want DownloadAction + }{ + {200, false, ActionSuccess}, + {404, false, ActionAdvanceCandidate}, + {410, false, ActionAdvanceCandidate}, + {429, false, ActionRateLimit}, + {403, false, ActionFailCandidate}, + {401, false, ActionFailCandidate}, + {500, false, ActionRetrySame}, + {502, false, ActionRetrySame}, + {202, true, ActionExternalFallback}, + {403, true, ActionExternalFallback}, + } + for _, tc := range cases { + if got := ClassifyHTTPStatus(tc.code, tc.bot); got != tc.want { + t.Errorf("ClassifyHTTPStatus(%d, bot=%v)=%v want %v", tc.code, tc.bot, got, tc.want) + } + } +} + +func TestHTTPStatusErrorUnwrap(t *testing.T) { + err404 := NewHTTPStatusError(404, "https://example.com/x?token=secret", 0) + if !errors.Is(err404, ErrCandidateUnavailable) { + t.Fatalf("404 must unwrap to ErrCandidateUnavailable") + } + msg := err404.Error() + if strings.Contains(msg, "secret") { + t.Fatalf("url credentials leaked in %q", msg) + } + + err429 := NewHTTPStatusError(429, "https://example.com/x", time.Second) + if !errors.Is(err429, ErrRateLimited) { + t.Fatalf("429 must unwrap to ErrRateLimited") + } +} + +func TestClassifyDownloadError(t *testing.T) { + if ClassifyDownloadError(context.Canceled) != ActionFailPackage { + t.Fatal("cancel must fail package") + } + if ClassifyDownloadError(&os.PathError{Op: "write", Path: "/tmp/x", Err: os.ErrPermission}) != ActionFailLocal { + t.Fatal("path error must fail local") + } + if ClassifyDownloadError(NewHTTPStatusError(404, "https://example.com/a", 0)) != ActionAdvanceCandidate { + t.Fatal("404 error must advance") + } +} + +func TestParseRetryAfter(t *testing.T) { + now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC) + h := http.Header{} + h.Set("Retry-After", "5") + d, ok := ParseRetryAfter(h, now) + if !ok || d != 5*time.Second { + t.Fatalf("delta seconds: got %v ok=%v", d, ok) + } + h.Set("Retry-After", now.Add(3*time.Second).Format(http.TimeFormat)) + d, ok = ParseRetryAfter(h, now) + if !ok || d < 2*time.Second || d > 4*time.Second { + t.Fatalf("http-date wait: got %v ok=%v", d, ok) + } +} + +func TestRetryAfterWithinBudget(t *testing.T) { + old := MaxRetryAfterWait + MaxRetryAfterWait = 2 * time.Second + t.Cleanup(func() { MaxRetryAfterWait = old }) + if RetryAfterWithinBudget(3 * time.Second) { + t.Fatal("over-budget wait must be rejected") + } + if !RetryAfterWithinBudget(time.Second) { + t.Fatal("in-budget wait must be allowed") + } +} + +func TestRedactDownloadURL(t *testing.T) { + got := RedactDownloadURL("https://user:pass@example.com/f.ttf?token=abc&x=1") + if strings.Contains(got, "pass") || strings.Contains(got, "abc") { + t.Fatalf("leaked secret: %q", got) + } + if !strings.Contains(got, "REDACTED") { + t.Fatalf("expected redacted query: %q", got) + } +} diff --git a/internal/network/download_fallbacks.go b/internal/network/download_fallbacks.go index 7e9e15e..b65507a 100644 --- a/internal/network/download_fallbacks.go +++ b/internal/network/download_fallbacks.go @@ -2,6 +2,7 @@ package network import ( "bytes" + "context" "errors" "fmt" "math/rand" @@ -30,6 +31,26 @@ func (execRunner) CombinedOutput(name string, args ...string) ([]byte, error) { type DownloadFallbackOptions struct { UserAgent string Headers map[string]string + // Context cancels tool execution, host waits and retry backoff. Nil uses Background. + Context context.Context + // MaxAttempts bounds per-tool HTTP retries. Zero means 3. Use 1 when native retries already ran. + MaxAttempts int + // Exec carries inactivity, terminate-wait and output caps. Zero values use documented defaults. + Exec ExecOptions +} + +func (opts DownloadFallbackOptions) ctx() context.Context { + if opts.Context != nil { + return opts.Context + } + return context.Background() +} + +func (opts DownloadFallbackOptions) maxAttempts() int { + if opts.MaxAttempts > 0 { + return opts.MaxAttempts + } + return 3 } func isZipMagic(b []byte) bool { @@ -71,6 +92,7 @@ type FallbackAttemptError struct { URL string Report *DownloadFallbackReport attempt []string + cause error } func (e *FallbackAttemptError) Error() string { @@ -78,14 +100,21 @@ func (e *FallbackAttemptError) Error() string { return "download fallback failed" } if len(e.attempt) > 0 { - return fmt.Sprintf("download fallback failed for %s (%s)", e.URL, strings.Join(e.attempt, "; ")) + return fmt.Sprintf("download fallback failed for %s (%s)", RedactDownloadURL(e.URL), strings.Join(e.attempt, "; ")) } - return fmt.Sprintf("download fallback failed for %s", e.URL) + return fmt.Sprintf("download fallback failed for %s", RedactDownloadURL(e.URL)) +} + +func (e *FallbackAttemptError) Unwrap() error { + if e == nil { + return nil + } + return e.cause } // DownloadWithFallbacks attempts to download the URL to targetPath using optional external tools. // It is capability-first: tools are only attempted if found, but a found tool that fails will not -// stop the chain. On success, the returned report includes all steps (skipped, failed, and ok). +// stop the chain unless the failure is a terminal HTTP status (404/410/other permanent 4xx). func DownloadWithFallbacks(url, targetPath string, opts DownloadFallbackOptions) (*DownloadFallbackReport, error) { return downloadWithFallbacks(execRunner{}, url, targetPath, opts) } @@ -93,6 +122,8 @@ func DownloadWithFallbacks(url, targetPath string, opts DownloadFallbackOptions) func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts DownloadFallbackOptions) (*DownloadFallbackReport, error) { rep := &DownloadFallbackReport{} var compact []string + ctx := opts.ctx() + opts.Exec.ProgressPath = targetPath appendFailed := func(tool, bin, msg string) { compact = append(compact, tool+": "+msg) @@ -109,10 +140,9 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do if fi.Size() <= 0 { return fmt.Errorf("output file is empty") } - // Cheap WAF/HTML detection: many challenges return an HTML page. f, err := os.Open(targetPath) if err != nil { - return nil // can't inspect; keep it permissive + return nil } defer f.Close() var buf [512]byte @@ -121,8 +151,6 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do if bytes.HasPrefix(b, []byte("= 500 && code <= 599) - if code == 403 { - return finalStatus, fmt.Errorf("unexpected HTTP status %s", finalStatus) - } - if !shouldRetry || attempt == maxAttempts { - return finalStatus, fmt.Errorf("unexpected HTTP status %s", finalStatus) + httpErr := NewHTTPStatusError(code, url, 0) + action := ClassifyHTTPStatus(code, false) + switch action { + case ActionSuccess: + return finalStatus, nil + case ActionAdvanceCandidate, ActionFailCandidate, ActionFailPackage, ActionFailLocal, ActionRateLimit: + return finalStatus, httpErr + case ActionRetrySame: + lastErr = httpErr + if attempt == maxAttempts { + return finalStatus, httpErr + } + if err := sleepCtx(ctx, backoff+time.Duration(rng.Intn(200))*time.Millisecond); err != nil { + return finalStatus, err + } + backoff *= 2 + default: + return finalStatus, httpErr } - - // jitter in [0, 200ms] - j := time.Duration(rng.Intn(200)) * time.Millisecond - time.Sleep(backoff + j) - backoff *= 2 } + if lastErr != nil { + return finalStatus, lastErr + } return finalStatus, fmt.Errorf("unexpected HTTP status %s", finalStatus) } -func runWget(runner CommandRunner, wgetPath, url, targetPath string, opts DownloadFallbackOptions) error { +func matchHTTPStatus(re *regexp.Regexp, out []byte) string { + m := re.FindStringSubmatch(string(out)) + if len(m) == 2 { + return m[1] + } + return "" +} + +func parseHTTPStatus(s string) (int, bool) { + if s == "" { + return 0, false + } + code, err := strconv.Atoi(s) + if err != nil || code < 100 || code > 599 { + return 0, false + } + return code, true +} + +func runWget(ctx context.Context, runner CommandRunner, wgetPath, url, targetPath string, opts DownloadFallbackOptions) error { args := []string{ "-q", "-O", targetPath, @@ -279,14 +413,17 @@ func runWget(runner CommandRunner, wgetPath, url, targetPath string, opts Downlo } args = append(args, url) - out, err := runner.CombinedOutput(wgetPath, args...) + out, err := runTool(ctx, runner, opts, wgetPath, args...) if err != nil { + if isCancelErr(err) { + return err + } return fmt.Errorf("%s", normalizeToolError(out, err)) } return nil } -func runPowerShell(runner CommandRunner, psPath, url, targetPath string, opts DownloadFallbackOptions) error { +func runPowerShell(ctx context.Context, runner CommandRunner, psPath, url, targetPath string, opts DownloadFallbackOptions) error { ua := opts.UserAgent if ua == "" { ua = "Mozilla/5.0" @@ -311,13 +448,30 @@ func runPowerShell(runner CommandRunner, psPath, url, targetPath string, opts Do }, "; ") args := []string{"-NoProfile", "-NonInteractive", "-Command", script} - out, err := runner.CombinedOutput(psPath, args...) + out, err := runTool(ctx, runner, opts, psPath, args...) if err != nil { + if isCancelErr(err) { + return err + } return fmt.Errorf("%s", normalizeToolError(out, err)) } return nil } +func sleepCtx(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + func normalizeToolError(out []byte, err error) string { msg := strings.TrimSpace(string(bytes.TrimSpace(out))) if msg != "" { diff --git a/internal/network/download_fallbacks_test.go b/internal/network/download_fallbacks_test.go index a861fe8..dc0cc69 100644 --- a/internal/network/download_fallbacks_test.go +++ b/internal/network/download_fallbacks_test.go @@ -130,3 +130,51 @@ func TestDownloadWithFallbacks_CurlFails_WgetSucceeds(t *testing.T) { t.Fatalf("unexpected call order: %#v", r.calls) } } + +func TestDownloadWithFallbacks_Curl404DoesNotCallWget(t *testing.T) { + r := &fakeRunner{ + paths: map[string]string{ + "curl": "/usr/bin/curl", + "wget": "/usr/bin/wget", + }, + results: map[string]fakeResult{ + "/usr/bin/curl": {out: "FONTGET_HTTP_STATUS=404", err: errors.New("exit 22")}, + "/usr/bin/wget": {out: "", err: nil}, + }, + } + out := filepath.Join(t.TempDir(), "file.zip") + _, err := downloadWithFallbacks(r, "https://example.com/missing.zip", out, DownloadFallbackOptions{UserAgent: "ua"}) + if err == nil { + t.Fatal("expected 404 error") + } + if !errors.Is(err, ErrCandidateUnavailable) { + t.Fatalf("want ErrCandidateUnavailable, got %v", err) + } + if len(r.calls) != 1 || !strings.HasPrefix(r.calls[0], "/usr/bin/curl ") { + t.Fatalf("404 must not continue to wget, calls=%#v", r.calls) + } +} + +func TestDownloadWithFallbacks_Curl429DoesNotCallWget(t *testing.T) { + r := &fakeRunner{ + paths: map[string]string{ + "curl": "/usr/bin/curl", + "wget": "/usr/bin/wget", + }, + results: map[string]fakeResult{ + "/usr/bin/curl": {out: "FONTGET_HTTP_STATUS=429", err: errors.New("exit 22")}, + "/usr/bin/wget": {out: "", err: nil}, + }, + } + out := filepath.Join(t.TempDir(), "file.zip") + _, err := downloadWithFallbacks(r, "https://example.com/rate.zip", out, DownloadFallbackOptions{UserAgent: "ua"}) + if err == nil { + t.Fatal("expected 429 error") + } + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("want ErrRateLimited, got %v", err) + } + if len(r.calls) != 1 { + t.Fatalf("429 must not change tools, calls=%#v", r.calls) + } +} diff --git a/internal/network/download_http_client.go b/internal/network/download_http_client.go index 8433af6..eb25f46 100644 --- a/internal/network/download_http_client.go +++ b/internal/network/download_http_client.go @@ -83,9 +83,7 @@ func ShouldRetryGoDownloadStatus(code int) bool { switch code { case http.StatusTooManyRequests: // 429 return true - case http.StatusBadGateway: // 502 - return true - case http.StatusServiceUnavailable: // 503 + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: return true default: return false diff --git a/internal/network/exec.go b/internal/network/exec.go new file mode 100644 index 0000000..e389073 --- /dev/null +++ b/internal/network/exec.go @@ -0,0 +1,195 @@ +package network + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" +) + +const ( + // DefaultExternalInactivityTimeout is the stall bound when the target file does not grow. + // Measured from process start until first byte progress, then reset by further size growth. + // Log chatter is not progress. Tests may override via ExecOptions.InactivityTimeout. + DefaultExternalInactivityTimeout = 120 * time.Second + // DefaultExternalTerminateWait is how long we wait after cancellation before forced kill. + DefaultExternalTerminateWait = 5 * time.Second + // DefaultMaxCapturedOutput bounds retained stdout/stderr from an external downloader. + DefaultMaxCapturedOutput = 256 << 10 +) + +// ExecOptions controls cancellable external process execution. +type ExecOptions struct { + InactivityTimeout time.Duration + TerminateWait time.Duration + MaxOutputBytes int + // ProgressPath, when set, is stat'd to detect downloaded-byte progress (file size growth). + ProgressPath string +} + +func (o ExecOptions) inactivity() time.Duration { + if o.InactivityTimeout > 0 { + return o.InactivityTimeout + } + return DefaultExternalInactivityTimeout +} + +func (o ExecOptions) terminateWait() time.Duration { + if o.TerminateWait > 0 { + return o.TerminateWait + } + return DefaultExternalTerminateWait +} + +func (o ExecOptions) maxOutput() int { + if o.MaxOutputBytes > 0 { + return o.MaxOutputBytes + } + return DefaultMaxCapturedOutput +} + +// ContextCommandRunner is an optional CommandRunner extension used by production execution. +type ContextCommandRunner interface { + CommandRunner + CombinedOutputContext(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) +} + +func (execRunner) CombinedOutputContext(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) { + return RunCancellable(ctx, opts, name, args...) +} + +// RunCancellable starts name with args (no shell), waits until completion, cancellation, or stall. +// On cancel it signals the process group/job, waits TerminateWait, then force-kills descendants. +func RunCancellable(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, name, args...) + prepareProcessGroup(cmd) + + var out limitedBuffer + out.max = opts.maxOutput() + cmd.Stdout = &out + cmd.Stderr = &out + + if err := cmd.Start(); err != nil { + return out.Bytes(), err + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + stallCtx, stallCancel := context.WithCancel(ctx) + defer stallCancel() + if opts.ProgressPath != "" { + go watchDownloadProgress(stallCtx, stallCancel, opts.ProgressPath, opts.inactivity()) + } else { + go func() { + timer := time.NewTimer(opts.inactivity()) + defer timer.Stop() + select { + case <-stallCtx.Done(): + case <-timer.C: + stallCancel() + } + }() + } + + select { + case err := <-done: + return out.Bytes(), err + case <-stallCtx.Done(): + cause := stallCtx.Err() + if errors.Is(ctx.Err(), context.Canceled) { + cause = ctx.Err() + } else if ctx.Err() == nil { + cause = fmt.Errorf("%w: no download progress for %s", context.DeadlineExceeded, opts.inactivity()) + } + _ = terminateProcessTree(cmd, opts.terminateWait()) + select { + case <-done: + case <-time.After(opts.terminateWait()): + _ = killProcessTree(cmd) + <-done + } + return out.Bytes(), cause + } +} + +func watchDownloadProgress(ctx context.Context, cancel context.CancelFunc, path string, inactivity time.Duration) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + lastSize := int64(-1) + lastGrowth := time.Now() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + fi, err := os.Stat(path) + var size int64 + if err == nil { + size = fi.Size() + } + if size > lastSize { + lastSize = size + lastGrowth = time.Now() + continue + } + if time.Since(lastGrowth) >= inactivity { + cancel() + return + } + } + } +} + +type limitedBuffer struct { + max int + mu sync.Mutex + buf bytes.Buffer +} + +func (l *limitedBuffer) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + remain := l.max - l.buf.Len() + if remain <= 0 { + return len(p), nil + } + if len(p) > remain { + _, _ = l.buf.Write(p[:remain]) + return len(p), nil + } + return l.buf.Write(p) +} + +func (l *limitedBuffer) Bytes() []byte { + l.mu.Lock() + defer l.mu.Unlock() + return append([]byte(nil), l.buf.Bytes()...) +} + +func closePipes(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + if wc, ok := cmd.Stdout.(io.Closer); ok { + _ = wc.Close() + } + if wc, ok := cmd.Stderr.(io.Closer); ok { + _ = wc.Close() + } + if wc, ok := cmd.Stdin.(io.Closer); ok { + _ = wc.Close() + } +} diff --git a/internal/network/exec_test.go b/internal/network/exec_test.go new file mode 100644 index 0000000..1365e73 --- /dev/null +++ b/internal/network/exec_test.go @@ -0,0 +1,116 @@ +package network + +import ( + "context" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestRunCancellable_NoOutputHang(t *testing.T) { + name, args := longSleepArgs() + if _, err := exec.LookPath(name); err != nil { + t.Skip(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + _, err := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: 500 * time.Millisecond}, name, args...) + if err == nil { + t.Fatal("expected cancel or stall") + } +} + +func TestRunCancellable_InactivityStall(t *testing.T) { + name, args := longSleepArgs() + if _, err := exec.LookPath(name); err != nil { + t.Skip(err) + } + dir := t.TempDir() + progress := filepath.Join(dir, "out.bin") + _, err := RunCancellable(context.Background(), ExecOptions{ + InactivityTimeout: 300 * time.Millisecond, + TerminateWait: 500 * time.Millisecond, + ProgressPath: progress, + }, name, args...) + if err == nil { + t.Fatal("expected stall") + } +} + +func TestRunCancellable_KillsDescendants(t *testing.T) { + name, args := descendantSleepArgs() + if _, err := exec.LookPath(name); err != nil { + t.Skip(err) + } + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: 500 * time.Millisecond}, name, args...) + errCh <- err + }() + time.Sleep(200 * time.Millisecond) + cancel() + select { + case err := <-errCh: + if err == nil { + t.Fatal("expected cancellation error") + } + case <-time.After(8 * time.Second): + t.Fatal("descendant process tree did not stop after cancel") + } +} + +func descendantSleepArgs() (string, []string) { + if runtime.GOOS == "windows" { + return "cmd", []string{"/c", "timeout", "/t", "30", "/nobreak"} + } + return "sh", []string{"-c", "sleep 30"} +} + +func longSleepArgs() (string, []string) { + if runtime.GOOS == "windows" { + return "timeout", []string{"/t", "30", "/nobreak"} + } + return "sleep", []string{"30"} +} + +func TestLimitedBufferBoundsOutput(t *testing.T) { + var buf limitedBuffer + buf.max = 8 + n, err := buf.Write([]byte("abcdefghijklmnop")) + if err != nil || n != 16 { + t.Fatalf("write n=%d err=%v", n, err) + } + if len(buf.Bytes()) != 8 { + t.Fatalf("captured %d", len(buf.Bytes())) + } +} + +func TestRunCancellable_ReleasedOnCancel(t *testing.T) { + name, args := longSleepArgs() + if _, err := exec.LookPath(name); err != nil { + t.Skip(err) + } + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: 500 * time.Millisecond}, name, args...) + errCh <- err + }() + time.Sleep(150 * time.Millisecond) + cancel() + select { + case err := <-errCh: + if err == nil { + t.Fatal("expected cancellation error") + } + case <-time.After(5 * time.Second): + t.Fatal("process did not stop after cancel") + } +} diff --git a/internal/network/exec_unix.go b/internal/network/exec_unix.go new file mode 100644 index 0000000..603f2b9 --- /dev/null +++ b/internal/network/exec_unix.go @@ -0,0 +1,37 @@ +//go:build unix + +package network + +import ( + "os/exec" + "syscall" + "time" +) + +func prepareProcessGroup(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func terminateProcessTree(cmd *exec.Cmd, wait time.Duration) error { + _ = wait + if cmd == nil || cmd.Process == nil { + return nil + } + pgid := cmd.Process.Pid + _ = syscall.Kill(-pgid, syscall.SIGTERM) + closePipes(cmd) + return nil +} + +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + pgid := cmd.Process.Pid + _ = syscall.Kill(-pgid, syscall.SIGKILL) + closePipes(cmd) + return nil +} diff --git a/internal/network/exec_windows.go b/internal/network/exec_windows.go new file mode 100644 index 0000000..1631ff8 --- /dev/null +++ b/internal/network/exec_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +package network + +import ( + "os/exec" + "strconv" + "syscall" + "time" + + "golang.org/x/sys/windows" +) + +func prepareProcessGroup(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP +} + +func terminateProcessTree(cmd *exec.Cmd, wait time.Duration) error { + if cmd == nil || cmd.Process == nil { + return nil + } + pid := strconv.Itoa(cmd.Process.Pid) + _ = exec.Command("taskkill", "/T", "/PID", pid).Run() + closePipes(cmd) + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return killProcessTree(cmd) +} + +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + pid := strconv.Itoa(cmd.Process.Pid) + err := exec.Command("taskkill", "/F", "/T", "/PID", pid).Run() + closePipes(cmd) + return err +} diff --git a/internal/platform/darwin.go b/internal/platform/darwin.go index 20779e2..c59a8f4 100644 --- a/internal/platform/darwin.go +++ b/internal/platform/darwin.go @@ -78,31 +78,20 @@ func (m *darwinFontManager) InstallFont(fontPath string, scope InstallationScope targetPath := filepath.Join(targetDir, fontName) - // Check if font is already installed - if _, err := os.Stat(targetPath); err == nil { - if !force { - return fmt.Errorf("font already installed: %s", fontName) - } - // Remove the existing file if force is true - if err := os.Remove(targetPath); err != nil { - return fmt.Errorf("failed to overwrite existing font: %w", err) - } + mut, err := placeFontFile(fontPath, targetPath, force, opts) + if err != nil { + return err } - - // Copy the font file to the target directory - if err := copyFile(fontPath, targetPath); err != nil { - return fmt.Errorf("failed to copy font file: %w", err) + if opts != nil && opts.FailPoint == InstallFailRegister { + _ = RollbackMutation(mut) + return failPointError(InstallFailRegister) } skipCache := opts != nil && opts.SkipPostInstallCacheRefresh if !skipCache { - // Update the font cache (non-critical on macOS 14+) - // Fonts in ~/Library/Fonts and /Library/Fonts are auto-detected by macOS + // Cache refresh failure is non-critical - font is already installed. + // Do not roll back committed files for a best-effort discovery-cache refresh. if err := m.updateFontCache(scope); err != nil { - // Cache refresh failure is non-critical - font is already installed - // On macOS 14+, fonts are auto-detected without manual cache refresh - // Don't remove the file - installation succeeded, cache refresh is optional - // Return a warning-style error that can be handled gracefully return fmt.Errorf("font installed successfully, but cache refresh failed (non-critical): %w", err) } } @@ -165,7 +154,7 @@ func (m *darwinFontManager) RequiresElevation(scope InstallationScope) bool { // updateFontCache refreshes the font cache on macOS // Uses modern method compatible with macOS 14+ (Sonoma) // On macOS 14+, atsutil was removed, so we use pkill fontd instead -func (m *darwinFontManager) updateFontCache(scope InstallationScope) error { +func (m *darwinFontManager) updateFontCache(_ InstallationScope) error { // Modern approach: restart fontd service to refresh cache // fontd automatically restarts and picks up new fonts // This works on both older macOS versions and macOS 14+ diff --git a/internal/platform/linux.go b/internal/platform/linux.go index 69be92a..c420503 100644 --- a/internal/platform/linux.go +++ b/internal/platform/linux.go @@ -77,28 +77,19 @@ func (m *linuxFontManager) InstallFont(fontPath string, scope InstallationScope, targetPath := filepath.Join(targetDir, fontName) - // Check if font is already installed - if _, err := os.Stat(targetPath); err == nil { - if !force { - return fmt.Errorf("font already installed: %s", fontName) - } - // Remove the existing file if force is true - if err := os.Remove(targetPath); err != nil { - return fmt.Errorf("failed to overwrite existing font: %w", err) - } + mut, err := placeFontFile(fontPath, targetPath, force, opts) + if err != nil { + return err } - - // Copy the font file to the target directory - if err := copyFile(fontPath, targetPath); err != nil { - return fmt.Errorf("failed to copy font file: %w", err) + if opts != nil && opts.FailPoint == InstallFailRegister { + _ = RollbackMutation(mut) + return failPointError(InstallFailRegister) } skipCache := opts != nil && opts.SkipPostInstallCacheRefresh if !skipCache { - // Update the font cache if err := m.updateFontCache(scope); err != nil { - // Clean up the file if cache update fails - os.Remove(targetPath) + _ = RollbackMutation(mut) return fmt.Errorf("failed to update font cache: %w", err) } } diff --git a/internal/platform/mutation.go b/internal/platform/mutation.go new file mode 100644 index 0000000..5372c73 --- /dev/null +++ b/internal/platform/mutation.go @@ -0,0 +1,120 @@ +package platform + +import ( + "fmt" + "os" + "path/filepath" +) + +func PlaceFontFile(src, dest string, force bool, opts *InstallFontOptions) (FileMutation, error) { + return placeFontFile(src, dest, force, opts) +} + +func failPointError(point InstallFailPoint) error { + return fmt.Errorf("injected failure at %s", point) +} + +// placeFontFile copies src to dest, using a same-volume backup and staged file when replacing. +// It never uses an unconditional Remove(old) followed by an unprotected copy as the only strategy. +func placeFontFile(src, dest string, force bool, opts *InstallFontOptions) (FileMutation, error) { + var mut FileMutation + mut.DestPath = dest + + if _, err := os.Stat(dest); err == nil { + if !force { + return mut, fmt.Errorf("font already installed: %s", filepath.Base(dest)) + } + if err := replaceExistingFontFile(src, dest, &mut, opts); err != nil { + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut + } + return mut, err + } + } else if !os.IsNotExist(err) { + return mut, fmt.Errorf("stat destination: %w", err) + } else { + if opts != nil && opts.FailPoint == InstallFailCopy { + return mut, failPointError(InstallFailCopy) + } + if err := copyFile(src, dest); err != nil { + return mut, fmt.Errorf("copy font file: %w", err) + } + mut.Created = true + } + + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut + } + return mut, nil +} + +func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { + backup := dest + ".fontget-bak" + if opts != nil && opts.FailPoint == InstallFailBackup { + return failPointError(InstallFailBackup) + } + if err := copyFile(dest, backup); err != nil { + return fmt.Errorf("backup existing font: %w", err) + } + mut.BackupPath = backup + mut.Replaced = true + + if opts != nil && opts.FailPoint == InstallFailCopy { + return failPointError(InstallFailCopy) + } + + staged := dest + ".fontget-new" + if err := copyFile(src, staged); err != nil { + _ = os.Remove(backup) + mut.BackupPath = "" + mut.Replaced = false + return fmt.Errorf("stage replacement: %w", err) + } + + if opts != nil && opts.FailPoint == InstallFailReplace { + _ = os.Remove(staged) + return failPointError(InstallFailReplace) + } + + oldMoved := dest + ".fontget-old" + _ = os.Remove(oldMoved) + if err := os.Rename(dest, oldMoved); err != nil { + _ = os.Remove(staged) + return fmt.Errorf("cannot replace locked or busy font: %w", err) + } + if err := os.Rename(staged, dest); err != nil { + _ = os.Rename(oldMoved, dest) + _ = os.Remove(staged) + return fmt.Errorf("install replacement: %w", err) + } + _ = os.Remove(oldMoved) + return nil +} + +// CommitMutation deletes disposable backups after a successful package commit. +func CommitMutation(mut FileMutation) error { + if mut.BackupPath == "" { + return nil + } + if err := os.Remove(mut.BackupPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// RollbackMutation undoes a single file mutation. Pre-existing files are restored from backup; +// files created by this operation are removed. Untouched fonts are not deleted. +func RollbackMutation(mut FileMutation) error { + if mut.Replaced && mut.BackupPath != "" { + if err := copyFile(mut.BackupPath, mut.DestPath); err != nil { + return fmt.Errorf("restore backup %s: %w", mut.BackupPath, err) + } + return nil + } + if mut.Created && mut.DestPath != "" { + if err := os.Remove(mut.DestPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove created file %s: %w", mut.DestPath, err) + } + } + return nil +} diff --git a/internal/platform/mutation_test.go b/internal/platform/mutation_test.go new file mode 100644 index 0000000..3714c91 --- /dev/null +++ b/internal/platform/mutation_test.go @@ -0,0 +1,113 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPlaceFontFileCreates(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + if err := os.WriteFile(src, []byte("new-font-bytes"), 0644); err != nil { + t.Fatal(err) + } + var mut FileMutation + if _, err := placeFontFile(src, dst, false, &InstallFontOptions{Mutation: &mut}); err != nil { + t.Fatal(err) + } + if !mut.Created || mut.Replaced { + t.Fatalf("mutation = %+v", mut) + } + got, _ := os.ReadFile(dst) + if string(got) != "new-font-bytes" { + t.Fatalf("dest = %q", got) + } +} + +func TestPlaceFontFileForceRestoresOnReplaceFail(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + if err := os.WriteFile(src, []byte("new-bytes-here"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("old-bytes-here"), 0644); err != nil { + t.Fatal(err) + } + var mut FileMutation + err := error(nil) + _, err = placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut, FailPoint: InstallFailReplace}) + if err == nil { + t.Fatal("expected injected replace failure") + } + got, _ := os.ReadFile(dst) + if string(got) != "old-bytes-here" { + t.Fatalf("original bytes lost: %q", got) + } + if mut.BackupPath == "" { + t.Fatal("backup must exist so recovery remains possible") + } + bak, _ := os.ReadFile(mut.BackupPath) + if string(bak) != "old-bytes-here" { + t.Fatalf("backup = %q", bak) + } +} + +func TestRollbackAndCommitMutation(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + if err := os.WriteFile(src, []byte("new-bytes-here"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("old-bytes-here"), 0644); err != nil { + t.Fatal(err) + } + mut, err := placeFontFile(src, dst, true, nil) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dst) + if string(got) != "new-bytes-here" { + t.Fatalf("replaced = %q", got) + } + if err := RollbackMutation(mut); err != nil { + t.Fatal(err) + } + got, _ = os.ReadFile(dst) + if string(got) != "old-bytes-here" { + t.Fatalf("restored = %q", got) + } + if err := os.WriteFile(dst, []byte("new-bytes-here"), 0644); err != nil { + t.Fatal(err) + } + mut2, err := placeFontFile(src, dst, true, nil) + if err != nil { + t.Fatal(err) + } + if err := CommitMutation(mut2); err != nil { + t.Fatal(err) + } + if mut2.BackupPath != "" { + if _, err := os.Stat(mut2.BackupPath); !os.IsNotExist(err) { + t.Fatalf("backup should be removed after commit: %v", err) + } + } +} + +func TestPlaceFontFileSkipWithoutForce(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + _ = os.WriteFile(src, []byte("new"), 0644) + _ = os.WriteFile(dst, []byte("old"), 0644) + if _, err := placeFontFile(src, dst, false, nil); err == nil { + t.Fatal("expected already installed") + } + got, _ := os.ReadFile(dst) + if string(got) != "old" { + t.Fatalf("must not touch dest: %q", got) + } +} diff --git a/internal/platform/name_table_test.go b/internal/platform/name_table_test.go index 12b6518..09d8bb7 100644 --- a/internal/platform/name_table_test.go +++ b/internal/platform/name_table_test.go @@ -156,11 +156,10 @@ func BenchmarkParseNameTable_Large(b *testing.B) { nameTable := buildNameTable(records) b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { md, err := parseNameTable(nameTable) if err != nil || md.FamilyName == "" || md.StyleName == "" { b.Fatalf("unexpected failure: md=%+v err=%v", md, err) } } } - diff --git a/internal/platform/platform.go b/internal/platform/platform.go index b328650..1f1f162 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -27,11 +27,34 @@ const ( MachineScope InstallationScope = "machine" ) +// InstallFailPoint is a test-only hook that stops an install after the named step. +type InstallFailPoint string + +const ( + InstallFailNone InstallFailPoint = "" + InstallFailBackup InstallFailPoint = "backup" + InstallFailCopy InstallFailPoint = "copy" + InstallFailReplace InstallFailPoint = "replace" + InstallFailRegister InstallFailPoint = "register" +) + +// FileMutation records destination changes so a package can roll them back or commit backups. +type FileMutation struct { + DestPath string + BackupPath string + Created bool + Replaced bool +} + // InstallFontOptions configures InstallFont. A nil opts value keeps legacy behavior (run post-install cache/notify after each InstallFont). type InstallFontOptions struct { // SkipPostInstallCacheRefresh skips the per-install OS font cache update / Windows WM_FONTCHANGE notification. // Use with FlushFontCache(scope) once after installing multiple files in one batch. SkipPostInstallCacheRefresh bool + // Mutation, when non-nil, is filled with the destination change performed by this call. + Mutation *FileMutation + // FailPoint is a test hook. Production code must leave it empty. + FailPoint InstallFailPoint } // RemoveFontOptions configures RemoveFont. A nil opts value keeps legacy behavior (run post-remove cache/notify after each RemoveFont). diff --git a/internal/platform/temp.go b/internal/platform/temp.go index 00d81f2..721830d 100644 --- a/internal/platform/temp.go +++ b/internal/platform/temp.go @@ -4,24 +4,26 @@ import ( "fmt" "os" "path/filepath" + "strings" ) const ( - // TempDirName is the name of our temporary directory + // TempDirName is the name of our temporary directory (shared container only). TempDirName = "Fontget" - // TempFontsDir is the name of the fonts subdirectory - TempFontsDir = "fonts" + // TempFontsDir is the name of the fonts subdirectory (legacy shared staging). + TempFontsDir = "fonts" + operationDirPrefix = "op-" ) -// GetTempDir returns the platform-specific temp directory path for Fontget +// GetTempDir returns the platform-specific temp directory path for Fontget. +// This is a shared container for per-operation directories. Callers must not +// treat it as exclusive working space or delete it during normal cleanup. func GetTempDir() (string, error) { - // Get the system's temp directory tempDir := os.TempDir() if tempDir == "" { return "", fmt.Errorf("failed to get system temp directory") } - // Create our temp directory path fontgetTempDir := filepath.Join(tempDir, TempDirName) if err := os.MkdirAll(fontgetTempDir, 0755); err != nil { return "", fmt.Errorf("failed to create temp directory: %w", err) @@ -30,15 +32,14 @@ func GetTempDir() (string, error) { return fontgetTempDir, nil } -// GetTempFontsDir returns the path to the temporary fonts directory +// GetTempFontsDir returns the path to the legacy shared temporary fonts directory. +// New installation code should use NewOperationStaging instead. func GetTempFontsDir() (string, error) { - // Get the base temp directory tempDir, err := GetTempDir() if err != nil { return "", err } - // Create the fonts subdirectory fontsDir := filepath.Join(tempDir, TempFontsDir) if err := os.MkdirAll(fontsDir, 0755); err != nil { return "", fmt.Errorf("failed to create fonts directory: %w", err) @@ -47,32 +48,141 @@ func GetTempFontsDir() (string, error) { return fontsDir, nil } -// CleanupTempDir removes all files from the temp directory +// CleanupTempDir removes the shared Fontget temp container if it is empty. +// It never deletes unrelated sibling directories. Prefer OperationStaging.Cleanup. func CleanupTempDir() error { tempDir, err := GetTempDir() if err != nil { return err } - - // Remove the entire temp directory and its contents - if err := os.RemoveAll(tempDir); err != nil { + if err := os.Remove(tempDir); err != nil && !os.IsNotExist(err) { + // Non-empty container is expected while other operations are running. + if isNotEmptyDirErr(err) { + return nil + } return fmt.Errorf("failed to cleanup temp directory: %w", err) } - return nil } -// CleanupTempFontsDir removes all files from the temp fonts directory +// CleanupTempFontsDir removes the legacy shared fonts subdirectory if empty. func CleanupTempFontsDir() error { fontsDir, err := GetTempFontsDir() if err != nil { return err } - - // Remove the fonts directory and its contents - if err := os.RemoveAll(fontsDir); err != nil { + if err := os.Remove(fontsDir); err != nil && !os.IsNotExist(err) { + if isNotEmptyDirErr(err) { + return nil + } return fmt.Errorf("failed to cleanup fonts directory: %w", err) } + return nil +} + +func isNotEmptyDirErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "directory not empty") || strings.Contains(msg, "not empty") +} + +// OperationStaging owns a unique temporary directory for one add/install command. +// Cleanup removes only this directory, never the shared Fontget root or siblings. +type OperationStaging struct { + Root string +} + +// NewOperationStaging allocates a unique operation directory under the shared Fontget temp root. +func NewOperationStaging() (*OperationStaging, error) { + base, err := GetTempDir() + if err != nil { + return nil, err + } + dir, err := os.MkdirTemp(base, operationDirPrefix) + if err != nil { + return nil, fmt.Errorf("failed to create operation temp directory: %w", err) + } + return &OperationStaging{Root: dir}, nil +} + +// PackageDir returns a dedicated directory for one catalogue package (font ID). +func (s *OperationStaging) PackageDir(packageID string) (string, error) { + if s == nil || s.Root == "" { + return "", fmt.Errorf("nil operation staging") + } + name := sanitizeStagingName(packageID) + if name == "" { + name = "package" + } + dir := filepath.Join(s.Root, "pkg-"+name) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create package staging directory: %w", err) + } + return dir, nil +} + +// VariantDir returns a dedicated directory for one variant inside a package. +func (s *OperationStaging) VariantDir(packageID, variant string) (string, error) { + pkg, err := s.PackageDir(packageID) + if err != nil { + return "", err + } + name := sanitizeStagingName(variant) + if name == "" { + name = "variant" + } + dir := filepath.Join(pkg, "var-"+name) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create variant staging directory: %w", err) + } + return dir, nil +} +// AttemptDir allocates a unique directory for one download/extract candidate attempt. +func (s *OperationStaging) AttemptDir(packageID, variant string) (string, error) { + parent, err := s.VariantDir(packageID, variant) + if err != nil { + return "", err + } + dir, err := os.MkdirTemp(parent, "attempt-*") + if err != nil { + return "", fmt.Errorf("failed to create attempt staging directory: %w", err) + } + return dir, nil +} + +// Cleanup removes this operation's disposable staging tree only. +func (s *OperationStaging) Cleanup() error { + if s == nil || s.Root == "" { + return nil + } + root := s.Root + s.Root = "" + if err := os.RemoveAll(root); err != nil { + return fmt.Errorf("failed to cleanup operation staging %s: %w", root, err) + } return nil } + +func sanitizeStagingName(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := b.String() + if len(out) > 80 { + out = out[:80] + } + return out +} diff --git a/internal/platform/temp_test.go b/internal/platform/temp_test.go new file mode 100644 index 0000000..552fcfb --- /dev/null +++ b/internal/platform/temp_test.go @@ -0,0 +1,117 @@ +package platform + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestOperationStagingCleanupLeavesSibling(t *testing.T) { + a, err := NewOperationStaging() + if err != nil { + t.Fatal(err) + } + b, err := NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = a.Cleanup() + _ = b.Cleanup() + }) + + marker := filepath.Join(b.Root, "keep.txt") + if err := os.WriteFile(marker, []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + if err := a.Cleanup(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(a.Root); !os.IsNotExist(err) { + t.Fatalf("cleaned staging still present: %v", err) + } + got, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("sibling staging lost: %v", err) + } + if string(got) != "keep" { + t.Fatalf("sibling contents = %q", got) + } + + root, err := GetTempDir() + if err != nil { + t.Fatal(err) + } + if err := CleanupTempDir(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(root); err != nil { + t.Fatalf("shared temp root must survive occupied cleanup: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("occupied child must survive shared-root cleanup: %v", err) + } +} + +func TestOperationStagingConcurrentCleanup(t *testing.T) { + const n = 8 + stags := make([]*OperationStaging, n) + for i := 0; i < n; i++ { + s, err := NewOperationStaging() + if err != nil { + t.Fatal(err) + } + stags[i] = s + if err := os.WriteFile(filepath.Join(s.Root, "x"), []byte{byte(i)}, 0644); err != nil { + t.Fatal(err) + } + } + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + if err := stags[i].Cleanup(); err != nil { + t.Errorf("cleanup %d: %v", i, err) + } + }() + } + wg.Wait() + for i, s := range stags { + if _, err := os.Stat(s.Root); !os.IsNotExist(err) { + t.Errorf("staging %d still present", i) + } + } +} + +func TestAttemptDirIsolatedFromSiblingVariant(t *testing.T) { + s, err := NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Cleanup() }) + + okDir, err := s.VariantDir("pkg", "regular") + if err != nil { + t.Fatal(err) + } + keep := filepath.Join(okDir, "ok.ttf") + if err := os.WriteFile(keep, []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + attempt, err := s.AttemptDir("pkg", "bold") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(attempt, "bad.bin"), []byte("bad"), 0644); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(attempt); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(keep); err != nil { + t.Fatalf("successful variant staging lost: %v", err) + } +} diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 6d40a09..79ae3c6 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -86,38 +86,41 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop targetPath := filepath.Join(targetDir, fontName) logger.Debug("Target path: %s", targetPath) - // Check if font is already installed - logger.Debug("Checking if font is already installed...") + existed := false if _, err := os.Stat(targetPath); err == nil { - if !force { - logger.Warn("Font already installed at %s", targetPath) - return fmt.Errorf("font already installed: %s", fontName) + existed = true + if force { + logger.Debug("Unregistering existing font before safe replacement...") + _ = RemoveFontResource(targetPath) } - logger.Debug("Font exists, removing due to force flag...") - // Remove the existing file if force is true - if err := os.Remove(targetPath); err != nil { - logger.Error("Failed to overwrite existing font at %s: %v", targetPath, err) - return fmt.Errorf("failed to overwrite existing font: %w", err) + } + + logger.Debug("Placing font file at destination...") + mut, err := placeFontFile(fontPath, targetPath, force, opts) + if err != nil { + logger.Error("Failed to place font file at %s: %v", targetPath, err) + if existed { + _ = AddFontResource(targetPath) } - logger.Debug("Existing font removed successfully") + return err } - // Copy the font file to the target directory - logger.Debug("Copying font file to target directory...") - if err := copyFile(fontPath, targetPath); err != nil { - logger.Error("Failed to copy font file from %s to %s: %v", fontPath, targetPath, err) - return fmt.Errorf("failed to copy font file: %w", err) + if opts != nil && opts.FailPoint == InstallFailRegister { + _ = RollbackMutation(mut) + if existed { + _ = AddFontResource(targetPath) + } + return failPointError(InstallFailRegister) } - logger.Debug("Font file copied successfully") - // Add the font to the system logger.Debug("Adding font resource...") if err := AddFontResource(targetPath); err != nil { logger.Error("Failed to add font resource at %s: %v", targetPath, err) - // Clean up on error - logger.Debug("Cleaning up after failed font resource addition...") - if removeErr := os.Remove(targetPath); removeErr != nil { - logger.Error("Failed to clean up font file after resource addition failure: %v", removeErr) + if rbErr := RollbackMutation(mut); rbErr != nil { + logger.Error("Rollback after register failure: %v", rbErr) + } + if existed { + _ = AddFontResource(targetPath) } return fmt.Errorf("failed to add font resource: %w", err) } @@ -128,10 +131,13 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop logger.Debug("Adding font to registry...") if err := m.addFontToRegistry(fontName, targetPath); err != nil { logger.Error("Failed to add font to registry: %v", err) - // Clean up on error - logger.Debug("Cleaning up after failed registry addition...") RemoveFontResource(targetPath) - os.Remove(targetPath) + if rbErr := RollbackMutation(mut); rbErr != nil { + logger.Error("Rollback after registry failure: %v", rbErr) + } + if existed { + _ = AddFontResource(targetPath) + } return fmt.Errorf("failed to add font to registry: %w", err) } logger.Debug("Font added to registry successfully") @@ -143,13 +149,16 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop logger.Debug("Notifying system about font change...") if err := NotifyFontChange(); err != nil { logger.Error("Failed to notify font change: %v", err) - // Clean up on error - logger.Debug("Cleaning up after failed notification...") RemoveFontResource(targetPath) if scope == MachineScope { m.removeFontFromRegistry(fontName) } - os.Remove(targetPath) + if rbErr := RollbackMutation(mut); rbErr != nil { + logger.Error("Rollback after notify failure: %v", rbErr) + } + if existed { + _ = AddFontResource(targetPath) + } return fmt.Errorf("failed to notify font change: %w", err) } logger.Debug("Font change notification sent successfully") diff --git a/internal/repo/checksum.go b/internal/repo/checksum.go new file mode 100644 index 0000000..78d0d57 --- /dev/null +++ b/internal/repo/checksum.go @@ -0,0 +1,126 @@ +package repo + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "os" + "path" + "strings" + + "fontget/internal/network" +) + +var ( + // ErrChecksumMismatch is returned when a downloaded payload does not match its expected digest. + ErrChecksumMismatch = errors.New("checksum mismatch") + // ErrMalformedChecksum is returned when a non-empty expected digest is not a valid SHA-256 hex value. + ErrMalformedChecksum = errors.New("malformed checksum") + // ErrChecksumUnassociated is returned when an expected digest cannot be applied to the candidate payload. + ErrChecksumUnassociated = errors.New("checksum does not apply to this payload") + // ErrCandidatesExhausted is returned when every download candidate failed. + ErrCandidatesExhausted = errors.New("download candidates exhausted") +) + +// ParseExpectedSHA256 validates a supplied expected digest. Empty means no checksum was provided. +// A malformed non-empty value is an error, not equivalent to no checksum. +func ParseExpectedSHA256(expected string) (string, error) { + expected = strings.TrimSpace(expected) + if expected == "" { + return "", nil + } + if strings.HasPrefix(expected, "sha256:") || strings.HasPrefix(expected, "SHA256:") { + expected = strings.TrimSpace(expected[7:]) + } + if len(expected) != 64 { + return "", fmt.Errorf("%w: expected 64 hex characters", ErrMalformedChecksum) + } + for i := 0; i < len(expected); i++ { + c := expected[i] + hexDigit := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + if !hexDigit { + return "", fmt.Errorf("%w: non-hex character", ErrMalformedChecksum) + } + } + return strings.ToLower(expected), nil +} + +// VerifyFileSHA256 compares path bytes against an already-validated expected hex digest. +func VerifyFileSHA256(path, expectedHex string) error { + expectedHex, err := ParseExpectedSHA256(expectedHex) + if err != nil { + return err + } + if expectedHex == "" { + return nil + } + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("%w: open downloaded file: %v", network.ErrLocalFailure, err) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("%w: hash downloaded file: %v", network.ErrLocalFailure, err) + } + got := hex.EncodeToString(h.Sum(nil)) + if got != expectedHex { + return fmt.Errorf("%w: expected %s, got %s", ErrChecksumMismatch, expectedHex, got) + } + return nil +} + +// checksumAppliesToCandidate reports whether font.SHA may be used for candidateURL. +// Same URL or same basename (mirrors) may share a digest. Different representations cannot. +func checksumAppliesToCandidate(font *FontFile, candidateURL string) error { + if font == nil { + return nil + } + normalized, err := ParseExpectedSHA256(font.SHA) + if err != nil { + return err + } + if normalized == "" { + return nil + } + font.SHA = normalized + primary := strings.TrimSpace(font.DownloadURL) + if primary == "" && len(font.DownloadCandidates) > 0 { + primary = font.DownloadCandidates[0] + } + if sameChecksumPayload(primary, candidateURL) { + return nil + } + return fmt.Errorf("%w: digest is bound to %s, not %s", ErrChecksumUnassociated, primary, candidateURL) +} + +func sameChecksumPayload(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == "" || b == "" { + return false + } + if normalizeDownloadURLKey(a) == normalizeDownloadURLKey(b) { + return true + } + return payloadIdentity(a) == payloadIdentity(b) && payloadIdentity(a) != "" +} + +func payloadIdentity(raw string) string { + u, err := url.Parse(raw) + name := raw + if err == nil { + name = u.Path + } + base := strings.ToLower(path.Base(name)) + if i := strings.IndexByte(base, '?'); i >= 0 { + base = base[:i] + } + if base == "" || base == "." || base == "/" { + return "" + } + return base +} diff --git a/internal/repo/checksum_test.go b/internal/repo/checksum_test.go new file mode 100644 index 0000000..7949a99 --- /dev/null +++ b/internal/repo/checksum_test.go @@ -0,0 +1,64 @@ +package repo + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestParseExpectedSHA256(t *testing.T) { + if got, err := ParseExpectedSHA256(""); err != nil || got != "" { + t.Fatalf("empty: got %q err=%v", got, err) + } + ok := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + got, err := ParseExpectedSHA256("SHA256:" + ok) + if err != nil || got != ok { + t.Fatalf("prefixed: got %q err=%v", got, err) + } + if _, err := ParseExpectedSHA256("not-a-hash"); err == nil || !errors.Is(err, ErrMalformedChecksum) { + t.Fatalf("malformed: err=%v", err) + } + if _, err := ParseExpectedSHA256("xyz"); err == nil || !errors.Is(err, ErrMalformedChecksum) { + t.Fatalf("short: err=%v", err) + } +} + +func TestVerifyFileSHA256(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.bin") + data := []byte("font-bytes") + if err := os.WriteFile(p, data, 0644); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + hexSum := hex.EncodeToString(sum[:]) + if err := VerifyFileSHA256(p, hexSum); err != nil { + t.Fatalf("correct digest: %v", err) + } + if err := VerifyFileSHA256(p, ""); err != nil { + t.Fatalf("no digest: %v", err) + } + wrong := hex.EncodeToString(make([]byte, 32)) + if err := VerifyFileSHA256(p, wrong); err == nil || !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("wrong digest: %v", err) + } +} + +func TestChecksumAppliesToCandidate(t *testing.T) { + sum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + font := &FontFile{ + SHA: sum, + DownloadURL: "https://cdn.example/a.zip", + DownloadCandidates: []string{"https://cdn.example/a.zip", "https://cdn.example/a.tar.xz"}, + } + if err := checksumAppliesToCandidate(font, "https://mirror.example/a.zip"); err != nil { + t.Fatalf("mirror zip should share digest: %v", err) + } + err := checksumAppliesToCandidate(font, "https://cdn.example/a.tar.xz") + if err == nil || !errors.Is(err, ErrChecksumUnassociated) { + t.Fatalf("tar.xz must not use zip digest: %v", err) + } +} diff --git a/internal/repo/download_candidates_test.go b/internal/repo/download_candidates_test.go index 04e791a..7f885f1 100644 --- a/internal/repo/download_candidates_test.go +++ b/internal/repo/download_candidates_test.go @@ -3,6 +3,7 @@ package repo import ( "archive/zip" "bytes" + "errors" "net/http" "net/http/httptest" "os" @@ -216,6 +217,7 @@ func TestDownloadAndExtractFont_formatRetryToSecondCandidate(t *testing.T) { return } if !strings.Contains(err.Error(), "after 2 format candidates") && + !strings.Contains(err.Error(), "candidates exhausted") && !strings.Contains(err.Error(), "no valid font files") && !strings.Contains(err.Error(), "failed to extract") { t.Fatalf("unexpected err after retry: %v", err) @@ -246,8 +248,12 @@ func TestDownloadAndExtractFont_allCandidatesFail(t *testing.T) { if err == nil { t.Fatal("expected error") } - if !strings.Contains(err.Error(), "after 2 format candidates") { - t.Fatalf("want multi-candidate error, got: %v", err) + if !errors.Is(err, ErrCandidatesExhausted) { + t.Fatalf("want ErrCandidatesExhausted, got: %v", err) + } + msg := err.Error() + if !strings.Contains(msg, "/a.tar.xz") || !strings.Contains(msg, "/a.zip") { + t.Fatalf("want candidate outcomes, got: %v", err) } entries, _ := os.ReadDir(tmp) for _, e := range entries { diff --git a/internal/repo/download_reliability_test.go b/internal/repo/download_reliability_test.go new file mode 100644 index 0000000..18abde2 --- /dev/null +++ b/internal/repo/download_reliability_test.go @@ -0,0 +1,183 @@ +package repo + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "fontget/internal/network" + "fontget/internal/testutil" +) + +func TestDownloadAndExtractFont_Skip404ThenSucceed(t *testing.T) { + payload := testutil.MinimalTTF("TestFamily", "Regular") + missingHits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/missing.ttf") { + missingHits++ + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "font/ttf") + _, _ = w.Write(payload) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + font := &FontFile{ + Name: "Test", + Variant: "Regular", + DownloadURL: srv.URL + "/missing.ttf", + DownloadCandidates: []string{srv.URL + "/missing.ttf", srv.URL + "/ok.ttf"}, + } + paths, err := DownloadAndExtractFont(font, dir, nil) + if err != nil { + t.Fatalf("expected fallback candidate to succeed: %v", err) + } + if len(paths) != 1 { + t.Fatalf("paths=%v", paths) + } + if missingHits != 1 { + t.Fatalf("404 URL hit %d times; must not retry with external tools", missingHits) + } +} + +func TestDownloadFont_ChecksumMismatch(t *testing.T) { + payload := testutil.MinimalTTF("TestFamily", "Regular") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(payload) + })) + t.Cleanup(srv.Close) + + wrong := hex.EncodeToString(make([]byte, 32)) + dir := t.TempDir() + font := &FontFile{Name: "Test", Variant: "Regular", Path: "t.ttf", DownloadURL: srv.URL + "/t.ttf", SHA: wrong} + _, err := DownloadFont(font, dir, nil) + if err == nil || !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("want checksum mismatch, got %v", err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Fatalf("rejected download must be removed, leftover %v", entries) + } +} + +func TestDownloadFont_CorrectChecksum(t *testing.T) { + payload := testutil.MinimalTTF("TestFamily", "Regular") + sum := sha256.Sum256(payload) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(payload) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + font := &FontFile{Name: "Test", Variant: "Regular", Path: "t.ttf", DownloadURL: srv.URL + "/t.ttf", SHA: hex.EncodeToString(sum[:])} + path, err := DownloadFont(font, dir, nil) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatal("payload mismatch") + } +} + +func TestDownloadFont_MalformedChecksum(t *testing.T) { + dir := t.TempDir() + font := &FontFile{Name: "Test", Path: "t.ttf", DownloadURL: "https://example.com/t.ttf", SHA: "nope"} + _, err := DownloadFont(font, dir, nil) + if err == nil || !errors.Is(err, ErrMalformedChecksum) { + t.Fatalf("want malformed checksum, got %v", err) + } +} + +func TestDownloadAndExtractFont_UnassociatedChecksum(t *testing.T) { + sum := hex.EncodeToString(make([]byte, 32)) + sum = strings.ReplaceAll(sum, "00", "ab") + if len(sum) != 64 { + sum = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + font := &FontFile{ + Name: "Test", + SHA: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + DownloadURL: "https://example.com/a.zip", + DownloadCandidates: []string{"https://example.com/a.zip", "https://example.com/a.tar.xz"}, + } + _, err := DownloadAndExtractFont(font, t.TempDir(), nil) + if err == nil || !errors.Is(err, ErrChecksumUnassociated) { + t.Fatalf("want unassociated checksum, got %v", err) + } +} + +func TestDownloadFont_LocalWriteFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte{0x00, 0x01, 0x00, 0x00}) + })) + t.Cleanup(srv.Close) + blocked := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(blocked, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + font := &FontFile{Name: "Test", Path: "t.ttf", DownloadURL: srv.URL + "/t.ttf"} + _, err := DownloadFont(font, blocked, nil) + if err == nil || !errors.Is(err, network.ErrLocalFailure) { + t.Fatalf("want local failure, got %v", err) + } +} + +func TestCompleteDownloadedFile(t *testing.T) { + payload := testutil.MinimalTTF("HashFam", "Regular") + sum := sha256.Sum256(payload) + dir := t.TempDir() + okPath := filepath.Join(dir, "ok.ttf") + if err := os.WriteFile(okPath, payload, 0644); err != nil { + t.Fatal(err) + } + if err := completeDownloadedFile(okPath, hex.EncodeToString(sum[:])); err != nil { + t.Fatal(err) + } + bad := filepath.Join(dir, "bad.ttf") + if err := os.WriteFile(bad, payload, 0644); err != nil { + t.Fatal(err) + } + wrong := hex.EncodeToString(make([]byte, 32)) + if err := completeDownloadedFile(bad, wrong); err == nil || !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("want mismatch, got %v", err) + } + if _, err := os.Stat(bad); !os.IsNotExist(err) { + t.Fatal("rejected payload must be removed") + } +} + +func TestDownloadFont_RetryAfterExceedsBudget(t *testing.T) { + old := network.MaxRetryAfterWait + network.MaxRetryAfterWait = time.Millisecond + t.Cleanup(func() { network.MaxRetryAfterWait = old }) + + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + })) + t.Cleanup(srv.Close) + + font := &FontFile{Name: "Test", Path: "t.ttf", DownloadURL: srv.URL + "/t.ttf"} + _, err := DownloadFont(font, t.TempDir(), nil) + if err == nil || !errors.Is(err, network.ErrRateLimited) { + t.Fatalf("want rate limited, got %v", err) + } + if hits != 1 { + t.Fatalf("over-budget 429 must not retry, hits=%d", hits) + } +} diff --git a/internal/repo/font.go b/internal/repo/font.go index ba92c2c..c1ce83d 100644 --- a/internal/repo/font.go +++ b/internal/repo/font.go @@ -3,8 +3,7 @@ package repo import ( "bufio" "bytes" - "crypto/sha256" - "encoding/hex" + "context" "errors" "fmt" "fontget/internal/config" @@ -65,10 +64,13 @@ var ( downloadHostSlots = map[string]chan struct{}{} ) -func acquireDownloadHostSlot(host string) func() { +func acquireDownloadHostSlot(ctx context.Context, host string) (func(), error) { + if ctx == nil { + ctx = context.Background() + } host = strings.TrimSpace(strings.ToLower(host)) if host == "" { - return func() {} + return func() {}, nil } downloadHostMu.Lock() @@ -80,8 +82,12 @@ func acquireDownloadHostSlot(host string) func() { } downloadHostMu.Unlock() - ch <- struct{}{} - return func() { <-ch } + select { + case ch <- struct{}{}: + return func() { <-ch }, nil + case <-ctx.Done(): + return func() {}, ctx.Err() + } } func fallbackHeadersFromRequest(req *http.Request) map[string]string { @@ -172,9 +178,12 @@ type DownloadFontOptions struct { // ArchiveFontID is the full FontGet font ID (e.g. "nerd.noto-sans-mono") used by source-specific // archive selectors when FontFile.Name alone is insufficient. ArchiveFontID string + + // Context cancels host-slot waits, HTTP requests, retry backoff and external tools. + Context context.Context } -// DownloadFont downloads a font file and verifies its SHA-256 hash if available +// DownloadFont downloads a font file and verifies its SHA-256 hash if available. func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) (string, error) { start := time.Now() dbg := func(format string, args ...interface{}) { @@ -192,24 +201,37 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( dbg("DownloadFont: downloaded size=%d bytes path=%s", info.Size(), path) } - // Create target directory if it doesn't exist + if font == nil { + return "", fmt.Errorf("font is nil") + } + ctx := context.Background() + if opts != nil && opts.Context != nil { + ctx = opts.Context + } + if err := ctx.Err(); err != nil { + return "", err + } + + if _, err := ParseExpectedSHA256(font.SHA); err != nil { + return "", err + } + if err := checksumAppliesToCandidate(font, font.DownloadURL); err != nil { + return "", err + } + if err := os.MkdirAll(targetDir, 0755); err != nil { - return "", fmt.Errorf("failed to create target directory: %w", err) + return "", fmt.Errorf("%w: failed to create target directory: %v", network.ErrLocalFailure, err) } - // Compute target path early so we can fall back to alternate download methods. targetPath := filepath.Join(targetDir, font.Path) - // Create HTTP request - req, err := http.NewRequest("GET", font.DownloadURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", font.DownloadURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Accept", "*/*") - // Many CDNs behave more predictably when Accept-Language is present (still honest; not browser spoofing). req.Header.Set("Accept-Language", "en-US,en;q=0.9") - // Download file appConfig := config.GetUserPreferences() downloadTimeout := config.ParseDuration(appConfig.Network.DownloadTimeout, 30*time.Second) requestTimeout := config.ParseDuration(appConfig.Network.RequestTimeout, 10*time.Second) @@ -222,7 +244,6 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( path = u.Path } - // Font Squirrel-specific headers: make the request look like a normal file download, without pretending to be a browser. isFontSquirrel := strings.Contains(host, "fontsquirrel.com") if isFontSquirrel { req.Header.Set("User-Agent", resolveDownloadUserAgent()) @@ -230,10 +251,8 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( req.Header.Set("Accept-Encoding", "identity") req.Header.Set("Referer", "https://www.fontsquirrel.com/") } else { - // Default UA for other upstreams. req.Header.Set("User-Agent", resolveDownloadUserAgent()) } - // Font Squirrel often delays/challenges non-browser clients. Prefer failing fast and falling back. fastHeaderTimeout := requestTimeout if fallbackEnabled && strings.Contains(host, "fontsquirrel.com") { if fastHeaderTimeout <= 0 || fastHeaderTimeout > 3*time.Second { @@ -241,101 +260,100 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( } } - releaseHost := acquireDownloadHostSlot(host) + releaseHost, err := acquireDownloadHostSlot(ctx, host) + if err != nil { + return "", err + } defer releaseHost() - // Don't use http.Client.Timeout for downloads - it times out even during active transfers - // Instead, use ResponseHeaderTimeout to detect connection issues early - // The stall detector handles inactivity detection (no overall timeout needed) + fbOpts := network.DownloadFallbackOptions{ + UserAgent: req.Header.Get("User-Agent"), + Headers: fallbackHeadersFromRequest(req), + Context: ctx, + Exec: network.ExecOptions{ + InactivityTimeout: downloadTimeout, + }, + } + if downloadTimeout < network.DefaultExternalInactivityTimeout { + fbOpts.Exec.InactivityTimeout = network.DefaultExternalInactivityTimeout + } + + completeExternal := func(rep *network.DownloadFallbackReport) (string, error) { + if rep != nil { + for _, step := range rep.Steps { + dbg("DownloadFont fallback step: tool=%s path=%s result=%s detail=%q", step.Tool, step.Path, step.Result, step.Detail) + } + } + if err := completeDownloadedFile(targetPath, font.SHA); err != nil { + return "", err + } + toolName, toolPath := rep.UsedTool() + dbg("DownloadFont: %s -> %s (via %s)", font.DownloadURL, targetPath, toolName) + logging.GetLogger().Info("External download succeeded using %s (%s)", toolName, toolPath) + dbgFileSize(targetPath) + return targetPath, nil + } + resp, err := doDownloadRequestWithHeaderTimeout(req, fastHeaderTimeout, 15*time.Second, start, dbg) if err != nil { - // Always attempt external fallbacks when enabled. - if fallbackEnabled { + action := network.ClassifyDownloadError(err) + if action == network.ActionFailPackage || action == network.ActionFailLocal { + return "", err + } + if fallbackEnabled && action == network.ActionExternalFallback { dbg("DownloadFont: standard request failed: %v", err) - rep, fbErr := network.DownloadWithFallbacks(font.DownloadURL, targetPath, network.DownloadFallbackOptions{ - UserAgent: req.Header.Get("User-Agent"), - Headers: fallbackHeadersFromRequest(req), - }) - if rep != nil { - for _, step := range rep.Steps { - dbg("DownloadFont fallback step: tool=%s path=%s result=%s detail=%q", step.Tool, step.Path, step.Result, step.Detail) - } - } + rep, fbErr := network.DownloadWithFallbacks(font.DownloadURL, targetPath, fbOpts) if fbErr == nil { - toolName, toolPath := rep.UsedTool() - dbg("DownloadFont: %s -> %s (via %s)", font.DownloadURL, targetPath, toolName) - logging.GetLogger().Info("External download succeeded using %s (%s)", toolName, toolPath) - dbgFileSize(targetPath) - return targetPath, nil + return completeExternal(rep) + } + if network.ClassifyDownloadError(fbErr) != network.ActionExternalFallback { + return "", fbErr } } return "", fmt.Errorf("failed to download font: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Bot/WAF challenge: retry using external tools when enabled. - if network.IsBotChallenge(resp) { - logging.GetLogger().Info("Download: HTTP %d bot/WAF challenge from host %s", resp.StatusCode, host) - - wafAction := network.HeaderValueFold(resp.Header, "x-amzn-waf-action") - dbg("DownloadFont: IsBotChallenge=true status=%d x-amzn-waf-action=%q", resp.StatusCode, wafAction) + action := network.ClassifyHTTPStatus(resp.StatusCode, network.IsBotChallenge(resp)) + if action != network.ActionSuccess { + retryAfter, _ := network.ParseRetryAfter(resp.Header, time.Now()) + network.DrainAndCloseBody(resp.Body) + switch action { + case network.ActionAdvanceCandidate, network.ActionFailCandidate: + return "", network.NewHTTPStatusError(resp.StatusCode, font.DownloadURL, retryAfter) + case network.ActionRateLimit: + if !network.RetryAfterWithinBudget(retryAfter) { + return "", fmt.Errorf("%w: Retry-After %s exceeds wait budget", network.ErrRateLimited, retryAfter) + } + return "", network.NewHTTPStatusError(resp.StatusCode, font.DownloadURL, retryAfter) + case network.ActionExternalFallback: if !fallbackEnabled { logging.GetLogger().Info("External download fallback disabled (Network.EnableExternalDownloadFallback=false); not retrying with external tools") output.GetVerbose().Warning("Upstream returned HTTP %d (bot/WAF challenge). External download fallback is disabled in config.", resp.StatusCode) - dbg("DownloadFont: skipping DownloadWithFallbacks (EnableExternalDownloadFallback=false)") - return "", fmt.Errorf("HTTP %d (blocked by upstream challenge). Enable Network.EnableExternalDownloadFallback in config.yaml or retry later: %s", resp.StatusCode, font.DownloadURL) + return "", fmt.Errorf("HTTP %d (blocked by upstream challenge). Enable Network.EnableExternalDownloadFallback in config.yaml or retry later: %s", resp.StatusCode, network.RedactDownloadURL(font.DownloadURL)) } - output.GetVerbose().Info("Upstream returned HTTP %d (bot/WAF challenge). Retrying with external download tools if available.", resp.StatusCode) - - rep, fbErr := network.DownloadWithFallbacks(font.DownloadURL, targetPath, network.DownloadFallbackOptions{ - UserAgent: req.Header.Get("User-Agent"), - Headers: fallbackHeadersFromRequest(req), - }) - for _, step := range rep.Steps { - dbg("DownloadFont fallback step: tool=%s path=%s result=%s detail=%q", step.Tool, step.Path, step.Result, step.Detail) - } - + rep, fbErr := network.DownloadWithFallbacks(font.DownloadURL, targetPath, fbOpts) if fbErr == nil { - toolName, toolPath := rep.UsedTool() - logging.GetLogger().Info("External download succeeded using %s (%s)", toolName, toolPath) - output.GetVerbose().Info("Download completed using %s after HTTP %d challenge.", toolName, resp.StatusCode) - dbg("DownloadFont: %s -> %s (via %s)", font.DownloadURL, targetPath, toolName) - dbgFileSize(targetPath) - return targetPath, nil + return completeExternal(rep) } - logging.GetLogger().Error("External download fallback failed for %s: %v", font.DownloadURL, fbErr) - output.GetVerbose().Error("External download tools did not succeed after HTTP %d challenge. Use --debug for per-tool details.", resp.StatusCode) - output.GetDebug().Error("DownloadFont: DownloadWithFallbacks failed: %v", fbErr) - return "", fmt.Errorf("HTTP %d (blocked by upstream challenge): %s", resp.StatusCode, font.DownloadURL) - } - // Any non-200: attempt fallbacks when enabled. - if fallbackEnabled { - dbg("DownloadFont: HTTP %d from upstream, attempting fallbacks", resp.StatusCode) - rep, fbErr := network.DownloadWithFallbacks(font.DownloadURL, targetPath, network.DownloadFallbackOptions{ - UserAgent: req.Header.Get("User-Agent"), - Headers: fallbackHeadersFromRequest(req), - }) - if rep != nil { - for _, step := range rep.Steps { - dbg("DownloadFont fallback step: tool=%s path=%s result=%s detail=%q", step.Tool, step.Path, step.Result, step.Detail) + return "", fmt.Errorf("HTTP %d (blocked by upstream challenge): %s", resp.StatusCode, network.RedactDownloadURL(font.DownloadURL)) + case network.ActionRetrySame: + if fallbackEnabled { + fbOnce := fbOpts + fbOnce.MaxAttempts = 1 + dbg("DownloadFont: HTTP %d after native retries, trying external tools once", resp.StatusCode) + rep, fbErr := network.DownloadWithFallbacks(font.DownloadURL, targetPath, fbOnce) + if fbErr == nil { + return completeExternal(rep) } } - if fbErr == nil { - toolName, toolPath := rep.UsedTool() - dbg("DownloadFont: %s -> %s (via %s)", font.DownloadURL, targetPath, toolName) - logging.GetLogger().Info("External download succeeded using %s (%s)", toolName, toolPath) - dbgFileSize(targetPath) - return targetPath, nil - } + return "", network.NewHTTPStatusError(resp.StatusCode, font.DownloadURL, retryAfter) + default: + return "", network.NewHTTPStatusError(resp.StatusCode, font.DownloadURL, retryAfter) } - return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, font.DownloadURL) } - // Best-effort response info capture for downstream archive detection. if opts != nil && opts.OnResponseHeaders != nil { finalURL := "" if resp != nil && resp.Request != nil && resp.Request.URL != nil { @@ -348,8 +366,6 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( }) } - // If this looks like a Font Squirrel kit download, validate we actually received a ZIP payload. - // This prevents saving an HTML/WAF page that happens to return HTTP 200. expectZIP := isFontSquirrel && strings.Contains(path, "/fontfacekit/") if !expectZIP { ct := strings.ToLower(resp.Header.Get("Content-Type")) @@ -371,9 +387,6 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( } } - // Wrap response body with stall detection - // No overall timeout (0) - downloads can take as long as needed if there's activity - // Only timeout if no activity for downloadTimeout duration stallReader := network.WrapReaderWithStallDetection(resp.Body, downloadTimeout, 0) defer stallReader.Close() @@ -381,64 +394,58 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( if totalBytes <= 0 { totalBytes = -1 } - // Optional byte progress callback. var reader io.Reader = stallReader if opts != nil && opts.OnBytesDownloaded != nil { reader = newProgressReader(stallReader, totalBytes, opts.OnBytesDownloaded) } - // ZIP validation (best-effort) before writing to disk. if expectZIP { br := bufio.NewReader(reader) if hdr, peekErr := br.Peek(4); peekErr == nil { if !isZipMagic(hdr) { - return "", fmt.Errorf("download did not return a ZIP archive (possible upstream challenge): %s", font.DownloadURL) + return "", fmt.Errorf("download did not return a ZIP archive (possible upstream challenge): %s", network.RedactDownloadURL(font.DownloadURL)) } } reader = br } - // Create target file file, err := os.Create(targetPath) if err != nil { - return "", fmt.Errorf("failed to create file: %w", err) + return "", fmt.Errorf("%w: failed to create file: %v", network.ErrLocalFailure, err) } - defer file.Close() - - // If we have a SHA hash, verify it - if font.SHA != "" { - // Create SHA-256 hash - hash := sha256.New() - tee := io.TeeReader(reader, hash) - - // Copy file content with stall detection - if _, err := io.Copy(file, tee); err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + if _, err := io.Copy(file, reader); err != nil { + _ = file.Close() + _ = os.Remove(targetPath) + if ctx.Err() != nil { + return "", ctx.Err() } + return "", fmt.Errorf("failed to write file: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(targetPath) + return "", fmt.Errorf("%w: close download: %v", network.ErrLocalFailure, err) + } - // Calculate SHA-256 - calculatedHash := hex.EncodeToString(hash.Sum(nil)) - if calculatedHash != font.SHA { - if rerr := os.Remove(targetPath); rerr != nil && !os.IsNotExist(rerr) { - return "", fmt.Errorf("SHA-256 verification failed: expected %s, got %s (remove partial file: %v)", font.SHA, calculatedHash, rerr) - } - return "", fmt.Errorf("SHA-256 verification failed: expected %s, got %s", font.SHA, calculatedHash) - } - } else { - // Just copy the file content if we don't have a SHA hash - // Use stallReader instead of resp.Body for stall detection - if _, err := io.Copy(file, reader); err != nil { - return "", fmt.Errorf("failed to write file: %w", err) - } + if err := completeDownloadedFile(targetPath, font.SHA); err != nil { + return "", err } logging.GetLogger().Info("Download complete: %s -> %s", font.Path, targetPath) dbg("DownloadFont: %s -> %s", font.DownloadURL, targetPath) dbgFileSize(targetPath) - return targetPath, nil } +func completeDownloadedFile(path, expectedSHA string) error { + if err := VerifyFileSHA256(path, expectedSHA); err != nil { + if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) { + return fmt.Errorf("%w (remove rejected file: %v)", err, rerr) + } + return err + } + return nil +} + func doDownloadRequestWithHeaderTimeout(req *http.Request, fastHeaderTimeout time.Duration, slowHeaderTimeout time.Duration, start time.Time, dbg func(string, ...interface{})) (*http.Response, error) { if fastHeaderTimeout <= 0 { fastHeaderTimeout = 10 * time.Second @@ -487,13 +494,23 @@ func doDownloadRequestWithHeaderTimeout(req *http.Request, fastHeaderTimeout tim } if resp != nil && network.ShouldRetryGoDownloadStatus(resp.StatusCode) && attempt < maxTransientAttempts { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() + wait := backoff + time.Duration(rng.Intn(120))*time.Millisecond + if resp.StatusCode == http.StatusTooManyRequests { + if ra, ok := network.ParseRetryAfter(resp.Header, time.Now()); ok { + if !network.RetryAfterWithinBudget(ra) { + network.DrainAndCloseBody(resp.Body) + return nil, fmt.Errorf("%w: Retry-After %s exceeds wait budget", network.ErrRateLimited, ra) + } + wait = ra + } + } + network.DrainAndCloseBody(resp.Body) if dbg != nil { dbg("DownloadFont: transient HTTP %d, retrying (attempt %d/%d) hdr=%s", resp.StatusCode, attempt, maxTransientAttempts, network.FormatHTTPHeadersForDebug(resp.Header)) } - j := time.Duration(rng.Intn(120)) * time.Millisecond - time.Sleep(backoff + j) + if err := sleepRequest(req, wait); err != nil { + return nil, err + } backoff *= 2 continue } @@ -504,6 +521,24 @@ func doDownloadRequestWithHeaderTimeout(req *http.Request, fastHeaderTimeout tim return nil, fmt.Errorf("download request failed after %d attempts", maxTransientAttempts) } +func sleepRequest(req *http.Request, d time.Duration) error { + ctx := context.Background() + if req != nil && req.Context() != nil { + ctx = req.Context() + } + if d <= 0 { + return ctx.Err() + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + func isHTTP2HeaderTimeout(err error) bool { if err == nil { return false @@ -537,11 +572,27 @@ func DownloadAndExtractFont(font *FontFile, targetDir string, opts *DownloadFont } var lastErr error + var outcomes []string + primaryURL := strings.TrimSpace(font.DownloadURL) + if primaryURL == "" { + primaryURL = candidates[0] + } for i, candidateURL := range candidates { candidateURL = strings.TrimSpace(candidateURL) if candidateURL == "" { continue } + if opts != nil && opts.Context != nil { + if err := opts.Context.Err(); err != nil { + return nil, err + } + } + + attemptDir, err := os.MkdirTemp(targetDir, fmt.Sprintf("attempt-%d-*", i+1)) + if err != nil { + return nil, fmt.Errorf("%w: attempt staging: %v", network.ErrLocalFailure, err) + } + font.DownloadURL = candidateURL if isArchiveFile(candidateURL) { font.Path = filepath.Base(candidateURL) @@ -549,8 +600,22 @@ func DownloadAndExtractFont(font *FontFile, targetDir string, opts *DownloadFont font.Path = createFontFileName(font.Name, font.Variant, candidateURL) } - output.GetDebug().State("DownloadAndExtractFont: attempt %d/%d url=%s", i+1, len(candidates), candidateURL) - paths, err := attemptDownloadAndExtract(font, targetDir, opts) + if applyErr := checksumAppliesToCandidate(&FontFile{ + SHA: font.SHA, + DownloadURL: primaryURL, + DownloadCandidates: candidates, + }, candidateURL); applyErr != nil { + _ = os.RemoveAll(attemptDir) + lastErr = applyErr + outcomes = append(outcomes, fmt.Sprintf("%s: %v", network.RedactDownloadURL(candidateURL), applyErr)) + if errors.Is(applyErr, ErrChecksumUnassociated) || errors.Is(applyErr, ErrMalformedChecksum) { + return nil, applyErr + } + continue + } + + output.GetDebug().State("DownloadAndExtractFont: attempt %d/%d url=%s dir=%s", i+1, len(candidates), candidateURL, attemptDir) + paths, err := attemptDownloadAndExtract(font, attemptDir, opts) if err == nil { if i > 0 { output.GetDebug().State("DownloadAndExtractFont: succeeded on format candidate %d/%d url=%s", i+1, len(candidates), candidateURL) @@ -558,24 +623,32 @@ func DownloadAndExtractFont(font *FontFile, targetDir string, opts *DownloadFont return paths, nil } lastErr = err + outcomes = append(outcomes, fmt.Sprintf("%s: %v", network.RedactDownloadURL(candidateURL), err)) output.GetDebug().State("DownloadAndExtractFont: candidate %d/%d failed: %v", i+1, len(candidates), err) + _ = os.RemoveAll(attemptDir) - // Best-effort cleanup of this attempt's artifacts before the next format. - _ = os.Remove(filepath.Join(targetDir, font.Path)) - _ = os.RemoveAll(filepath.Join(targetDir, "extracted")) - - if i+1 < len(candidates) { - output.GetDebug().State("DownloadAndExtractFont: trying next format candidate") + if errors.Is(err, ErrChecksumMismatch) || errors.Is(err, ErrMalformedChecksum) || errors.Is(err, ErrChecksumUnassociated) { + return nil, err + } + action := network.ClassifyDownloadError(err) + switch action { + case network.ActionFailPackage, network.ActionFailLocal: + return nil, err + case network.ActionAdvanceCandidate, network.ActionFailCandidate, network.ActionRetrySame, network.ActionRateLimit, network.ActionExternalFallback: + if i+1 < len(candidates) { + output.GetDebug().State("DownloadAndExtractFont: trying next format candidate") + } + default: + if i+1 < len(candidates) { + output.GetDebug().State("DownloadAndExtractFont: trying next format candidate") + } } } if lastErr == nil { return nil, fmt.Errorf("no download URL for %s", font.Name) } - if len(candidates) > 1 { - return nil, fmt.Errorf("%w (after %d format candidates)", lastErr, len(candidates)) - } - return nil, lastErr + return nil, fmt.Errorf("%w: %s (%s)", ErrCandidatesExhausted, font.Name, strings.Join(outcomes, "; ")) } // attemptDownloadAndExtract performs one download + optional extract + validation for font.DownloadURL. diff --git a/internal/shared/errors.go b/internal/shared/errors.go index c4565a4..5ec867c 100644 --- a/internal/shared/errors.go +++ b/internal/shared/errors.go @@ -65,8 +65,48 @@ func (e *ElevationError) Error() string { return fmt.Sprintf("elevation required for operation '%s' on platform '%s'", e.Operation, e.Platform) } -// ErrOperationCancelled is a sentinel error used to indicate that an operation was cancelled by the user. -var ErrOperationCancelled = errors.New("operation cancelled") +// Classified operation errors. Callers should use errors.Is; do not match message text. +var ( + // ErrOperationCancelled is a sentinel error used to indicate that an operation was cancelled by the user. + ErrOperationCancelled = errors.New("operation cancelled") + + // ErrRecoveryRequired is returned when installation rollback could not fully restore prior state. + ErrRecoveryRequired = errors.New("installation recovery required") + // ErrLocalFailure is returned for local permission or disk errors that network fallback cannot repair. + ErrLocalFailure = errors.New("local filesystem failure") +) + +// DisplayedError wraps an error whose user-facing message has already been printed. +// The process should still exit non-zero, but the entry point must not print it again. +type DisplayedError struct { + Cause error +} + +func (e *DisplayedError) Error() string { + if e == nil || e.Cause == nil { + return "error already displayed" + } + return e.Cause.Error() +} + +func (e *DisplayedError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// AlreadyPrinted wraps err so the executable entry point skips duplicate output. +func AlreadyPrinted(err error) error { + if err == nil { + return nil + } + var displayed *DisplayedError + if errors.As(err, &displayed) { + return err + } + return &DisplayedError{Cause: err} +} // ErrExportCancelled is a sentinel error used to indicate that an export operation was cancelled by the user. var ErrExportCancelled = errors.New("export cancelled") diff --git a/internal/testutil/minifont.go b/internal/testutil/minifont.go new file mode 100644 index 0000000..4b635de --- /dev/null +++ b/internal/testutil/minifont.go @@ -0,0 +1,81 @@ +package testutil + +import ( + "encoding/binary" + "unicode/utf16" +) + +// MinimalTTF returns a tiny SFNT with a name table so ExtractFontMetadata succeeds. +func MinimalTTF(family, style string) []byte { + if family == "" { + family = "TestFamily" + } + if style == "" { + style = "Regular" + } + full := family + " " + style + type rec struct { + id uint16 + text string + } + names := []rec{{1, family}, {2, style}, {4, full}} + + var store []byte + type nr struct { + id, length, offset uint16 + } + var recs []nr + for _, n := range names { + u := utf16.Encode([]rune(n.text)) + off := len(store) + for _, r := range u { + store = append(store, byte(r>>8), byte(r)) + } + recs = append(recs, nr{id: n.id, length: uint16(len(u) * 2), offset: uint16(off)}) + } + + count := uint16(len(recs)) + stringOffset := uint16(6 + 12*int(count)) + nameTable := make([]byte, 0, int(stringOffset)+len(store)) + nameTable = append(nameTable, 0, 0) // format + nameTable = binary.BigEndian.AppendUint16(nameTable, count) + nameTable = binary.BigEndian.AppendUint16(nameTable, stringOffset) + for _, r := range recs { + nameTable = binary.BigEndian.AppendUint16(nameTable, 3) // platform + nameTable = binary.BigEndian.AppendUint16(nameTable, 1) // encoding + nameTable = binary.BigEndian.AppendUint16(nameTable, 0x0409) // language + nameTable = binary.BigEndian.AppendUint16(nameTable, r.id) + nameTable = binary.BigEndian.AppendUint16(nameTable, r.length) + nameTable = binary.BigEndian.AppendUint16(nameTable, r.offset) + } + nameTable = append(nameTable, store...) + for len(nameTable)%4 != 0 { + nameTable = append(nameTable, 0) + } + + var checksum uint32 + for i := 0; i+3 < len(nameTable); i += 4 { + checksum += binary.BigEndian.Uint32(nameTable[i : i+4]) + } + + header := make([]byte, 12) + binary.BigEndian.PutUint32(header[0:4], 0x00010000) + binary.BigEndian.PutUint16(header[4:6], 1) // numTables + binary.BigEndian.PutUint16(header[6:8], 16) // searchRange + binary.BigEndian.PutUint16(header[8:10], 0) // entrySelector + binary.BigEndian.PutUint16(header[10:12], 0) + + tableDir := make([]byte, 16) + copy(tableDir[0:4], []byte("name")) + binary.BigEndian.PutUint32(tableDir[4:8], checksum) + binary.BigEndian.PutUint32(tableDir[8:12], 28) // offset + binary.BigEndian.PutUint32(tableDir[12:16], uint32(len(nameTable))) + + out := append([]byte{}, header...) + out = append(out, tableDir...) + out = append(out, nameTable...) + if len(out) < 1024 { + out = append(out, make([]byte, 1024-len(out))...) + } + return out +} diff --git a/main.go b/main.go index 09094f3..82039a7 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,19 @@ package main import ( + "errors" "fmt" "fontget/cmd" + "fontget/internal/shared" "os" ) func main() { if err := cmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + var displayed *shared.DisplayedError + if !errors.As(err, &displayed) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } os.Exit(1) } } From 9193bad739aa8651252957d90041ebd0fb85bb8c Mon Sep 17 00:00:00 2001 From: graphixa Date: Sat, 12 Sep 2026 16:25:48 +1000 Subject: [PATCH 03/23] Address review: recovery, cancel, and download classification. Make package rollback registration-aware, use unique install artifacts, join progress workers on every exit, bound external-process teardown, time-limit error-body drains, and keep rate-limits from advancing candidates. --- cmd/add.go | 32 +++- cmd/add_reliability_test.go | 35 +++- cmd/remove.go | 7 + internal/components/progress_bar.go | 45 +++-- internal/network/classify.go | 17 +- internal/network/classify_test.go | 15 ++ internal/network/download_fallbacks.go | 61 ++++-- internal/network/download_fallbacks_test.go | 24 +++ internal/network/exec.go | 67 +++++-- internal/network/exec_test.go | 55 +++++- internal/network/exec_unix.go | 15 +- internal/network/exec_windows.go | 19 +- internal/platform/darwin.go | 5 + internal/platform/linux.go | 5 + internal/platform/mutation.go | 200 ++++++++++++++++---- internal/platform/mutation_other.go | 13 ++ internal/platform/mutation_test.go | 71 ++++++- internal/platform/mutation_windows.go | 34 ++++ internal/platform/platform.go | 32 +++- internal/platform/windows.go | 83 ++++++-- internal/repo/archive.go | 26 ++- internal/repo/download_reliability_test.go | 28 +++ internal/repo/font.go | 12 +- 23 files changed, 753 insertions(+), 148 deletions(-) create mode 100644 internal/platform/mutation_other.go create mode 100644 internal/platform/mutation_windows.go diff --git a/cmd/add.go b/cmd/add.go index 5ddfd81..fae2c4c 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1165,6 +1165,14 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager var skippedFiles []string var failedFiles []string + destPaths := make([]string, 0, len(fontPaths)) + for _, fontPath := range fontPaths { + destPaths = append(destPaths, filepath.Join(fontDir, filepath.Base(fontPath))) + } + if collErr := platform.CheckDestinationCollisions(destPaths); collErr != nil { + return 0, 0, len(fontPaths), nil, []string{collErr.Error()}, 0, nil, collErr + } + batchOpts := &platform.InstallFontOptions{SkipPostInstallCacheRefresh: true} total := len(fontPaths) @@ -1395,7 +1403,7 @@ func installFont( ctx, allFontPaths, fontManager, installScope, fontDir, force, onProgress) if installErr != nil || failed > 0 { - rbErr := rollbackPackageMutations(ctx, fontID, string(installScope), mutations, installErr) + rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, installErr) res := buildInstallResult(InstallStatusFailed, "Installation failed", 0, skipped, failed, details, instErrs, downloadSize) if rbErr != nil { return res, rbErr @@ -1416,7 +1424,7 @@ func installFont( res := buildInstallResult(status, message, installed, skipped, failed, details, instErrs, downloadSize) if status == InstallStatusCompleted && installed > 0 { if testInstallFailPoint == "provenance" { - rbErr := rollbackPackageMutations(ctx, fontID, string(installScope), mutations, fmt.Errorf("injected provenance failure")) + rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, fmt.Errorf("injected provenance failure")) res.Status = InstallStatusFailed res.Success = 0 if rbErr != nil { @@ -1425,7 +1433,7 @@ func installFont( return res, fmt.Errorf("injected provenance failure") } if recErr := tryRecordInstallationRegistry(fontID, fontFiles, installScope, fontDir, res); recErr != nil { - rbErr := rollbackPackageMutations(ctx, fontID, string(installScope), mutations, recErr) + rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, recErr) res.Status = InstallStatusFailed res.Success = 0 if rbErr != nil { @@ -1440,10 +1448,9 @@ func installFont( return res, nil } -func rollbackPackageMutations(ctx context.Context, fontID, scope string, mutations []platform.FileMutation, cause error) error { +func rollbackPackageMutations(ctx context.Context, fontManager platform.FontManager, installScope platform.InstallationScope, fontID, scope string, mutations []platform.FileMutation, cause error) error { recCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) defer cancel() - _ = recCtx var outstanding []string var completed []string @@ -1454,6 +1461,11 @@ func rollbackPackageMutations(ctx context.Context, fontID, scope string, mutatio var backupPaths []string var affected []string for i := len(mutations) - 1; i >= 0; i-- { + if err := recCtx.Err(); err != nil { + outstanding = append(outstanding, "recovery timeout before finishing remaining mutations") + recErrs = append(recErrs, err.Error()) + break + } mut := mutations[i] affected = append(affected, mut.DestPath) if mut.BackupPath != "" { @@ -1469,9 +1481,19 @@ func rollbackPackageMutations(ctx context.Context, fontID, scope string, mutatio _ = os.Remove(mut.BackupPath) } } + + if fontManager != nil && len(mutations) > 0 { + if err := recCtx.Err(); err == nil { + if flushErr := fontManager.FlushFontCache(installScope); flushErr != nil { + output.GetDebug().Warning("Font cache refresh after rollback: %v", flushErr) + } + } + } + if len(outstanding) == 0 { return nil } + // Retain backups for outstanding recoveries. path, saveErr := installations.SaveRecoveryRecord(installations.RecoveryRecord{ PackageID: fontID, Scope: scope, diff --git a/cmd/add_reliability_test.go b/cmd/add_reliability_test.go index 70b4a6e..1068185 100644 --- a/cmd/add_reliability_test.go +++ b/cmd/add_reliability_test.go @@ -145,7 +145,8 @@ func TestDisplayedErrorSkipsDuplicate(t *testing.T) { } type copyFontManager struct { - dir string + dir string + registered map[string]bool } func (m *copyFontManager) FlushFontCache(scope platform.InstallationScope) error { return nil } @@ -153,10 +154,26 @@ func (m *copyFontManager) InstallFont(fontPath string, scope platform.Installati if opts == nil { opts = &platform.InstallFontOptions{} } - mut, err := platform.PlaceFontFile(fontPath, filepath.Join(m.dir, filepath.Base(fontPath)), force, opts) + if m.registered == nil { + m.registered = map[string]bool{} + } + dest := filepath.Join(m.dir, filepath.Base(fontPath)) + name := filepath.Base(fontPath) + mut, err := platform.PlaceFontFile(fontPath, dest, force, opts) if err != nil { return err } + mut.FontName = name + mut.Scope = scope + mut.ResourceRegistered = true + m.registered[name] = true + mut.UndoRegistration = func() error { + delete(m.registered, name) + return nil + } + if opts.Mutation != nil { + *opts.Mutation = mut + } if opts.FailPoint == platform.InstallFailRegister { _ = platform.RollbackMutation(mut) return errors.New("injected failure at register") @@ -204,7 +221,7 @@ func TestInstallRollbackAfterFirstMutation(t *testing.T) { if err == nil { t.Fatal("expected injected failure") } - if rb := rollbackPackageMutations(context.Background(), "test.pkg", "user", mutations, err); rb != nil { + if rb := rollbackPackageMutations(context.Background(), fm, platform.UserScope, "test.pkg", "user", mutations, err); rb != nil { t.Fatalf("rollback: %v", rb) } entries, _ := os.ReadDir(fontDir) @@ -214,6 +231,9 @@ func TestInstallRollbackAfterFirstMutation(t *testing.T) { } t.Fatalf("rolled-back dest must not keep created fonts, found %s", e.Name()) } + if len(fm.registered) != 0 { + t.Fatalf("registrations left after rollback: %#v", fm.registered) + } } func TestInstallRollbackRestoresReplacedBytes(t *testing.T) { @@ -277,7 +297,7 @@ func TestInstallRollbackAfterLaterMutation(t *testing.T) { if len(mutations) < 2 { t.Fatalf("need two mutations before later fail, got %d", len(mutations)) } - if rb := rollbackPackageMutations(context.Background(), "test.pkg", "user", mutations, err); rb != nil { + if rb := rollbackPackageMutations(context.Background(), fm, platform.UserScope, "test.pkg", "user", mutations, err); rb != nil { t.Fatalf("rollback: %v", rb) } entries, _ := os.ReadDir(fontDir) @@ -287,6 +307,9 @@ func TestInstallRollbackAfterLaterMutation(t *testing.T) { } t.Fatalf("rolled-back dest must not keep created fonts, found %s", e.Name()) } + if len(fm.registered) != 0 { + t.Fatalf("registrations left after rollback: %#v", fm.registered) + } } func TestInstallRegisterFailRollsBack(t *testing.T) { @@ -307,7 +330,7 @@ func TestInstallRegisterFailRollsBack(t *testing.T) { if err == nil { t.Fatal("expected register failure") } - if rb := rollbackPackageMutations(context.Background(), "test.pkg", "user", mutations, err); rb != nil { + if rb := rollbackPackageMutations(context.Background(), fm, platform.UserScope, "test.pkg", "user", mutations, err); rb != nil { t.Fatalf("rollback: %v", rb) } entries, _ := os.ReadDir(fontDir) @@ -374,7 +397,7 @@ func TestRollbackFailureWritesRecoveryRecord(t *testing.T) { t.Fatal(err) } mut := platform.FileMutation{DestPath: dest, BackupPath: backup, Replaced: true} - err := rollbackPackageMutations(context.Background(), "test.pkg", "user", []platform.FileMutation{mut}, errors.New("install failed")) + err := rollbackPackageMutations(context.Background(), nil, platform.UserScope, "test.pkg", "user", []platform.FileMutation{mut}, errors.New("install failed")) if err == nil || !errors.Is(err, shared.ErrRecoveryRequired) { t.Fatalf("want ErrRecoveryRequired, got %v", err) } diff --git a/cmd/remove.go b/cmd/remove.go index 5f505ac..904c048 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -407,6 +408,12 @@ type RemoveFontFilesParams struct { // removeFontFiles removes font files from system func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int, details []string, errors []string) { + unlock, lockErr := installations.LockDestination(context.Background(), params.FontDir) + if lockErr != nil { + return 0, 0, len(params.MatchingFonts), nil, []string{lockErr.Error()} + } + defer unlock() + batchOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} total := len(params.MatchingFonts) diff --git a/internal/components/progress_bar.go b/internal/components/progress_bar.go index c14be86..7dd96f4 100644 --- a/internal/components/progress_bar.go +++ b/internal/components/progress_bar.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "sync/atomic" "time" "fontget/internal/shared" @@ -164,25 +165,24 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch key { case "q", "ctrl+c", "esc", "enter", " ": if m.quitting { - // If already completed, any key quits - return m, tea.Quit - } else { - m.quitting = true - m.cancelled = true - m.err = shared.ErrOperationCancelled - if m.cancelChan != nil { - select { - case <-m.cancelChan: - default: - close(m.cancelChan) - } - } + // Already cancelling or finished: never Quit without joining the worker. return m, waitForOperationQuit(m.opDone) } + m.quitting = true + m.cancelled = true + m.err = shared.ErrOperationCancelled + if m.cancelChan != nil { + select { + case <-m.cancelChan: + default: + close(m.cancelChan) + } + } + return m, waitForOperationQuit(m.opDone) } - // If operation is complete, any key press should quit + // If operation is complete, any other key still joins then quits. if m.quitting { - return m, tea.Quit + return m, waitForOperationQuit(m.opDone) } return m, nil @@ -663,7 +663,9 @@ func runInteractiveProgressBar(title string, items []OperationItem, verboseMode model := NewProgressBar(title, items, verboseMode, debugMode) p := tea.NewProgram(model) model.program = p + var workStarted atomic.Bool model.operationFunc = func(program *tea.Program) error { + workStarted.Store(true) return operation(func(msg tea.Msg) { select { case <-model.cancelChan: @@ -675,17 +677,22 @@ func runInteractiveProgressBar(title string, items []OperationItem, verboseMode } finalModel, err := p.Run() - if err != nil { + joinWorker := func() { + if !workStarted.Load() || model.opDone == nil { + return + } select { case <-model.cancelChan: default: close(model.cancelChan) } - if model.opDone != nil { - <-model.opDone - } + <-model.opDone + } + if err != nil { + joinWorker() return err } + joinWorker() if m, ok := finalModel.(ProgressBarModel); ok { if m.cancelled { diff --git a/internal/network/classify.go b/internal/network/classify.go index ff0aeef..b14c80b 100644 --- a/internal/network/classify.go +++ b/internal/network/classify.go @@ -164,13 +164,24 @@ func isLocalIOError(err error) bool { return false } -// DrainAndCloseBody discards up to maxErrorBodyDrain bytes and closes the body. +// DrainAndCloseBody closes the body promptly. A short, bounded drain avoids holding a host +// slot on a stalled error body; on drain timeout the body is closed immediately. func DrainAndCloseBody(body io.ReadCloser) { if body == nil { return } - _, _ = io.Copy(io.Discard, io.LimitReader(body, maxErrorBodyDrain)) - _ = body.Close() + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = io.Copy(io.Discard, io.LimitReader(body, maxErrorBodyDrain)) + _ = body.Close() + }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = body.Close() + <-done + } } // ParseRetryAfter returns the wait duration from a Retry-After header. diff --git a/internal/network/classify_test.go b/internal/network/classify_test.go index ff8b5aa..5e58eb2 100644 --- a/internal/network/classify_test.go +++ b/internal/network/classify_test.go @@ -3,6 +3,7 @@ package network import ( "context" "errors" + "io" "net/http" "os" "strings" @@ -89,6 +90,20 @@ func TestRetryAfterWithinBudget(t *testing.T) { } } +func TestDrainAndCloseBodyBoundsWait(t *testing.T) { + pr, pw := io.Pipe() + go func() { + // Never write — drain must time out and close. + time.Sleep(5 * time.Second) + _ = pw.Close() + }() + start := time.Now() + DrainAndCloseBody(pr) + if time.Since(start) > 4*time.Second { + t.Fatalf("drain hung for %s", time.Since(start)) + } +} + func TestRedactDownloadURL(t *testing.T) { got := RedactDownloadURL("https://user:pass@example.com/f.ttf?token=abc&x=1") if strings.Contains(got, "pass") || strings.Contains(got, "abc") { diff --git a/internal/network/download_fallbacks.go b/internal/network/download_fallbacks.go index b65507a..bfc0906 100644 --- a/internal/network/download_fallbacks.go +++ b/internal/network/download_fallbacks.go @@ -226,7 +226,7 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do rep.Steps = append(rep.Steps, DownloadFallbackStep{Tool: "wget", Result: "skipped", Detail: "not found in PATH"}) } else { ok, term := tryTool("wget", wgetPath, func() (string, error) { - return "", runWget(ctx, runner, wgetPath, url, targetPath, opts) + return runWget(ctx, runner, wgetPath, url, targetPath, opts) }) if ok { return rep, nil @@ -244,7 +244,7 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do rep.Steps = append(rep.Steps, DownloadFallbackStep{Tool: "pwsh", Result: "skipped", Detail: "not found in PATH"}) } else { ok, term := tryTool("pwsh", pwshPath, func() (string, error) { - return "", runPowerShell(ctx, runner, pwshPath, url, targetPath, opts) + return runPowerShell(ctx, runner, pwshPath, url, targetPath, opts) }) if ok { return rep, nil @@ -262,7 +262,7 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do rep.Steps = append(rep.Steps, DownloadFallbackStep{Tool: "powershell", Result: "skipped", Detail: "not found in PATH"}) } else { ok, term := tryTool("powershell", psPath, func() (string, error) { - return "", runPowerShell(ctx, runner, psPath, url, targetPath, opts) + return runPowerShell(ctx, runner, psPath, url, targetPath, opts) }) if ok { return rep, nil @@ -400,10 +400,11 @@ func parseHTTPStatus(s string) (int, bool) { return code, true } -func runWget(ctx context.Context, runner CommandRunner, wgetPath, url, targetPath string, opts DownloadFallbackOptions) error { +func runWget(ctx context.Context, runner CommandRunner, wgetPath, url, targetPath string, opts DownloadFallbackOptions) (string, error) { args := []string{ "-q", "-O", targetPath, + "--server-response", } if opts.UserAgent != "" { args = append(args, "--user-agent", opts.UserAgent) @@ -414,16 +415,20 @@ func runWget(ctx context.Context, runner CommandRunner, wgetPath, url, targetPat args = append(args, url) out, err := runTool(ctx, runner, opts, wgetPath, args...) + status := scrapeHTTPStatus(out, err) if err != nil { if isCancelErr(err) { - return err + return status, err } - return fmt.Errorf("%s", normalizeToolError(out, err)) + if code, ok := parseHTTPStatus(status); ok { + return status, NewHTTPStatusError(code, url, 0) + } + return status, fmt.Errorf("%s", normalizeToolError(out, err)) } - return nil + return status, nil } -func runPowerShell(ctx context.Context, runner CommandRunner, psPath, url, targetPath string, opts DownloadFallbackOptions) error { +func runPowerShell(ctx context.Context, runner CommandRunner, psPath, url, targetPath string, opts DownloadFallbackOptions) (string, error) { ua := opts.UserAgent if ua == "" { ua = "Mozilla/5.0" @@ -444,18 +449,50 @@ func runPowerShell(ctx context.Context, runner CommandRunner, psPath, url, targe fmt.Sprintf("$u='%s'", esc(url)), fmt.Sprintf("$p='%s'", esc(targetPath)), fmt.Sprintf("$h=%s", headerLiteral), - fmt.Sprintf("Invoke-WebRequest -Uri $u -OutFile $p -Headers $h -UserAgent '%s' -ErrorAction Stop | Out-Null", esc(ua)), + "try {", + fmt.Sprintf(" Invoke-WebRequest -Uri $u -OutFile $p -Headers $h -UserAgent '%s' -ErrorAction Stop | Out-Null", esc(ua)), + " Write-Output 'FONTGET_HTTP_STATUS=200'", + "} catch {", + " $code = 0", + " if ($_.Exception.Response -ne $null) { $code = [int]$_.Exception.Response.StatusCode }", + " Write-Output ('FONTGET_HTTP_STATUS=' + $code)", + " throw", + "}", }, "; ") args := []string{"-NoProfile", "-NonInteractive", "-Command", script} out, err := runTool(ctx, runner, opts, psPath, args...) + status := scrapeHTTPStatus(out, err) if err != nil { if isCancelErr(err) { - return err + return status, err + } + if code, ok := parseHTTPStatus(status); ok && code != 0 { + return status, NewHTTPStatusError(code, url, 0) } - return fmt.Errorf("%s", normalizeToolError(out, err)) + return status, fmt.Errorf("%s", normalizeToolError(out, err)) } - return nil + return status, nil +} + +var httpStatusScrapers = []*regexp.Regexp{ + regexp.MustCompile(`(?i)FONTGET_HTTP_STATUS=(\d{3})`), + regexp.MustCompile(`(?i)HTTP[/\d.]*\s+(\d{3})\b`), + regexp.MustCompile(`(?i)\bERROR\s+(\d{3})\b`), + regexp.MustCompile(`(?i)\bstatus(?:\s+code)?[=:\s]+(\d{3})\b`), +} + +func scrapeHTTPStatus(out []byte, err error) string { + blob := string(out) + if err != nil { + blob += "\n" + err.Error() + } + for _, re := range httpStatusScrapers { + if m := re.FindStringSubmatch(blob); len(m) == 2 { + return m[1] + } + } + return "" } func sleepCtx(ctx context.Context, d time.Duration) error { diff --git a/internal/network/download_fallbacks_test.go b/internal/network/download_fallbacks_test.go index dc0cc69..e7df301 100644 --- a/internal/network/download_fallbacks_test.go +++ b/internal/network/download_fallbacks_test.go @@ -155,6 +155,30 @@ func TestDownloadWithFallbacks_Curl404DoesNotCallWget(t *testing.T) { } } +func TestDownloadWithFallbacks_Wget404Structured(t *testing.T) { + r := &fakeRunner{ + paths: map[string]string{ + "wget": "/usr/bin/wget", + "pwsh": "/usr/bin/pwsh", + }, + results: map[string]fakeResult{ + "/usr/bin/wget": {out: "HTTP/1.1 404 Not Found", err: errors.New("exit 8")}, + "/usr/bin/pwsh": {out: "", err: nil}, + }, + } + out := filepath.Join(t.TempDir(), "file.zip") + _, err := downloadWithFallbacks(r, "https://example.com/missing.zip", out, DownloadFallbackOptions{UserAgent: "ua"}) + if err == nil { + t.Fatal("expected 404 error") + } + if !errors.Is(err, ErrCandidateUnavailable) { + t.Fatalf("want ErrCandidateUnavailable, got %v", err) + } + if len(r.calls) != 1 || !strings.HasPrefix(r.calls[0], "/usr/bin/wget ") { + t.Fatalf("404 must not continue to pwsh, calls=%#v", r.calls) + } +} + func TestDownloadWithFallbacks_Curl429DoesNotCallWget(t *testing.T) { r := &fakeRunner{ paths: map[string]string{ diff --git a/internal/network/exec.go b/internal/network/exec.go index e389073..4ba93ae 100644 --- a/internal/network/exec.go +++ b/internal/network/exec.go @@ -21,6 +21,8 @@ const ( DefaultExternalTerminateWait = 5 * time.Second // DefaultMaxCapturedOutput bounds retained stdout/stderr from an external downloader. DefaultMaxCapturedOutput = 256 << 10 + // defaultWaitAfterKill bounds how long we wait for Wait() after forced termination. + defaultWaitAfterKill = 10 * time.Second ) // ExecOptions controls cancellable external process execution. @@ -64,7 +66,8 @@ func (execRunner) CombinedOutputContext(ctx context.Context, opts ExecOptions, n } // RunCancellable starts name with args (no shell), waits until completion, cancellation, or stall. -// On cancel it signals the process group/job, waits TerminateWait, then force-kills descendants. +// Cancellation is owned entirely here: we do not use exec.CommandContext so Go's default +// Process.Kill cannot race process-tree teardown that needs the parent PID. func RunCancellable(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) { if ctx == nil { ctx = context.Background() @@ -73,20 +76,53 @@ func RunCancellable(ctx context.Context, opts ExecOptions, name string, args ... return nil, err } - cmd := exec.CommandContext(ctx, name, args...) + cmd := exec.Command(name, args...) prepareProcessGroup(cmd) var out limitedBuffer out.max = opts.maxOutput() - cmd.Stdout = &out - cmd.Stderr = &out + + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + return nil, err + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + _ = stdoutR.Close() + _ = stdoutW.Close() + return nil, err + } + cmd.Stdout = stdoutW + cmd.Stderr = stderrW + + var copyWG sync.WaitGroup + copyWG.Add(2) + go func() { + defer copyWG.Done() + _, _ = io.Copy(&out, stdoutR) + _ = stdoutR.Close() + }() + go func() { + defer copyWG.Done() + _, _ = io.Copy(&out, stderrR) + _ = stderrR.Close() + }() if err := cmd.Start(); err != nil { + _ = stdoutW.Close() + _ = stderrW.Close() + copyWG.Wait() return out.Bytes(), err } + _ = stdoutW.Close() + _ = stderrW.Close() done := make(chan error, 1) - go func() { done <- cmd.Wait() }() + go func() { + waitErr := cmd.Wait() + copyWG.Wait() + done <- waitErr + }() stallCtx, stallCancel := context.WithCancel(ctx) defer stallCancel() @@ -119,7 +155,11 @@ func RunCancellable(ctx context.Context, opts ExecOptions, name string, args ... case <-done: case <-time.After(opts.terminateWait()): _ = killProcessTree(cmd) - <-done + select { + case <-done: + case <-time.After(defaultWaitAfterKill): + return out.Bytes(), fmt.Errorf("%w: process did not exit after kill", cause) + } } return out.Bytes(), cause } @@ -178,18 +218,3 @@ func (l *limitedBuffer) Bytes() []byte { defer l.mu.Unlock() return append([]byte(nil), l.buf.Bytes()...) } - -func closePipes(cmd *exec.Cmd) { - if cmd == nil || cmd.Process == nil { - return - } - if wc, ok := cmd.Stdout.(io.Closer); ok { - _ = wc.Close() - } - if wc, ok := cmd.Stderr.(io.Closer); ok { - _ = wc.Close() - } - if wc, ok := cmd.Stdin.(io.Closer); ok { - _ = wc.Close() - } -} diff --git a/internal/network/exec_test.go b/internal/network/exec_test.go index 1365e73..7d8e15a 100644 --- a/internal/network/exec_test.go +++ b/internal/network/exec_test.go @@ -2,9 +2,13 @@ package network import ( "context" + "fmt" + "os" "os/exec" "path/filepath" "runtime" + "strconv" + "strings" "testing" "time" ) @@ -44,17 +48,28 @@ func TestRunCancellable_InactivityStall(t *testing.T) { } func TestRunCancellable_KillsDescendants(t *testing.T) { - name, args := descendantSleepArgs() + marker := filepath.Join(t.TempDir(), "alive") + name, args := descendantMarkerArgs(marker) if _, err := exec.LookPath(name); err != nil { t.Skip(err) } + _ = os.Remove(marker) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { - _, err := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: 500 * time.Millisecond}, name, args...) + _, err := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: time.Second}, name, args...) errCh <- err }() - time.Sleep(200 * time.Millisecond) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(marker); err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal("child never created alive marker") + } cancel() select { case err := <-errCh: @@ -62,15 +77,23 @@ func TestRunCancellable_KillsDescendants(t *testing.T) { t.Fatal("expected cancellation error") } case <-time.After(8 * time.Second): - t.Fatal("descendant process tree did not stop after cancel") + t.Fatal("runner did not return after cancel") + } + // Child should stop updating the marker; remove and ensure it is not recreated. + _ = os.Remove(marker) + time.Sleep(800 * time.Millisecond) + if _, err := os.Stat(marker); err == nil { + t.Fatal("descendant still alive after cancel (marker recreated)") } } -func descendantSleepArgs() (string, []string) { +func descendantMarkerArgs(marker string) (string, []string) { if runtime.GOOS == "windows" { - return "cmd", []string{"/c", "timeout", "/t", "30", "/nobreak"} + script := fmt.Sprintf(`while ($true) { Set-Content -LiteralPath '%s' -Value 'alive' -Encoding ascii; Start-Sleep -Seconds 1 }`, marker) + return "powershell", []string{"-NoProfile", "-NonInteractive", "-Command", script} } - return "sh", []string{"-c", "sleep 30"} + script := `touch "` + marker + `"; while true; do touch "` + marker + `"; sleep 1; done` + return "sh", []string{"-c", script} } func longSleepArgs() (string, []string) { @@ -114,3 +137,21 @@ func TestRunCancellable_ReleasedOnCancel(t *testing.T) { t.Fatal("process did not stop after cancel") } } + +func TestScrapeHTTPStatus(t *testing.T) { + if got := scrapeHTTPStatus([]byte("FONTGET_HTTP_STATUS=410"), nil); got != "410" { + t.Fatalf("got %q", got) + } + if got := scrapeHTTPStatus([]byte("HTTP/1.1 404 Not Found"), nil); got != "404" { + t.Fatalf("got %q", got) + } + if got := scrapeHTTPStatus(nil, errWith("ERROR 429: Too Many Requests")); got != "429" { + t.Fatalf("got %q", got) + } + _ = strconv.Itoa(1) + _ = strings.TrimSpace("x") +} + +type errWith string + +func (e errWith) Error() string { return string(e) } diff --git a/internal/network/exec_unix.go b/internal/network/exec_unix.go index 603f2b9..1f3c4a8 100644 --- a/internal/network/exec_unix.go +++ b/internal/network/exec_unix.go @@ -16,14 +16,19 @@ func prepareProcessGroup(cmd *exec.Cmd) { } func terminateProcessTree(cmd *exec.Cmd, wait time.Duration) error { - _ = wait if cmd == nil || cmd.Process == nil { return nil } pgid := cmd.Process.Pid _ = syscall.Kill(-pgid, syscall.SIGTERM) - closePipes(cmd) - return nil + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return killProcessTree(cmd) } func killProcessTree(cmd *exec.Cmd) error { @@ -31,7 +36,5 @@ func killProcessTree(cmd *exec.Cmd) error { return nil } pgid := cmd.Process.Pid - _ = syscall.Kill(-pgid, syscall.SIGKILL) - closePipes(cmd) - return nil + return syscall.Kill(-pgid, syscall.SIGKILL) } diff --git a/internal/network/exec_windows.go b/internal/network/exec_windows.go index 1631ff8..afcf68d 100644 --- a/internal/network/exec_windows.go +++ b/internal/network/exec_windows.go @@ -3,6 +3,7 @@ package network import ( + "context" "os/exec" "strconv" "syscall" @@ -18,13 +19,23 @@ func prepareProcessGroup(cmd *exec.Cmd) { cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP } +func runTaskkill(force bool, pid string, timeout time.Duration) error { + args := []string{"/T", "/PID", pid} + if force { + args = append([]string{"/F"}, args...) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "taskkill", args...) + return cmd.Run() +} + func terminateProcessTree(cmd *exec.Cmd, wait time.Duration) error { if cmd == nil || cmd.Process == nil { return nil } pid := strconv.Itoa(cmd.Process.Pid) - _ = exec.Command("taskkill", "/T", "/PID", pid).Run() - closePipes(cmd) + _ = runTaskkill(false, pid, wait) deadline := time.Now().Add(wait) for time.Now().Before(deadline) { if cmd.ProcessState != nil && cmd.ProcessState.Exited() { @@ -40,7 +51,5 @@ func killProcessTree(cmd *exec.Cmd) error { return nil } pid := strconv.Itoa(cmd.Process.Pid) - err := exec.Command("taskkill", "/F", "/T", "/PID", pid).Run() - closePipes(cmd) - return err + return runTaskkill(true, pid, DefaultExternalTerminateWait) } diff --git a/internal/platform/darwin.go b/internal/platform/darwin.go index c59a8f4..f6b2025 100644 --- a/internal/platform/darwin.go +++ b/internal/platform/darwin.go @@ -82,6 +82,11 @@ func (m *darwinFontManager) InstallFont(fontPath string, scope InstallationScope if err != nil { return err } + mut.FontName = fontName + mut.Scope = scope + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut + } if opts != nil && opts.FailPoint == InstallFailRegister { _ = RollbackMutation(mut) return failPointError(InstallFailRegister) diff --git a/internal/platform/linux.go b/internal/platform/linux.go index c420503..091e139 100644 --- a/internal/platform/linux.go +++ b/internal/platform/linux.go @@ -81,6 +81,11 @@ func (m *linuxFontManager) InstallFont(fontPath string, scope InstallationScope, if err != nil { return err } + mut.FontName = fontName + mut.Scope = scope + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut + } if opts != nil && opts.FailPoint == InstallFailRegister { _ = RollbackMutation(mut) return failPointError(InstallFailRegister) diff --git a/internal/platform/mutation.go b/internal/platform/mutation.go index 5372c73..29ce566 100644 --- a/internal/platform/mutation.go +++ b/internal/platform/mutation.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" ) func PlaceFontFile(src, dest string, force bool, opts *InstallFontOptions) (FileMutation, error) { @@ -14,8 +15,41 @@ func failPointError(point InstallFailPoint) error { return fmt.Errorf("injected failure at %s", point) } -// placeFontFile copies src to dest, using a same-volume backup and staged file when replacing. -// It never uses an unconditional Remove(old) followed by an unprotected copy as the only strategy. +func exportMutation(opts *InstallFontOptions, mut FileMutation) { + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut + } +} + +// uniqueArtifact returns an operation-owned path next to dest that will not collide with +// fixed legacy suffixes or another concurrent attempt's retained recovery backup. +func uniqueArtifact(dest, kind string) string { + return fmt.Sprintf("%s.fontget-%s-%d-%d", dest, kind, os.Getpid(), time.Now().UnixNano()) +} + +func trackArtifact(mut *FileMutation, path string) { + if path == "" { + return + } + mut.ArtifactPaths = append(mut.ArtifactPaths, path) +} + +func removeTracked(mut *FileMutation, path string) { + if path == "" { + return + } + _ = os.Remove(path) + out := mut.ArtifactPaths[:0] + for _, p := range mut.ArtifactPaths { + if p != path { + out = append(out, p) + } + } + mut.ArtifactPaths = out +} + +// placeFontFile copies src to dest, using unique same-volume staging and backups when replacing. +// New files are staged then renamed so a failed mid-copy cannot leave an untracked partial dest. func placeFontFile(src, dest string, force bool, opts *InstallFontOptions) (FileMutation, error) { var mut FileMutation mut.DestPath = dest @@ -25,96 +59,188 @@ func placeFontFile(src, dest string, force bool, opts *InstallFontOptions) (File return mut, fmt.Errorf("font already installed: %s", filepath.Base(dest)) } if err := replaceExistingFontFile(src, dest, &mut, opts); err != nil { - if opts != nil && opts.Mutation != nil { - *opts.Mutation = mut - } + exportMutation(opts, mut) return mut, err } } else if !os.IsNotExist(err) { return mut, fmt.Errorf("stat destination: %w", err) } else { - if opts != nil && opts.FailPoint == InstallFailCopy { - return mut, failPointError(InstallFailCopy) + if err := createNewFontFile(src, dest, &mut, opts); err != nil { + exportMutation(opts, mut) + return mut, err } - if err := copyFile(src, dest); err != nil { - return mut, fmt.Errorf("copy font file: %w", err) + } + + exportMutation(opts, mut) + return mut, nil +} + +func createNewFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { + if opts != nil && opts.FailPoint == InstallFailCopy { + return failPointError(InstallFailCopy) + } + + staged := uniqueArtifact(dest, "new") + trackArtifact(mut, staged) + if err := copyFile(src, staged); err != nil { + removeTracked(mut, staged) + return fmt.Errorf("stage new font: %w", err) + } + if opts != nil && opts.FailPoint == InstallFailCopyAfterWrite { + // Staged bytes exist; leave DestPath unset as Created. Caller still gets mutation via export + // so staged artifact is cleaned. Simulate "partial dest" by also creating dest then failing. + partial := dest + if copyErr := copyFile(staged, partial); copyErr == nil { + mut.Created = true + mut.DestPath = dest } - mut.Created = true + return failPointError(InstallFailCopyAfterWrite) } - if opts != nil && opts.Mutation != nil { - *opts.Mutation = mut + if err := os.Rename(staged, dest); err != nil { + // Cross-volume or busy: fall back to copy into dest, tracking ownership immediately. + mut.Created = true + if copyErr := copyFile(staged, dest); copyErr != nil { + _ = os.Remove(dest) + mut.Created = false + removeTracked(mut, staged) + return fmt.Errorf("install new font: %w", copyErr) + } + removeTracked(mut, staged) + return nil } - return mut, nil + removeTracked(mut, staged) + mut.Created = true + return nil } func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { - backup := dest + ".fontget-bak" if opts != nil && opts.FailPoint == InstallFailBackup { return failPointError(InstallFailBackup) } + + backup := uniqueArtifact(dest, "bak") if err := copyFile(dest, backup); err != nil { return fmt.Errorf("backup existing font: %w", err) } mut.BackupPath = backup mut.Replaced = true + trackArtifact(mut, backup) if opts != nil && opts.FailPoint == InstallFailCopy { return failPointError(InstallFailCopy) } - staged := dest + ".fontget-new" + staged := uniqueArtifact(dest, "new") + trackArtifact(mut, staged) if err := copyFile(src, staged); err != nil { - _ = os.Remove(backup) - mut.BackupPath = "" - mut.Replaced = false + removeTracked(mut, staged) return fmt.Errorf("stage replacement: %w", err) } + if opts != nil && opts.FailPoint == InstallFailCopyAfterWrite { + return failPointError(InstallFailCopyAfterWrite) + } if opts != nil && opts.FailPoint == InstallFailReplace { - _ = os.Remove(staged) + removeTracked(mut, staged) return failPointError(InstallFailReplace) } - oldMoved := dest + ".fontget-old" - _ = os.Remove(oldMoved) + oldMoved := uniqueArtifact(dest, "old") + trackArtifact(mut, oldMoved) if err := os.Rename(dest, oldMoved); err != nil { - _ = os.Remove(staged) + removeTracked(mut, staged) + removeTracked(mut, oldMoved) return fmt.Errorf("cannot replace locked or busy font: %w", err) } if err := os.Rename(staged, dest); err != nil { _ = os.Rename(oldMoved, dest) - _ = os.Remove(staged) + removeTracked(mut, staged) + removeTracked(mut, oldMoved) return fmt.Errorf("install replacement: %w", err) } - _ = os.Remove(oldMoved) + removeTracked(mut, staged) + removeTracked(mut, oldMoved) return nil } -// CommitMutation deletes disposable backups after a successful package commit. +// CommitMutation deletes disposable backups/artifacts after a successful package commit. func CommitMutation(mut FileMutation) error { - if mut.BackupPath == "" { - return nil + var first error + paths := append([]string{}, mut.ArtifactPaths...) + if mut.BackupPath != "" { + paths = append(paths, mut.BackupPath) } - if err := os.Remove(mut.BackupPath); err != nil && !os.IsNotExist(err) { - return err + seen := map[string]struct{}{} + for _, p := range paths { + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + if err := os.Remove(p); err != nil && !os.IsNotExist(err) && first == nil { + first = err + } } - return nil + return first } -// RollbackMutation undoes a single file mutation. Pre-existing files are restored from backup; -// files created by this operation are removed. Untouched fonts are not deleted. +// RollbackMutation undoes file and platform registration changes for one mutation. +// Pre-existing fonts are restored from backup; files created by this operation are removed. func RollbackMutation(mut FileMutation) error { + if mut.UndoRegistration != nil { + if err := mut.UndoRegistration(); err != nil { + return err + } + } else if err := undoFontRegistration(mut); err != nil { + return err + } + + var fileErr error if mut.Replaced && mut.BackupPath != "" { if err := copyFile(mut.BackupPath, mut.DestPath); err != nil { - return fmt.Errorf("restore backup %s: %w", mut.BackupPath, err) + fileErr = fmt.Errorf("restore backup %s: %w", mut.BackupPath, err) } - return nil - } - if mut.Created && mut.DestPath != "" { + } else if mut.Created && mut.DestPath != "" { if err := os.Remove(mut.DestPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove created file %s: %w", mut.DestPath, err) + fileErr = fmt.Errorf("remove created file %s: %w", mut.DestPath, err) + } + } + + if mut.RestoreRegistration != nil { + if restoreErr := mut.RestoreRegistration(); restoreErr != nil && fileErr == nil { + fileErr = restoreErr + } + } else if restoreErr := restorePriorFontRegistration(mut); restoreErr != nil && fileErr == nil { + fileErr = restoreErr + } + + // Do not delete BackupPath here: incomplete recovery must retain it. CommitMutation cleans success. + for _, p := range mut.ArtifactPaths { + if p == "" || p == mut.BackupPath { + continue + } + _ = os.Remove(p) + } + return fileErr +} + +// CheckDestinationCollisions returns an error if two paths resolve to the same destination basename. +func CheckDestinationCollisions(destPaths []string) error { + seen := make(map[string]string, len(destPaths)) + for _, p := range destPaths { + key := filepath.Clean(p) + base := filepath.Base(key) + if prev, ok := seen[base]; ok && prev != key { + return fmt.Errorf("destination collision: %s and %s both install as %s", prev, key, base) + } + if prev, ok := seen[key]; ok { + return fmt.Errorf("destination collision: duplicate path %s", prev) } + seen[base] = key + seen[key] = key } return nil } diff --git a/internal/platform/mutation_other.go b/internal/platform/mutation_other.go new file mode 100644 index 0000000..fba143d --- /dev/null +++ b/internal/platform/mutation_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package platform + +func undoFontRegistration(mut FileMutation) error { + _ = mut + return nil +} + +func restorePriorFontRegistration(mut FileMutation) error { + _ = mut + return nil +} diff --git a/internal/platform/mutation_test.go b/internal/platform/mutation_test.go index 3714c91..600249c 100644 --- a/internal/platform/mutation_test.go +++ b/internal/platform/mutation_test.go @@ -3,6 +3,7 @@ package platform import ( "os" "path/filepath" + "strings" "testing" ) @@ -37,8 +38,7 @@ func TestPlaceFontFileForceRestoresOnReplaceFail(t *testing.T) { t.Fatal(err) } var mut FileMutation - err := error(nil) - _, err = placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut, FailPoint: InstallFailReplace}) + _, err := placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut, FailPoint: InstallFailReplace}) if err == nil { t.Fatal("expected injected replace failure") } @@ -53,6 +53,73 @@ func TestPlaceFontFileForceRestoresOnReplaceFail(t *testing.T) { if string(bak) != "old-bytes-here" { t.Fatalf("backup = %q", bak) } + if !strings.Contains(filepath.Base(mut.BackupPath), ".fontget-bak-") { + t.Fatalf("backup should use unique suffix, got %s", mut.BackupPath) + } +} + +func TestPlaceFontFileCopyAfterWriteTracksPartial(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + if err := os.WriteFile(src, []byte("partial-new-bytes"), 0644); err != nil { + t.Fatal(err) + } + var mut FileMutation + _, err := placeFontFile(src, dst, false, &InstallFontOptions{Mutation: &mut, FailPoint: InstallFailCopyAfterWrite}) + if err == nil { + t.Fatal("expected copy-after-write failure") + } + if !mut.Created || mut.DestPath != dst { + t.Fatalf("partial dest must be tracked: %+v", mut) + } + if err := RollbackMutation(mut); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Fatal("partial dest must be removed by rollback") + } +} + +func TestUniqueBackupSurvivesSecondAttempt(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + _ = os.WriteFile(src, []byte("new-bytes-here"), 0644) + _ = os.WriteFile(dst, []byte("old-bytes-here"), 0644) + + var mut1 FileMutation + _, err := placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut1, FailPoint: InstallFailReplace}) + if err == nil { + t.Fatal("expected first failure") + } + firstBak := mut1.BackupPath + firstBytes, _ := os.ReadFile(firstBak) + + var mut2 FileMutation + _, err = placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut2, FailPoint: InstallFailReplace}) + if err == nil { + t.Fatal("expected second failure") + } + if mut2.BackupPath == firstBak { + t.Fatal("second attempt must not reuse first backup path") + } + got, err := os.ReadFile(firstBak) + if err != nil { + t.Fatalf("first recovery backup destroyed: %v", err) + } + if string(got) != string(firstBytes) { + t.Fatal("first backup contents changed") + } +} + +func TestCheckDestinationCollisions(t *testing.T) { + if err := CheckDestinationCollisions([]string{`C:\a\Foo.ttf`, `C:\b\Foo.ttf`}); err == nil { + t.Fatal("expected collision") + } + if err := CheckDestinationCollisions([]string{`C:\a\Foo.ttf`, `C:\a\Bar.ttf`}); err != nil { + t.Fatal(err) + } } func TestRollbackAndCommitMutation(t *testing.T) { diff --git a/internal/platform/mutation_windows.go b/internal/platform/mutation_windows.go new file mode 100644 index 0000000..9bb11a1 --- /dev/null +++ b/internal/platform/mutation_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package platform + +import "fmt" + +func undoFontRegistration(mut FileMutation) error { + if mut.RegistryAdded && mut.FontName != "" { + fm, err := NewFontManager() + if err == nil { + if wm, ok := fm.(*windowsFontManager); ok { + if rerr := wm.removeFontFromRegistry(mut.FontName); rerr != nil { + return fmt.Errorf("remove registry entry %s: %w", mut.FontName, rerr) + } + } + } + } + if mut.ResourceRegistered && mut.DestPath != "" { + if err := RemoveFontResource(mut.DestPath); err != nil { + return fmt.Errorf("unregister font resource %s: %w", mut.DestPath, err) + } + } + return nil +} + +func restorePriorFontRegistration(mut FileMutation) error { + if !mut.PriorResourceRemoved || mut.DestPath == "" { + return nil + } + if err := AddFontResource(mut.DestPath); err != nil { + return fmt.Errorf("restore prior font resource %s: %w", mut.DestPath, err) + } + return nil +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 1f1f162..36339fe 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -31,19 +31,39 @@ const ( type InstallFailPoint string const ( - InstallFailNone InstallFailPoint = "" - InstallFailBackup InstallFailPoint = "backup" - InstallFailCopy InstallFailPoint = "copy" - InstallFailReplace InstallFailPoint = "replace" - InstallFailRegister InstallFailPoint = "register" + InstallFailNone InstallFailPoint = "" + InstallFailBackup InstallFailPoint = "backup" + InstallFailCopy InstallFailPoint = "copy" + InstallFailCopyAfterWrite InstallFailPoint = "copy-after-write" + InstallFailReplace InstallFailPoint = "replace" + InstallFailRegister InstallFailPoint = "register" ) -// FileMutation records destination changes so a package can roll them back or commit backups. +// FileMutation records destination and registration changes so a package can roll them back. type FileMutation struct { DestPath string BackupPath string Created bool Replaced bool + + FontName string + Scope InstallationScope + + // ResourceRegistered is true after a successful AddFontResource for DestPath. + ResourceRegistered bool + // PriorResourceRemoved is true when an existing registration was cleared before replacement. + PriorResourceRemoved bool + // RegistryAdded is true after a successful machine-scope registry write for FontName. + RegistryAdded bool + + // ArtifactPaths are unique disposable files (backups/stages) owned by this mutation. + ArtifactPaths []string + + // UndoRegistration, when set, undoes registrations for this mutation (tests and platforms). + // When nil, platform undoFontRegistration is used. + UndoRegistration func() error + // RestoreRegistration restores prior registration after file restore. Nil uses platform helper. + RestoreRegistration func() error } // InstallFontOptions configures InstallFont. A nil opts value keeps legacy behavior (run post-install cache/notify after each InstallFont). diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 79ae3c6..1d9332e 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -91,7 +91,11 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop existed = true if force { logger.Debug("Unregistering existing font before safe replacement...") - _ = RemoveFontResource(targetPath) + if rerr := RemoveFontResource(targetPath); rerr == nil { + if opts != nil && opts.Mutation != nil { + opts.Mutation.PriorResourceRemoved = true + } + } } } @@ -104,12 +108,17 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop } return err } + mut.FontName = fontName + mut.Scope = scope + if existed && force { + mut.PriorResourceRemoved = true + } + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut + } if opts != nil && opts.FailPoint == InstallFailRegister { _ = RollbackMutation(mut) - if existed { - _ = AddFontResource(targetPath) - } return failPointError(InstallFailRegister) } @@ -119,11 +128,15 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop if rbErr := RollbackMutation(mut); rbErr != nil { logger.Error("Rollback after register failure: %v", rbErr) } - if existed { - _ = AddFontResource(targetPath) - } return fmt.Errorf("failed to add font resource: %w", err) } + mut.ResourceRegistered = true + if opts != nil && opts.Mutation != nil { + opts.Mutation.ResourceRegistered = true + opts.Mutation.FontName = fontName + opts.Mutation.Scope = scope + opts.Mutation.PriorResourceRemoved = mut.PriorResourceRemoved + } logger.Debug("Font resource added successfully") // Add font to registry if machine scope @@ -132,33 +145,71 @@ func (m *windowsFontManager) InstallFont(fontPath string, scope InstallationScop if err := m.addFontToRegistry(fontName, targetPath); err != nil { logger.Error("Failed to add font to registry: %v", err) RemoveFontResource(targetPath) + mut.ResourceRegistered = false if rbErr := RollbackMutation(mut); rbErr != nil { logger.Error("Rollback after registry failure: %v", rbErr) } - if existed { - _ = AddFontResource(targetPath) - } return fmt.Errorf("failed to add font to registry: %w", err) } + mut.RegistryAdded = true + if opts != nil && opts.Mutation != nil { + opts.Mutation.RegistryAdded = true + opts.Mutation.ResourceRegistered = true + } logger.Debug("Font added to registry successfully") } + destPath := targetPath + fname := fontName + priorRemoved := mut.PriorResourceRemoved + registryAdded := mut.RegistryAdded + resourceRegistered := mut.ResourceRegistered + mut.UndoRegistration = func() error { + if registryAdded { + _ = m.removeFontFromRegistry(fname) + } + if resourceRegistered { + _ = RemoveFontResource(destPath) + } + return nil + } + mut.RestoreRegistration = func() error { + if priorRemoved { + return AddFontResource(destPath) + } + return nil + } + if opts != nil && opts.Mutation != nil { + opts.Mutation.UndoRegistration = mut.UndoRegistration + opts.Mutation.RestoreRegistration = mut.RestoreRegistration + opts.Mutation.RegistryAdded = registryAdded + opts.Mutation.ResourceRegistered = resourceRegistered + opts.Mutation.PriorResourceRemoved = priorRemoved + opts.Mutation.FontName = fname + opts.Mutation.Scope = scope + } + skipNotify := opts != nil && opts.SkipPostInstallCacheRefresh if !skipNotify { // Notify other applications about the new font logger.Debug("Notifying system about font change...") if err := NotifyFontChange(); err != nil { logger.Error("Failed to notify font change: %v", err) - RemoveFontResource(targetPath) - if scope == MachineScope { - m.removeFontFromRegistry(fontName) + if scope == MachineScope && mut.RegistryAdded { + _ = m.removeFontFromRegistry(fontName) + mut.RegistryAdded = false + } + if mut.ResourceRegistered { + _ = RemoveFontResource(targetPath) + mut.ResourceRegistered = false + } + if opts != nil && opts.Mutation != nil { + opts.Mutation.ResourceRegistered = false + opts.Mutation.RegistryAdded = false } if rbErr := RollbackMutation(mut); rbErr != nil { logger.Error("Rollback after notify failure: %v", rbErr) } - if existed { - _ = AddFontResource(targetPath) - } return fmt.Errorf("failed to notify font change: %w", err) } logger.Debug("Font change notification sent successfully") diff --git a/internal/repo/archive.go b/internal/repo/archive.go index aba8f54..18e3317 100644 --- a/internal/repo/archive.go +++ b/internal/repo/archive.go @@ -3,6 +3,7 @@ package repo import ( "archive/tar" "archive/zip" + "context" "fmt" "io" "io/fs" @@ -101,6 +102,9 @@ func ExtractArchive(archivePath, destDir string) ([]string, error) { // ExtractOptions configures ExtractArchiveWithOptions. type ExtractOptions struct { + // Context cancels extraction between archive members. Nil uses Background. + Context context.Context + // OnFontFileExtracted is called after each font file is extracted. // total is the number of font files that will be extracted when known, otherwise -1. OnFontFileExtracted func(done int, total int) @@ -113,6 +117,13 @@ type ExtractOptions struct { Selection *ArchiveSelectionContext } +func extractContext(opts *ExtractOptions) context.Context { + if opts != nil && opts.Context != nil { + return opts.Context + } + return context.Background() +} + // ExtractArchiveWithOptions extracts an archive file to the specified directory, with optional progress callbacks. func ExtractArchiveWithOptions(archivePath, destDir string, opts *ExtractOptions) ([]string, error) { archiveType := DetectArchiveType(archivePath) @@ -213,6 +224,9 @@ func extractZIP(archivePath, destDir string, opts *ExtractOptions) ([]string, er done := 0 for _, file := range reader.File { + if err := extractContext(opts).Err(); err != nil { + return extractedFiles, err + } if file.FileInfo().IsDir() || strings.HasSuffix(file.Name, "/") { continue } @@ -332,6 +346,10 @@ func extractCompressedTARPackageMode( } for { + if err := extractContext(opts).Err(); err != nil { + cleanupWritten() + return nil, err + } header, err := tr.Next() if err == io.EOF { break @@ -465,6 +483,9 @@ func extractSelectedCompressedTAR( done := 0 for { + if err := extractContext(opts).Err(); err != nil { + return extractedFiles, err + } header, err := tr.Next() if err == io.EOF { break @@ -536,7 +557,7 @@ func extract7Z(archivePath, destDir string, opts *ExtractOptions) ([]string, err } defer os.RemoveAll(tmp) - cmd := exec.Command(tool, "x", "-y", "-o"+tmp, archivePath) + cmd := exec.CommandContext(extractContext(opts), tool, "x", "-y", "-o"+tmp, archivePath) out, runErr := cmd.CombinedOutput() if runErr != nil { return nil, fmt.Errorf("7z extraction failed: %w (%s)", runErr, strings.TrimSpace(string(out))) @@ -553,6 +574,9 @@ func extract7Z(archivePath, destDir string, opts *ExtractOptions) ([]string, err seenDest := make(map[string]string) walkErr := filepath.WalkDir(tmp, func(p string, d fs.DirEntry, walkErr error) error { + if err := extractContext(opts).Err(); err != nil { + return err + } if walkErr != nil { return walkErr } diff --git a/internal/repo/download_reliability_test.go b/internal/repo/download_reliability_test.go index 18abde2..1fb93cd 100644 --- a/internal/repo/download_reliability_test.go +++ b/internal/repo/download_reliability_test.go @@ -159,6 +159,34 @@ func TestCompleteDownloadedFile(t *testing.T) { } } +func TestDownloadAndExtractFont_RateLimitDoesNotAdvanceCandidate(t *testing.T) { + old := network.MaxRetryAfterWait + network.MaxRetryAfterWait = time.Millisecond + t.Cleanup(func() { network.MaxRetryAfterWait = old }) + + hits := map[string]int{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits[r.URL.Path]++ + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + })) + t.Cleanup(srv.Close) + + font := &FontFile{ + Name: "Rate", + Variant: "Regular", + DownloadURL: srv.URL + "/a.zip", + DownloadCandidates: []string{srv.URL + "/a.zip", srv.URL + "/b.zip"}, + } + _, err := DownloadAndExtractFont(font, t.TempDir(), nil) + if err == nil || !errors.Is(err, network.ErrRateLimited) { + t.Fatalf("want rate limited, got %v", err) + } + if hits["/b.zip"] != 0 { + t.Fatalf("must not advance to next candidate on rate limit, hits=%v", hits) + } +} + func TestDownloadFont_RetryAfterExceedsBudget(t *testing.T) { old := network.MaxRetryAfterWait network.MaxRetryAfterWait = time.Millisecond diff --git a/internal/repo/font.go b/internal/repo/font.go index c1ce83d..1456b80 100644 --- a/internal/repo/font.go +++ b/internal/repo/font.go @@ -183,6 +183,13 @@ type DownloadFontOptions struct { Context context.Context } +func downloadOptsContext(opts *DownloadFontOptions) context.Context { + if opts != nil && opts.Context != nil { + return opts.Context + } + return context.Background() +} + // DownloadFont downloads a font file and verifies its SHA-256 hash if available. func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) (string, error) { start := time.Now() @@ -632,9 +639,9 @@ func DownloadAndExtractFont(font *FontFile, targetDir string, opts *DownloadFont } action := network.ClassifyDownloadError(err) switch action { - case network.ActionFailPackage, network.ActionFailLocal: + case network.ActionFailPackage, network.ActionFailLocal, network.ActionRateLimit: return nil, err - case network.ActionAdvanceCandidate, network.ActionFailCandidate, network.ActionRetrySame, network.ActionRateLimit, network.ActionExternalFallback: + case network.ActionAdvanceCandidate, network.ActionFailCandidate, network.ActionRetrySame, network.ActionExternalFallback: if i+1 < len(candidates) { output.GetDebug().State("DownloadAndExtractFont: trying next format candidate") } @@ -759,6 +766,7 @@ func attemptDownloadAndExtract(font *FontFile, targetDir string, opts *DownloadF } extractedFiles, err := ExtractArchiveWithOptions(downloadedPath, extractDir, &ExtractOptions{ + Context: downloadOptsContext(opts), OnFontFileExtracted: func(done int, total int) { if opts != nil && opts.OnExtractProgress != nil { opts.OnExtractProgress(done, total) From a2a58058f03c5751b7d7cd2e662a2dfe6b4c5b61 Mon Sep 17 00:00:00 2001 From: graphixa Date: Sat, 12 Sep 2026 19:17:07 +1000 Subject: [PATCH 04/23] Remove unused add debug helpers after unified progress path. staticcheck U1000 failed CI on logInstallResultDetails, updateInstallStatus, and installFontsInDebugMode. --- cmd/add.go | 120 +---------------------------------------------------- 1 file changed, 1 insertion(+), 119 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index fae2c4c..26fbef5 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,4 +1,4 @@ -package cmd +package cmd import ( "context" @@ -905,124 +905,6 @@ func processInstallResult(result *InstallResult) (installedFiles, skippedFiles, return installedFiles, skippedFiles, failedFiles } -// logInstallResultDetails logs detailed variant information in debug mode -func logInstallResultDetails(result *InstallResult, fontName, scopeLabel string) { - if result == nil { - return - } - - installedFiles, skippedFiles, failedFiles := processInstallResult(result) - - if len(installedFiles) > 0 { - output.GetDebug().State("Installed variants:") - for _, file := range installedFiles { - output.GetDebug().State(" - %s", file) - } - } - if len(skippedFiles) > 0 { - output.GetDebug().State("Skipped variants:") - for _, file := range skippedFiles { - output.GetDebug().State(" - %s", file) - } - } - if len(failedFiles) > 0 { - output.GetDebug().State("Failed variants:") - for _, file := range failedFiles { - output.GetDebug().State(" - %s", file) - } - } - - output.GetDebug().State("Font %s in %s completed: %s - %s (Installed: %d, Skipped: %d, Failed: %d)", - fontName, scopeLabel, result.Status, result.Message, result.Success, result.Skipped, result.Failed) -} - -// updateInstallStatus updates installation status from result -func updateInstallStatus(status *InstallationStatus, result *InstallResult) { - if result == nil { - return - } - status.Installed += result.Success - status.Skipped += result.Skipped - status.Failed += result.Failed - status.Errors = append(status.Errors, result.Errors...) -} - -// installFontsInDebugMode processes fonts with plain text output (no TUI) for easier parsing/logging. -// -// This function is used when --debug flag is enabled. It bypasses the TUI progress bar and uses -// plain text output instead, making it easier to parse logs and debug issues. -// -// It processes each font in fontsToInstall, calls installFont for each, and updates the status -// tracking structure. All output is sent to debug logger for detailed diagnostic information. -func installFontsInDebugMode(fontManager platform.FontManager, fontsToInstall []FontToInstall, installScope platform.InstallationScope, force bool, fontDir string, status *InstallationStatus, _ string) error { - output.GetDebug().State("Starting font installation operation") - output.GetDebug().State("Total fonts: %d", len(fontsToInstall)) - - // Determine scope label for display - scopeLabel := InstallScopeLabelUser - if installScope == platform.MachineScope { - scopeLabel = InstallScopeLabelMachine - } - - // Process each font - for i, fontGroup := range fontsToInstall { - output.GetDebug().State("Installing font %d/%d: %s", i+1, len(fontsToInstall), fontGroup.FontName) - output.GetDebug().State("Installing font %s in %s (directory: %s)", fontGroup.FontName, scopeLabel, fontDir) - - result, err := installFont( - context.Background(), - fontGroup.Fonts, - fontGroup.FontID, - fontManager, - installScope, - force, - fontDir, - nil, - false, - nil, - ) - - if err != nil { - output.GetDebug().State("Error installing font %s in %s: %v", fontGroup.FontName, scopeLabel, err) - if result != nil { - updateInstallStatus(status, result) - // Show failed variants if available - _, _, failedFiles := processInstallResult(result) - if len(failedFiles) > 0 { - output.GetDebug().State("Failed variants:") - for _, file := range failedFiles { - output.GetDebug().State(" - %s", file) - } - } - } - continue - } - - // Update status - updateInstallStatus(status, result) - - // Show detailed result information in debug mode - logInstallResultDetails(result, fontGroup.FontName, scopeLabel) - } - - output.GetDebug().State("Operation complete - Installed: %d, Skipped: %d, Failed: %d", - status.Installed, status.Skipped, status.Failed) - - // Print status report - output.PrintStatusReport(output.StatusReport{ - Success: status.Installed, - Skipped: status.Skipped, - Failed: status.Failed, - SuccessLabel: "Installed", - SkippedLabel: "Skipped", - FailedLabel: "Failed", - }, output.IsVerboseOutputEnabled()) - - GetLogger().Info("Installation complete - Installed: %d, Skipped: %d, Failed: %d", - status.Installed, status.Skipped, status.Failed) - return nil -} - // variantLinesForVerboseProgress returns one human-readable label per manifest variant for the progress TUI // (avoids listing both style names and on-disk filenames). func variantLinesForVerboseProgress(fonts []repo.FontFile) []string { From 0073214d6ddb2951b93a924642f54ce3a01ce9c1 Mon Sep 17 00:00:00 2001 From: graphixa Date: Sat, 12 Sep 2026 19:50:36 +1000 Subject: [PATCH 05/23] Refactor exec_test.go and minifont.go for improved error handling and type safety --- internal/network/exec_test.go | 61 ++++++++++++++++++++++++----------- internal/testutil/minifont.go | 30 ++++++++++++++--- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/internal/network/exec_test.go b/internal/network/exec_test.go index 7d8e15a..088b90b 100644 --- a/internal/network/exec_test.go +++ b/internal/network/exec_test.go @@ -7,8 +7,6 @@ import ( "os/exec" "path/filepath" "runtime" - "strconv" - "strings" "testing" "time" ) @@ -48,52 +46,79 @@ func TestRunCancellable_InactivityStall(t *testing.T) { } func TestRunCancellable_KillsDescendants(t *testing.T) { - marker := filepath.Join(t.TempDir(), "alive") - name, args := descendantMarkerArgs(marker) + dir := t.TempDir() + marker := filepath.Join(dir, "alive") + name, args, err := descendantMarkerArgs(dir, marker) + if err != nil { + t.Fatal(err) + } if _, err := exec.LookPath(name); err != nil { t.Skip(err) } - _ = os.Remove(marker) + ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { - _, err := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: time.Second}, name, args...) - errCh <- err + _, runErr := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: time.Second}, name, args...) + errCh <- runErr }() - deadline := time.Now().Add(3 * time.Second) + + deadline := time.Now().Add(8 * time.Second) for time.Now().Before(deadline) { - if _, err := os.Stat(marker); err == nil { + select { + case runErr := <-errCh: + t.Fatalf("runner exited before marker appeared: %v", runErr) + default: + } + if _, statErr := os.Stat(marker); statErr == nil { break } time.Sleep(50 * time.Millisecond) } if _, err := os.Stat(marker); err != nil { - t.Fatal("child never created alive marker") + select { + case runErr := <-errCh: + t.Fatalf("child never created alive marker; runner err=%v", runErr) + default: + t.Fatal("child never created alive marker") + } } + cancel() select { case err := <-errCh: if err == nil { t.Fatal("expected cancellation error") } - case <-time.After(8 * time.Second): + case <-time.After(10 * time.Second): t.Fatal("runner did not return after cancel") } + // Child should stop updating the marker; remove and ensure it is not recreated. _ = os.Remove(marker) - time.Sleep(800 * time.Millisecond) + time.Sleep(1200 * time.Millisecond) if _, err := os.Stat(marker); err == nil { t.Fatal("descendant still alive after cancel (marker recreated)") } } -func descendantMarkerArgs(marker string) (string, []string) { +func descendantMarkerArgs(dir, marker string) (string, []string, error) { if runtime.GOOS == "windows" { - script := fmt.Sprintf(`while ($true) { Set-Content -LiteralPath '%s' -Value 'alive' -Encoding ascii; Start-Sleep -Seconds 1 }`, marker) - return "powershell", []string{"-NoProfile", "-NonInteractive", "-Command", script} + // Use a .cmd helper: more reliable on GitHub Actions than powershell -Command loops. + bat := filepath.Join(dir, "alive_loop.cmd") + script := fmt.Sprintf(""+ + "@echo off\r\n"+ + ":loop\r\n"+ + ">\"%s\" echo alive\r\n"+ + "ping -n 2 127.0.0.1 >nul\r\n"+ + "goto loop\r\n", marker) + if err := os.WriteFile(bat, []byte(script), 0o644); err != nil { + return "", nil, err + } + return "cmd", []string{"/c", bat}, nil } - script := `touch "` + marker + `"; while true; do touch "` + marker + `"; sleep 1; done` - return "sh", []string{"-c", script} + script := `touch "` + marker + `"; (sleep 60 &); while true; do touch "` + marker + `"; sleep 1; done` + return "sh", []string{"-c", script}, nil } func longSleepArgs() (string, []string) { @@ -148,8 +173,6 @@ func TestScrapeHTTPStatus(t *testing.T) { if got := scrapeHTTPStatus(nil, errWith("ERROR 429: Too Many Requests")); got != "429" { t.Fatalf("got %q", got) } - _ = strconv.Itoa(1) - _ = strings.TrimSpace("x") } type errWith string diff --git a/internal/testutil/minifont.go b/internal/testutil/minifont.go index 4b635de..4650cda 100644 --- a/internal/testutil/minifont.go +++ b/internal/testutil/minifont.go @@ -2,6 +2,8 @@ package testutil import ( "encoding/binary" + "fmt" + "math" "unicode/utf16" ) @@ -29,13 +31,17 @@ func MinimalTTF(family, style string) []byte { u := utf16.Encode([]rune(n.text)) off := len(store) for _, r := range u { - store = append(store, byte(r>>8), byte(r)) + store = binary.BigEndian.AppendUint16(store, r) } - recs = append(recs, nr{id: n.id, length: uint16(len(u) * 2), offset: uint16(off)}) + recs = append(recs, nr{ + id: n.id, + length: toUint16(len(u) * 2), + offset: toUint16(off), + }) } - count := uint16(len(recs)) - stringOffset := uint16(6 + 12*int(count)) + count := toUint16(len(recs)) + stringOffset := toUint16(6 + 12*int(count)) nameTable := make([]byte, 0, int(stringOffset)+len(store)) nameTable = append(nameTable, 0, 0) // format nameTable = binary.BigEndian.AppendUint16(nameTable, count) @@ -69,7 +75,7 @@ func MinimalTTF(family, style string) []byte { copy(tableDir[0:4], []byte("name")) binary.BigEndian.PutUint32(tableDir[4:8], checksum) binary.BigEndian.PutUint32(tableDir[8:12], 28) // offset - binary.BigEndian.PutUint32(tableDir[12:16], uint32(len(nameTable))) + binary.BigEndian.PutUint32(tableDir[12:16], toUint32(len(nameTable))) out := append([]byte{}, header...) out = append(out, tableDir...) @@ -79,3 +85,17 @@ func MinimalTTF(family, style string) []byte { } return out } + +func toUint16(n int) uint16 { + if n < 0 || n > math.MaxUint16 { + panic(fmt.Sprintf("uint16 overflow: %d", n)) + } + return uint16(n) // #nosec G115 -- range-checked above for SFNT name-table field widths +} + +func toUint32(n int) uint32 { + if n < 0 || n > math.MaxUint32 { + panic(fmt.Sprintf("uint32 overflow: %d", n)) + } + return uint32(n) // #nosec G115 -- range-checked above for SFNT table length +} From 5147ecded79717cc85239d539be14a378b00a4c2 Mon Sep 17 00:00:00 2001 From: graphixa Date: Sun, 13 Sep 2026 01:27:29 +1000 Subject: [PATCH 06/23] Update dependencies and refactor font installation tests for improved error handling - Removed unused dependency on github.com/blang/semver and updated to use golang.org/x/mod v0.41.0. - Refactored font installation tests to utilize a new control structure for injecting failure scenarios, enhancing test reliability and clarity. - Simplified table rendering in various commands by using default column configurations. - Removed deprecated functions and cleaned up code for better maintainability. --- cmd/add.go | 67 +- cmd/add_reliability_test.go | 18 +- cmd/browse.go | 13 +- cmd/browse_model.go | 2 +- cmd/import.go | 15 +- cmd/info.go | 11 +- cmd/integration_test.go | 7 +- cmd/remove.go | 37 +- cmd/search.go | 19 +- cmd/sources.go | 21 +- cmd/sources_cli.go | 11 +- .../validation.go => cmd/sources_helpers.go | 67 +- cmd/sources_manage.go | 30 +- cmd/sources_update.go | 3 +- cmd/update.go | 12 +- cmd/utils_test.go | 88 --- go.mod | 6 +- go.sum | 4 +- internal/cmdutils/file.go | 20 - internal/cmdutils/repository.go | 26 - internal/components/card_sections.go | 4 +- internal/components/confirm.go | 19 - internal/components/form.go | 312 -------- internal/components/form_navigation.go | 185 ----- internal/components/inline_prompt.go | 240 ------ internal/components/overlay.go | 14 - internal/components/table_custom.go | 9 +- internal/components/table_utils.go | 11 + internal/components/unified_form.go | 687 ------------------ internal/components/unified_form_test.go | 354 --------- internal/functions/doc.go | 5 - internal/functions/sort.go | 65 -- internal/installations/lock.go | 6 +- internal/installations/recovery.go | 26 +- internal/license/license.go | 178 ----- internal/logging/config.go | 97 --- internal/network/classify.go | 6 +- internal/network/classify_test.go | 4 +- internal/network/download_fallbacks.go | 28 +- internal/network/download_fallbacks_test.go | 8 + internal/network/exec.go | 16 +- internal/onboarding/onboarding.go | 72 -- internal/onboarding/steps.go | 607 ---------------- internal/platform/mutation.go | 12 - internal/platform/platform.go | 2 - internal/platform/temp.go | 82 +-- internal/platform/temp_test.go | 30 - internal/repo/checksum.go | 12 +- internal/repo/font.go | 25 +- internal/repo/font_matches.go | 13 +- internal/{normalize => repo}/normalize.go | 2 +- internal/shared/doc.go | 2 +- internal/shared/errors.go | 26 - internal/shared/file.go | 24 +- internal/shared/textwrap.go | 29 +- internal/templates/command_template.go | 321 -------- internal/ui/components.go | 186 ----- internal/update/config.go | 47 -- internal/update/release.go | 46 +- internal/update/update.go | 37 +- internal/update/update_test.go | 6 +- 61 files changed, 313 insertions(+), 4019 deletions(-) rename internal/functions/validation.go => cmd/sources_helpers.go (75%) delete mode 100644 internal/cmdutils/file.go delete mode 100644 internal/components/form.go delete mode 100644 internal/components/form_navigation.go delete mode 100644 internal/components/inline_prompt.go delete mode 100644 internal/components/unified_form.go delete mode 100644 internal/components/unified_form_test.go delete mode 100644 internal/functions/doc.go delete mode 100644 internal/functions/sort.go delete mode 100644 internal/license/license.go delete mode 100644 internal/onboarding/steps.go rename internal/{normalize => repo}/normalize.go (98%) delete mode 100644 internal/templates/command_template.go delete mode 100644 internal/update/config.go diff --git a/cmd/add.go b/cmd/add.go index 26fbef5..05829ca 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,4 +1,4 @@ -package cmd +package cmd import ( "context" @@ -214,13 +214,7 @@ func showGroupedFontNotFoundWithSuggestions(notFoundFonts []string) { // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width @@ -317,13 +311,7 @@ func showFontNotFoundWithSuggestions(fontName string, similar []string) { // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width @@ -468,13 +456,7 @@ func showMultipleMatchesAndExit(fontName string, matches []repo.FontMatch) { // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width @@ -758,6 +740,7 @@ Use --scope to set installation location: staging, suppressVerboseDownloads, onProgress, + nil, ) if err != nil { @@ -962,19 +945,6 @@ func archiveSourcePrefixFromFontID(fontID string) string { return strings.ToLower(fontID[:i]) } -// cloneDownloadOptsForProgress returns a shallow copy of downloadOpts (or zero) -// with ArchiveSourcePrefix set from archiveSourcePrefix so progress callbacks -// can be attached without dropping fields like OnResponseHeaders. -func cloneDownloadOptsForProgress(downloadOpts *repo.DownloadFontOptions, archiveSourcePrefix, archiveFontID string) *repo.DownloadFontOptions { - var base repo.DownloadFontOptions - if downloadOpts != nil { - base = *downloadOpts - } - base.ArchiveSourcePrefix = archiveSourcePrefix - base.ArchiveFontID = archiveFontID - return &base -} - // downloadFontVariants downloads all variants of a font family func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, staging *platform.OperationStaging, fontID string, archiveSourcePrefix string, downloadOpts *repo.DownloadFontOptions, onProgress StepProgressFunc) ([]string, error) { start := time.Now() @@ -1041,7 +1011,7 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin } // installDownloadedFonts installs downloaded font files to system -func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress StepProgressFunc) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { +func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress StepProgressFunc, tc *installTestControl) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { start := time.Now() var installedFiles []string var skippedFiles []string @@ -1087,7 +1057,7 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager var mut platform.FileMutation batchOpts.Mutation = &mut - if testInstallFailPoint == "register" { + if tc != nil && tc.failRegister { batchOpts.FailPoint = platform.InstallFailRegister } else { batchOpts.FailPoint = "" @@ -1111,15 +1081,9 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager break } - if testInstallFailPoint == "after-first" && len(mutations) == 1 { - failed++ - err = fmt.Errorf("injected failure after first file mutation") - failedFiles = append(failedFiles, fontDisplayName) - break - } - if testInstallFailPoint == "after-later" && len(mutations) >= 2 { + if tc != nil && tc.failAfterMutations > 0 && len(mutations) >= tc.failAfterMutations { failed++ - err = fmt.Errorf("injected failure after later file mutation") + err = fmt.Errorf("injected failure after mutation count %d", len(mutations)) failedFiles = append(failedFiles, fontDisplayName) break } @@ -1174,8 +1138,12 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager return installed, skipped, failed, details, errs, downloadSize, mutations, err } -// testInstallFailPoint injects package-level failures in tests: after-first, after-later, register, provenance. -var testInstallFailPoint string +// installTestControl injects package-level failures from tests. Production passes nil. +type installTestControl struct { + failRegister bool + failAfterMutations int // fail when len(mutations) >= N; 0 = off + failProvenance bool +} // buildInstallResult builds InstallResult from installation outcomes func buildInstallResult(status string, message string, installed, skipped, failed int, details []string, errors []string, downloadSize int64) *InstallResult { @@ -1219,6 +1187,7 @@ func installFont( staging *platform.OperationStaging, suppressVerboseDownloads bool, onProgress StepProgressFunc, + tc *installTestControl, ) (*InstallResult, error) { if ctx == nil { ctx = context.Background() @@ -1282,7 +1251,7 @@ func installFont( defer unlockDest() installed, skipped, failed, details, instErrs, downloadSize, mutations, installErr := installDownloadedFonts( - ctx, allFontPaths, fontManager, installScope, fontDir, force, onProgress) + ctx, allFontPaths, fontManager, installScope, fontDir, force, onProgress, tc) if installErr != nil || failed > 0 { rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, installErr) @@ -1305,7 +1274,7 @@ func installFont( res := buildInstallResult(status, message, installed, skipped, failed, details, instErrs, downloadSize) if status == InstallStatusCompleted && installed > 0 { - if testInstallFailPoint == "provenance" { + if tc != nil && tc.failProvenance { rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, fmt.Errorf("injected provenance failure")) res.Status = InstallStatusFailed res.Success = 0 diff --git a/cmd/add_reliability_test.go b/cmd/add_reliability_test.go index 1068185..7127da4 100644 --- a/cmd/add_reliability_test.go +++ b/cmd/add_reliability_test.go @@ -214,10 +214,7 @@ func TestInstallRollbackAfterFirstMutation(t *testing.T) { t.Fatal(err) } - testInstallFailPoint = "after-first" - t.Cleanup(func() { testInstallFailPoint = "" }) - - _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil) + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failAfterMutations: 1}) if err == nil { t.Fatal("expected injected failure") } @@ -288,9 +285,7 @@ func TestInstallRollbackAfterLaterMutation(t *testing.T) { if err := os.WriteFile(pathB, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { t.Fatal(err) } - testInstallFailPoint = "after-later" - t.Cleanup(func() { testInstallFailPoint = "" }) - _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil) + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failAfterMutations: 2}) if err == nil { t.Fatal("expected injected failure") } @@ -324,9 +319,7 @@ func TestInstallRegisterFailRollsBack(t *testing.T) { if err := os.WriteFile(src, testutil.MinimalTTF("Face", "Regular"), 0644); err != nil { t.Fatal(err) } - testInstallFailPoint = "register" - t.Cleanup(func() { testInstallFailPoint = "" }) - _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{src}, fm, platform.UserScope, fontDir, false, nil) + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{src}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failRegister: true}) if err == nil { t.Fatal("expected register failure") } @@ -359,16 +352,13 @@ func TestInstallProvenanceFailRollsBack(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = staging.Cleanup() }) - testInstallFailPoint = "provenance" - t.Cleanup(func() { testInstallFailPoint = "" }) - files := []repo.FontFile{{ Name: "ProvFam", Variant: "Regular", Path: "ProvFam-Regular.ttf", DownloadURL: srv.URL + "/ProvFam-Regular.ttf", }} - res, err := installFont(context.Background(), files, "test.prov", fm, platform.UserScope, false, fontDir, staging, true, nil) + res, err := installFont(context.Background(), files, "test.prov", fm, platform.UserScope, false, fontDir, staging, true, nil, &installTestControl{failProvenance: true}) if err == nil { t.Fatal("expected provenance failure") } diff --git a/cmd/browse.go b/cmd/browse.go index e23b5c9..91803cd 100644 --- a/cmd/browse.go +++ b/cmd/browse.go @@ -5,7 +5,9 @@ import ( "fmt" "fontget/internal/cmdutils" + "fontget/internal/output" "fontget/internal/platform" + "fontget/internal/repo" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -29,9 +31,16 @@ Flags --scope (-s) and --force (-f) match fontget add (user/machine install scop return err } - r, err := cmdutils.GetRepository(GetLogger()) + output.GetVerbose().Info("Loading font repository") + output.GetDebug().State("Calling repo.GetRepository()") + r, err := repo.GetRepository() if err != nil { - return err + if lg := GetLogger(); lg != nil { + lg.Error("Failed to get repository: %v", err) + } + output.GetVerbose().Error("%v", err) + output.GetDebug().Error("repo.GetRepository() failed: %v", err) + return fmt.Errorf("unable to load font repository: %w", err) } fontManager, err := cmdutils.CreateFontManager(func() cmdutils.Logger { return GetLogger() }) diff --git a/cmd/browse_model.go b/cmd/browse_model.go index 762820b..32fb1a5 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -563,7 +563,7 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea ch <- browseOpProgressMsg{phase: step, percent: OverallInstallPercent(0, 1, step, stepPct)} } - ir, ierr := installFont(context.Background(), res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress) + ir, ierr := installFont(context.Background(), res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress, nil) ch <- installFinishedMsg{result: ir, err: ierr, fontID: fontID} }() return m.waitForOpMsg() diff --git a/cmd/import.go b/cmd/import.go index 00025b1..06e81fb 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -33,17 +33,16 @@ type ImportResult struct { // loadAndValidateManifest loads and validates an export manifest file func loadAndValidateManifest(manifestFile string) (*ExportManifest, error) { // Check if file exists - exists, err := cmdutils.CheckFileExists(manifestFile) - if err != nil { + if _, err := os.Stat(manifestFile); err != nil { + if os.IsNotExist(err) { + cmdutils.PrintErrorf("Manifest file not found: '%s'", ui.InfoText.Render(manifestFile)) + fmt.Println() + return nil, fmt.Errorf("manifest file not found: %s", manifestFile) + } cmdutils.PrintErrorf("Unable to check manifest file: %v", err) fmt.Println() return nil, err } - if !exists { - cmdutils.PrintErrorf("Manifest file not found: '%s'", ui.InfoText.Render(manifestFile)) - fmt.Println() - return nil, fmt.Errorf("manifest file not found: %s", manifestFile) - } // Read manifest file // Note: Entire manifest is loaded into memory. This is acceptable because: @@ -646,6 +645,7 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni nil, true, onProgress, + nil, ) if err != nil { @@ -759,6 +759,7 @@ func importFontsInDebugMode(fontManager platform.FontManager, fontsToInstall []F nil, false, nil, + nil, ) if err != nil { diff --git a/cmd/info.go b/cmd/info.go index 5c76b5d..c092a2b 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -95,9 +95,16 @@ Use --license to show only license information.`, // Get repository (using cached manifest) output.GetVerbose().Info("Initializing repository for font lookup") - r, err := cmdutils.GetRepository(GetLogger()) + output.GetVerbose().Info("Loading font repository") + output.GetDebug().State("Calling repo.GetRepository()") + r, err := repo.GetRepository() if err != nil { - return err + if lg := GetLogger(); lg != nil { + lg.Error("Failed to get repository: %v", err) + } + output.GetVerbose().Error("%v", err) + output.GetDebug().Error("repo.GetRepository() failed: %v", err) + return fmt.Errorf("unable to load font repository: %w", err) } // Get manifest diff --git a/cmd/integration_test.go b/cmd/integration_test.go index b236574..7b94b71 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "fontget/internal/cmdutils" + "fontget/internal/repo" "fontget/internal/shared" ) @@ -121,20 +122,20 @@ func TestGetRepository_Integration(t *testing.T) { t.Fatalf("Failed to initialize manifest: %v", err) } - repo, err := cmdutils.GetRepository(GetLogger()) + r, err := repo.GetRepository() if err != nil { t.Errorf("getRepository() unexpected error: %v", err) return } - if repo == nil { + if r == nil { t.Errorf("getRepository() returned nil repository") return } // Verify repository is usable - manifest, err := repo.GetManifest() + manifest, err := r.GetManifest() if err != nil { t.Errorf("getRepository() returned repository that failed to get manifest: %v", err) } diff --git a/cmd/remove.go b/cmd/remove.go index 904c048..a24b52d 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -11,7 +11,6 @@ import ( "fontget/internal/cmdutils" "fontget/internal/components" "fontget/internal/installations" - "fontget/internal/normalize" "fontget/internal/output" "fontget/internal/platform" "fontget/internal/repo" @@ -80,7 +79,7 @@ type RemoveResult struct { // // Returns the original name if no suffix pattern is found func extractBaseFontName(familyName string) string { - return normalize.BaseFamilyName(familyName) + return repo.BaseFamilyName(familyName) } // ProgressCallback is a function type for reporting progress during font finding @@ -218,7 +217,7 @@ func findFontFamilyFiles(fontFamily string, fontManager platform.FontManager, sc // normalizeFontName normalizes a font name for comparison func normalizeFontName(name string) string { - return normalize.FontKey(name) + return repo.FontKey(name) } // resolveFontNameOrID resolves a Font ID to a font name, or returns the original if it's already a font name @@ -1443,13 +1442,7 @@ Use --scope to set removal location: // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width @@ -1595,13 +1588,7 @@ Use --scope to set removal location: // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width @@ -2476,13 +2463,7 @@ Use --scope to set removal location: // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width @@ -2629,13 +2610,7 @@ Use --scope to set removal location: // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width diff --git a/cmd/search.go b/cmd/search.go index 3086442..038d547 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -258,9 +258,16 @@ Use -s without a value to list sources.`, } output.GetDebug().State("Starting font search with parameters: query='%s', category='%s', source='%s'", query, category, source) - r, err := cmdutils.GetRepository(GetLogger()) + output.GetVerbose().Info("Loading font repository") + output.GetDebug().State("Calling repo.GetRepository()") + r, err := repo.GetRepository() if err != nil { - return err + if lg := GetLogger(); lg != nil { + lg.Error("Failed to get repository: %v", err) + } + output.GetVerbose().Error("%v", err) + output.GetDebug().Error("repo.GetRepository() failed: %v", err) + return fmt.Errorf("unable to load font repository: %w", err) } // Handle source-only search (no query, no category) @@ -475,13 +482,7 @@ Use -s without a value to list sources.`, // Render table with priority configuration tableConfig := components.TableConfig{ - Columns: []components.ColumnConfig{ - {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, - {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, // Highest priority, don't trim - {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, - {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, - {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, // Lowest priority - }, + Columns: components.DefaultFontTableColumns(), Rows: tableRows, Width: 0, // Auto-detect terminal width MaxWidth: 120, // Maximum width diff --git a/cmd/sources.go b/cmd/sources.go index 9a04f15..45ea8e2 100644 --- a/cmd/sources.go +++ b/cmd/sources.go @@ -13,7 +13,6 @@ import ( "fontget/internal/components" "fontget/internal/config" - "fontget/internal/functions" "fontget/internal/output" "fontget/internal/repo" "fontget/internal/shared" @@ -146,7 +145,7 @@ var sourcesInfoCmd = &cobra.Command{ sb.WriteString("\n") if sourcesDir != "" { sb.WriteString(ui.CardLabel.Render("Total Cache Size: ")) - sb.WriteString(ui.Text.Render(formatFileSize(totalCacheSize))) + sb.WriteString(ui.Text.Render(shared.FormatFileSize(totalCacheSize))) sb.WriteString("\n\n") } else { sb.WriteString("\n") @@ -370,7 +369,7 @@ func runSourcesUpdateVerbose() error { } // Get enabled sources - enabledSources := functions.GetEnabledSourcesInOrder(manifest) + enabledSources := GetEnabledSourcesInOrder(manifest) if len(enabledSources) == 0 { return fmt.Errorf("no sources are enabled") } @@ -561,20 +560,6 @@ func isValidSourceFile(filePath string) bool { return json.Unmarshal(data, &jsonData) == nil } -// formatFileSize formats cache/directory sizes for sources output (KMGTPE). See shared.FormatFileSize for the narrower KB/MB helper used elsewhere. -func formatFileSize(size int64) string { - const unit = 1024 - if size < unit { - return fmt.Sprintf("%d B", size) - } - div, exp := int64(unit), 0 - for n := size / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) -} - func getDirSize(dir string) int64 { var size int64 filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { @@ -666,7 +651,7 @@ If validation fails, run 'fontget sources update' to refresh the source files.`, if isValidSourceFile(filePath) { // Get file size for display if info, err := os.Stat(filePath); err == nil { - size := formatFileSize(info.Size()) + size := shared.FormatFileSize(info.Size()) fmt.Printf(" %s %s (%s) | %s\n", ui.SuccessText.Render("✓"), entry.Name(), diff --git a/cmd/sources_cli.go b/cmd/sources_cli.go index c868f78..7f29b8f 100644 --- a/cmd/sources_cli.go +++ b/cmd/sources_cli.go @@ -8,7 +8,6 @@ import ( "fontget/internal/cmdutils" "fontget/internal/config" - "fontget/internal/functions" "fontget/internal/output" "fontget/internal/ui" @@ -131,7 +130,7 @@ func runSourcesAdd(cmd *cobra.Command, _ []string) error { } existing := convertManifestToSourceItems(manifest) - result := functions.ValidateSourceForm(name, url, prefix, existing, -1) + result := ValidateSourceForm(name, url, prefix, existing, -1) if !result.IsValid { fmt.Println() cmdutils.PrintErrorf("%s", result.GetFirstError()) @@ -140,7 +139,7 @@ func runSourcesAdd(cmd *cobra.Command, _ []string) error { } if prefix == "" { - prefix = functions.AutoGeneratePrefix(name) + prefix = AutoGeneratePrefix(name) } prefix = strings.ToLower(prefix) @@ -358,9 +357,9 @@ func runSourcesSet(cmd *cobra.Command, _ []string) error { source := manifest.Sources[name] existing := convertManifestToSourceItems(manifest) - editingIndex := functions.FindSourceIndex(existing, name) + editingIndex := FindSourceIndex(existing, name) if hasURL { - if err := functions.ValidateURL(url); err != nil { + if err := ValidateURL(url); err != nil { fmt.Println() cmdutils.PrintErrorf("%v", err) fmt.Println() @@ -378,7 +377,7 @@ func runSourcesSet(cmd *cobra.Command, _ []string) error { } if hasPrefix { prefix = strings.ToLower(prefix) - if err := functions.ValidatePrefix(prefix); err != nil { + if err := ValidatePrefix(prefix); err != nil { fmt.Println() cmdutils.PrintErrorf("%v", err) fmt.Println() diff --git a/internal/functions/validation.go b/cmd/sources_helpers.go similarity index 75% rename from internal/functions/validation.go rename to cmd/sources_helpers.go index a7f3c8a..a560cf4 100644 --- a/internal/functions/validation.go +++ b/cmd/sources_helpers.go @@ -1,10 +1,72 @@ -package functions +package cmd import ( "fmt" + "sort" "strings" + + "fontget/internal/config" ) +// SourceItem represents a source for sorting purposes +type SourceItem struct { + Name string + Prefix string + URL string + Enabled bool + IsBuiltIn bool + Priority int +} + +// SortSources sorts sources by type (built-in first) then by priority order +func SortSources(sources []SourceItem) { + sort.Slice(sources, func(i, j int) bool { + // Built-in sources come first + if sources[i].IsBuiltIn != sources[j].IsBuiltIn { + return sources[i].IsBuiltIn + } + // Within same type, sort by priority (lower number = higher priority) + if sources[i].Priority != sources[j].Priority { + return sources[i].Priority < sources[j].Priority + } + // If priorities are equal, sort by name + return sources[i].Name < sources[j].Name + }) +} + +// GetEnabledSourcesInOrder returns enabled sources in priority order from config manifest +func GetEnabledSourcesInOrder(manifest *config.Manifest) []string { + var sources []SourceItem + + for name, source := range manifest.Sources { + if source.Enabled { + sources = append(sources, SourceItem{ + Name: name, + Priority: source.Priority, + }) + } + } + + // Sort by priority + SortSources(sources) + + var result []string + for _, source := range sources { + result = append(result, source.Name) + } + return result +} + +// FindSourceIndex finds the index of a source by name +func FindSourceIndex(sources []SourceItem, name string) int { + for i, source := range sources { + if source.Name == name { + return i + } + } + return -1 +} + // Input validation constants const ( MinInputWidth = 30 @@ -93,7 +155,6 @@ func ValidateSourceForm(name, url, prefix string, existingSources []SourceItem, // Validate name if err := ValidateRequired(name, "Name"); err != nil { - // Extract just the message, not the full error string (which includes field name) if validationErr, ok := err.(ValidationError); ok { result.AddError("Name", validationErr.Message) } else { @@ -103,7 +164,6 @@ func ValidateSourceForm(name, url, prefix string, existingSources []SourceItem, // Validate URL if err := ValidateURL(url); err != nil { - // Extract just the message, not the full error string (which includes field name) if validationErr, ok := err.(ValidationError); ok { result.AddError("URL", validationErr.Message) } else { @@ -113,7 +173,6 @@ func ValidateSourceForm(name, url, prefix string, existingSources []SourceItem, // Validate prefix if err := ValidatePrefix(prefix); err != nil { - // Extract just the message, not the full error string (which includes field name) if validationErr, ok := err.(ValidationError); ok { result.AddError("Prefix", validationErr.Message) } else { diff --git a/cmd/sources_manage.go b/cmd/sources_manage.go index f955548..f305ca5 100644 --- a/cmd/sources_manage.go +++ b/cmd/sources_manage.go @@ -7,7 +7,6 @@ import ( "fontget/internal/components" "fontget/internal/config" - "fontget/internal/functions" "fontget/internal/output" "fontget/internal/ui" @@ -16,12 +15,11 @@ import ( "github.com/spf13/cobra" ) -// SourceItem represents a source in the TUI -// This type is now defined in internal/functions/sort.go for consistency +// Source helpers (SourceItem, validation, sorting) live in sources_helpers.go. // sourcesModel represents the main model for the sources management TUI type sourcesModel struct { - sources []functions.SourceItem + sources []SourceItem cursor int manifest *config.Manifest state string // "list", "add", "edit", "confirm", "save_confirm", "builtin_warning" @@ -60,7 +58,7 @@ func NewSourcesModel() (*sourcesModel, error) { sm.sources = convertManifestToSourceItems(manifest) // Sort sources using the centralized sorting function - functions.SortSources(sm.sources) + SortSources(sm.sources) // Initialize checkbox list sm.initCheckboxList() @@ -98,7 +96,7 @@ func NewSourcesModel() (*sourcesModel, error) { // updateInputWidths updates the width of text inputs based on terminal size func (m *sourcesModel) updateInputWidths() { - width := functions.CalculateInputWidth(m.width) + width := CalculateInputWidth(m.width) m.nameInput.Width = width m.urlInput.Width = width m.prefixInput.Width = width @@ -476,10 +474,10 @@ func (m *sourcesModel) resetForm() { } // convertManifestToSourceItems converts manifest sources to SourceItem slice -func convertManifestToSourceItems(manifest *config.Manifest) []functions.SourceItem { - var sources []functions.SourceItem +func convertManifestToSourceItems(manifest *config.Manifest) []SourceItem { + var sources []SourceItem for name, source := range manifest.Sources { - sources = append(sources, functions.SourceItem{ + sources = append(sources, SourceItem{ Name: name, Prefix: source.Prefix, URL: source.URL, @@ -556,7 +554,7 @@ func (m *sourcesModel) validateForm() bool { } // Use centralized validation - result := functions.ValidateSourceForm(name, url, prefix, m.sources, editingIndex) + result := ValidateSourceForm(name, url, prefix, m.sources, editingIndex) if !result.IsValid { m.err = result.GetFirstError() @@ -565,7 +563,7 @@ func (m *sourcesModel) validateForm() bool { // Auto-generate prefix if empty if prefix == "" { - generatedPrefix := functions.AutoGeneratePrefix(name) + generatedPrefix := AutoGeneratePrefix(name) m.prefixInput.SetValue(generatedPrefix) } @@ -586,7 +584,7 @@ func (m *sourcesModel) addSource() { } // Assign priority to custom sources (100+ to ensure they come after built-in sources) - newSource := functions.SourceItem{ + newSource := SourceItem{ Name: name, Prefix: prefix, URL: url, @@ -596,10 +594,10 @@ func (m *sourcesModel) addSource() { } m.sources = append(m.sources, newSource) - functions.SortSources(m.sources) + SortSources(m.sources) // Find the new source's position using the utility function - m.cursor = functions.FindSourceIndex(m.sources, name) + m.cursor = FindSourceIndex(m.sources, name) // Reinitialize checkbox list to reflect new source m.initCheckboxList() @@ -623,10 +621,10 @@ func (m *sourcesModel) updateSource() { m.sources[m.editingIndex].Prefix = prefix // Re-sort sources using the centralized sorting function - functions.SortSources(m.sources) + SortSources(m.sources) // Find the updated source's position using the utility function - m.cursor = functions.FindSourceIndex(m.sources, name) + m.cursor = FindSourceIndex(m.sources, name) // Reinitialize checkbox list to reflect updated source m.initCheckboxList() diff --git a/cmd/sources_update.go b/cmd/sources_update.go index 00755cf..b7a4a0a 100644 --- a/cmd/sources_update.go +++ b/cmd/sources_update.go @@ -10,7 +10,6 @@ import ( "time" "fontget/internal/config" - "fontget/internal/functions" "fontget/internal/output" "fontget/internal/repo" "fontget/internal/ui" @@ -91,7 +90,7 @@ func NewUpdateModel(verbose bool) (*updateModel, error) { } // Get enabled sources - enabledSources := functions.GetEnabledSourcesInOrder(manifest) + enabledSources := GetEnabledSourcesInOrder(manifest) if len(enabledSources) == 0 { return nil, fmt.Errorf("no sources are enabled") } diff --git a/cmd/update.go b/cmd/update.go index a6357d0..4fa3ae6 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -10,8 +10,8 @@ import ( "fontget/internal/update" "fontget/internal/version" - "github.com/blang/semver" "github.com/spf13/cobra" + "golang.org/x/mod/semver" ) // Version prefix constant (could be v, or ver) @@ -210,12 +210,10 @@ func handleUpdateToVersion(targetVersion string, autoYes bool) error { // Parse versions to detect downgrades (ignore parse errors gracefully) isDowngrade := false - if curr, errCurr := semver.Parse(currentVersion); errCurr == nil { - if tgt, errTgt := semver.Parse(targetVersion); errTgt == nil { - if tgt.LT(curr) { - isDowngrade = true - } - } + curr := "v" + strings.TrimPrefix(strings.TrimSpace(currentVersion), "v") + tgt := "v" + strings.TrimPrefix(strings.TrimSpace(targetVersion), "v") + if semver.IsValid(curr) && semver.IsValid(tgt) && semver.Compare(tgt, curr) < 0 { + isDowngrade = true } // Show update information with styled labels diff --git a/cmd/utils_test.go b/cmd/utils_test.go index a98fccd..cede885 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -7,7 +7,6 @@ import ( "fontget/internal/cmdutils" "fontget/internal/output" "fontget/internal/platform" - "fontget/internal/repo" "fontget/internal/shared" ) @@ -165,55 +164,6 @@ func TestFontRemovalError(t *testing.T) { } } -func TestConfigurationError(t *testing.T) { - tests := []struct { - name string - field string - value string - hint string - expected string - }{ - { - name: "without hint", - field: "scope", - value: "invalid", - hint: "", - expected: "configuration error in field 'scope' with value 'invalid'", - }, - { - name: "with hint", - field: "scope", - value: "invalid", - hint: "must be 'user' or 'machine'", - expected: "configuration error in field 'scope' with value 'invalid': must be 'user' or 'machine'", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := &shared.ConfigurationError{ - Field: tt.field, - Value: tt.value, - Hint: tt.hint, - } - if err.Error() != tt.expected { - t.Errorf("ConfigurationError.Error() = %q, expected %q", err.Error(), tt.expected) - } - }) - } -} - -func TestElevationError(t *testing.T) { - err := &shared.ElevationError{ - Operation: "install", - Platform: "windows", - } - expected := "elevation required for operation 'install' on platform 'windows'" - if err.Error() != expected { - t.Errorf("ElevationError.Error() = %q, expected %q", err.Error(), expected) - } -} - func TestGetFontFamilyNameFromFilename(t *testing.T) { tests := []struct { name string @@ -433,41 +383,3 @@ func TestArchiveSourcePrefixFromFontID(t *testing.T) { t.Fatalf("got %q want empty", got) } } - -func TestCloneDownloadOptsForProgress_preservesOnResponseHeaders(t *testing.T) { - called := false - in := &repo.DownloadFontOptions{ - SuppressVerboseProgressLine: true, - OnResponseHeaders: func(repo.HTTPResponseInfo) { called = true }, - } - out := cloneDownloadOptsForProgress(in, "fontshare", "fontshare.foo") - if out.ArchiveSourcePrefix != "fontshare" { - t.Fatalf("ArchiveSourcePrefix: got %q", out.ArchiveSourcePrefix) - } - if out.ArchiveFontID != "fontshare.foo" { - t.Fatalf("ArchiveFontID: got %q", out.ArchiveFontID) - } - if !out.SuppressVerboseProgressLine { - t.Fatal("SuppressVerboseProgressLine lost") - } - if out.OnResponseHeaders == nil { - t.Fatal("OnResponseHeaders dropped") - } - out.OnResponseHeaders(repo.HTTPResponseInfo{}) - if !called { - t.Fatal("OnResponseHeaders should be the same callback") - } -} - -func TestCloneDownloadOptsForProgress_nilIncoming(t *testing.T) { - out := cloneDownloadOptsForProgress(nil, "league", "league.fanwood") - if out.ArchiveSourcePrefix != "league" { - t.Fatalf("got %q", out.ArchiveSourcePrefix) - } - if out.ArchiveFontID != "league.fanwood" { - t.Fatalf("ArchiveFontID: got %q", out.ArchiveFontID) - } - if out.OnResponseHeaders != nil { - t.Fatal("expected nil OnResponseHeaders") - } -} diff --git a/go.mod b/go.mod index 1ccd07e..09a2086 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,15 @@ go 1.26.0 toolchain go1.26.6 require ( - github.com/blang/semver v3.5.1+incompatible github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.10.1 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd github.com/charmbracelet/x/term v0.2.1 - github.com/mattn/go-runewidth v0.0.16 github.com/spf13/cobra v1.9.1 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 golang.org/x/image v0.39.0 + golang.org/x/mod v0.41.0 golang.org/x/sys v0.47.0 golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 @@ -26,12 +24,14 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index b201c5d..d7929cc 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -63,6 +61,8 @@ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/internal/cmdutils/file.go b/internal/cmdutils/file.go deleted file mode 100644 index 807662e..0000000 --- a/internal/cmdutils/file.go +++ /dev/null @@ -1,20 +0,0 @@ -package cmdutils - -import ( - "os" -) - -// CheckFileExists checks if a file exists at the given path. -// Returns true if the file exists, false if it doesn't exist. -// Returns an error if there was a problem checking the file (other than file not found). -func CheckFileExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - if os.IsNotExist(err) { - return false, nil - } - // Some other error occurred - return false, err -} diff --git a/internal/cmdutils/repository.go b/internal/cmdutils/repository.go index f074bc0..c6223d6 100644 --- a/internal/cmdutils/repository.go +++ b/internal/cmdutils/repository.go @@ -1,36 +1,10 @@ package cmdutils import ( - "fmt" - "fontget/internal/output" "fontget/internal/repo" ) -// GetRepository gets the font repository (standard caching/refresh policy in repo). -// Returns standardized error handling for repository initialization. -// -// logger can be nil (for testing or when logging is not needed). -// -// NOTE: This function is tested via integration tests (see cmd/integration_test.go) -// because it depends on package-level repo functions that are difficult to mock. -func GetRepository(logger Logger) (*repo.Repository, error) { - output.GetVerbose().Info("Loading font repository") - output.GetDebug().State("Calling repo.GetRepository()") - r, err := repo.GetRepository() - - if err != nil { - if logger != nil { - logger.Error("Failed to get repository: %v", err) - } - output.GetVerbose().Error("%v", err) - output.GetDebug().Error("repo.GetRepository() failed: %v", err) - return nil, fmt.Errorf("unable to load font repository: %w", err) - } - - return r, nil -} - // MatchInstalledFontsToRepository matches installed fonts to repository entries. // This is reusable for list and export commands. // diff --git a/internal/components/card_sections.go b/internal/components/card_sections.go index db39f16..2eb8d78 100644 --- a/internal/components/card_sections.go +++ b/internal/components/card_sections.go @@ -6,8 +6,8 @@ import ( "fontget/internal/ui" + "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/cellbuf" ) // CardInnerContentWidth is the display width available for card body text inside @@ -69,7 +69,7 @@ func formatPlainSection(sec CardSection, inner int) []string { rw = 1 } valStyled := ui.Text.Render(sec.Value) - wrapped := cellbuf.Wrap(valStyled, rw, "") + wrapped := lipgloss.NewStyle().Width(rw).Render(valStyled) parts := strings.Split(wrapped, "\n") out := make([]string, 0, len(parts)) indent := strings.Repeat(" ", wPref) diff --git a/internal/components/confirm.go b/internal/components/confirm.go index fc2b5cc..026945b 100644 --- a/internal/components/confirm.go +++ b/internal/components/confirm.go @@ -187,22 +187,3 @@ func RunConfirmWithOptions(title, message, confirmText, cancelText string, useAl return false, nil } - -// DeleteConfirm runs a delete confirmation dialog -func DeleteConfirm(itemName string) (bool, error) { - title := "Confirm Deletion" - message := fmt.Sprintf("Are you sure you want to delete '%s'?", ui.TableSourceName.Render(itemName)) - return RunConfirm(title, message) -} - -// SaveConfirm runs a save confirmation dialog -func SaveConfirm() (bool, error) { - title := "Save Changes" - message := "You have unsaved changes. Do you want to save before exiting?" - return RunConfirmWithOptions(title, message, "Save", "Discard", true, true) -} - -// WarningConfirm runs a warning confirmation dialog -func WarningConfirm(title, message string) (bool, error) { - return RunConfirm(title, message) -} diff --git a/internal/components/form.go b/internal/components/form.go deleted file mode 100644 index 0202ed5..0000000 --- a/internal/components/form.go +++ /dev/null @@ -1,312 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "fontget/internal/ui" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -// FormField represents a single form field -type FormField struct { - Label string - Value string - Placeholder string - Focused bool - ReadOnly bool - Input textinput.Model -} - -// FormModel represents a form component -type FormModel struct { - Title string - Fields []FormField - FocusedField int - Error string - ReadOnly bool - Width int - Height int - OnSubmit func(values map[string]string) error - OnCancel func() -} - -// NewFormModel creates a new form model -func NewFormModel(title string, fieldConfigs []FieldConfig) *FormModel { - fields := make([]FormField, len(fieldConfigs)) - - for i, config := range fieldConfigs { - input := textinput.New() - input.Placeholder = config.Placeholder - input.Width = 50 // Default width, will be updated on window resize - input.TextStyle = ui.FormInput - input.PlaceholderStyle = ui.FormPlaceholder - - fields[i] = FormField{ - Label: config.Label, - Value: config.Value, - Placeholder: config.Placeholder, - Focused: i == 0, // First field is focused by default - ReadOnly: config.ReadOnly, - Input: input, - } - } - - // Focus the first field - if len(fields) > 0 { - fields[0].Input.Focus() - } - - return &FormModel{ - Title: title, - Fields: fields, - FocusedField: 0, - Width: 80, - Height: 24, - } -} - -// FieldConfig represents configuration for a form field -type FieldConfig struct { - Label string - Value string - Placeholder string - ReadOnly bool -} - -// Init initializes the form model -func (m FormModel) Init() tea.Cmd { - return textinput.Blink -} - -// Update handles messages and updates the form -func (m FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - // Handle window resize - if msg, ok := msg.(tea.WindowSizeMsg); ok { - m.Width = msg.Width - m.Height = msg.Height - m.updateInputWidths() - return m, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - if m.OnCancel != nil { - m.OnCancel() - } - return m, tea.Quit - - case "tab": - m.FocusedField = (m.FocusedField + 1) % len(m.Fields) - m.updateFocus() - - case "shift+tab": - m.FocusedField = (m.FocusedField - 1 + len(m.Fields)) % len(m.Fields) - m.updateFocus() - - case "enter": - if m.ReadOnly { - // In read-only mode, just quit - return m, tea.Quit - } else if m.validateForm() { - values := m.getValues() - if m.OnSubmit != nil { - if err := m.OnSubmit(values); err != nil { - m.Error = err.Error() - return m, nil - } - } - return m, tea.Quit - } - - case "ctrl+c": - return m, tea.Quit - } - } - - // Update focused input (only if not in read-only mode) - if !m.ReadOnly && m.FocusedField < len(m.Fields) { - m.Fields[m.FocusedField].Input, cmd = m.Fields[m.FocusedField].Input.Update(msg) - } - - return m, cmd -} - -// View renders the form -func (m FormModel) View() string { - out := ui.PageTitle.Render(m.Title) + "\n\n" - - // Render fields - for i, field := range m.Fields { - fieldValue := m.renderFieldValue(field) - styledLabel := ui.FormLabel.Render(field.Label) - out += fmt.Sprintf(" %s %s\n", styledLabel, fieldValue) - if i < len(m.Fields)-1 { - out += "\n" - } - } - - // Render error if any - if m.Error != "" { - out += "\n" + ui.RenderError(m.Error) + "\n" - } - - // Render commands - commands := m.getCommands() - helpText := strings.Join(commands, " ") - out += "\n" + helpText - - if m.Width > 0 && m.Height > 0 { - return ui.FillTerminalArea(out, m.Width, m.Height) - } - return out -} - -// renderFieldValue renders the value for a field -func (m FormModel) renderFieldValue(field FormField) string { - if field.ReadOnly { - // In read-only mode, show as static text - return ui.FormReadOnly.Render(field.Value) - } - - // In edit mode, show as input field with custom styling - if field.Focused { - // For the focused field, use the textinput's View() method to get the blinking cursor - return field.Input.View() - } else { - // For non-focused fields, show the value with custom styling - inputValue := field.Input.Value() - if inputValue == "" { - // Show placeholder with placeholder styling - return ui.FormPlaceholder.Render(field.Input.Placeholder) - } else { - // Show actual input value with form input styling - return ui.FormInput.Render(inputValue) - } - } -} - -// getCommands returns the command help text -func (m FormModel) getCommands() []string { - if m.ReadOnly { - return []string{ - ui.RenderKeyWithDescription("Tab/Shift+Tab", "Move"), - ui.RenderKeyWithDescription("Enter/Esc", "Back"), - } - } - - return []string{ - ui.RenderKeyWithDescription("Tab/Shift+Tab", "Move"), - ui.RenderKeyWithDescription("Enter", "Submit"), - ui.RenderKeyWithDescription("Esc", "Cancel"), - } -} - -// updateInputWidths updates the width of text inputs based on terminal size -func (m *FormModel) updateInputWidths() { - width := m.calculateInputWidth() - for i := range m.Fields { - m.Fields[i].Input.Width = width - } -} - -// calculateInputWidth calculates the appropriate input width based on terminal size -func (m FormModel) calculateInputWidth() int { - // Use a reasonable default width, but not too wide - width := m.Width - 20 // Account for margins and labels - if width < 30 { - width = 30 - } - if width > 80 { - width = 80 - } - return width -} - -// updateFocus updates which input is focused -func (m *FormModel) updateFocus() { - // Blur all inputs - for i := range m.Fields { - m.Fields[i].Input.Blur() - m.Fields[i].Focused = false - } - - // Focus the current field - if m.FocusedField < len(m.Fields) && !m.ReadOnly { - m.Fields[m.FocusedField].Input.Focus() - m.Fields[m.FocusedField].Focused = true - } -} - -// validateForm validates the form inputs -func (m *FormModel) validateForm() bool { - // Basic validation - check that required fields are not empty - for i, field := range m.Fields { - if !field.ReadOnly { - value := strings.TrimSpace(field.Input.Value()) - if value == "" { - m.Error = fmt.Sprintf("%s is required", field.Label) - m.FocusedField = i - m.updateFocus() - return false - } - } - } - - m.Error = "" - return true -} - -// getValues returns a map of field values -func (m FormModel) getValues() map[string]string { - values := make(map[string]string) - for _, field := range m.Fields { - values[field.Label] = strings.TrimSpace(field.Input.Value()) - } - return values -} - -// SetValues sets the values for the form fields -func (m *FormModel) SetValues(values map[string]string) { - for i, field := range m.Fields { - if value, exists := values[field.Label]; exists { - m.Fields[i].Input.SetValue(value) - } - } -} - -// SetError sets the error message -func (m *FormModel) SetError(err string) { - m.Error = err -} - -// SetReadOnly sets the read-only mode for the form -func (m *FormModel) SetReadOnly(readOnly bool) { - m.ReadOnly = readOnly - for i := range m.Fields { - m.Fields[i].ReadOnly = readOnly - } -} - -// RunForm runs a form with the given configuration -func RunForm(title string, fieldConfigs []FieldConfig, onSubmit func(values map[string]string) error, onCancel func()) error { - model := NewFormModel(title, fieldConfigs) - model.OnSubmit = onSubmit - model.OnCancel = onCancel - - // Create and run the Bubble Tea program - program := tea.NewProgram(model, tea.WithAltScreen()) - - _, err := program.Run() - if err != nil { - return fmt.Errorf("failed to run form: %w", err) - } - - return nil -} diff --git a/internal/components/form_navigation.go b/internal/components/form_navigation.go deleted file mode 100644 index 1c2b426..0000000 --- a/internal/components/form_navigation.go +++ /dev/null @@ -1,185 +0,0 @@ -package components - -// FormNavigation handles navigation between a list component and buttons -// It provides a consistent navigation pattern: Tab switches focus, Up/Down navigates list, -// Left/Right navigates buttons, and automatically switches focus at boundaries. -type FormNavigation struct { - // List state - ListFocused bool - ListCursor int - ListLength int - ListHasFocus func() bool - ListSetFocus func(bool) - ListNavigate func(direction string) bool // Returns true if navigation was handled - ListGetCursor func() int - ListSetCursor func(int) - - // Button state - ButtonGroup *ButtonGroup -} - -// NewFormNavigation creates a new FormNavigation instance -func NewFormNavigation(listLength int, buttonGroup *ButtonGroup) *FormNavigation { - return &FormNavigation{ - ListFocused: true, - ListCursor: 0, - ListLength: listLength, - ButtonGroup: buttonGroup, - } -} - -// HandleKey processes a key press and returns: -// - handled: whether the key was handled -// - action: any action that should be taken (e.g., button action) -// - listAction: any list-specific action (e.g., toggle) -func (fn *FormNavigation) HandleKey(key string) (handled bool, action string, listAction string) { - // Tab switches focus between list and buttons - if key == "tab" { - fn.ListFocused = !fn.ListFocused - if fn.ListSetFocus != nil { - fn.ListSetFocus(fn.ListFocused) - } - if fn.ButtonGroup != nil { - fn.ButtonGroup.SetFocus(!fn.ListFocused) - } - return true, "", "" - } - - // Handle list navigation when list has focus - if fn.ListFocused { - return fn.handleListNavigation(key) - } - - // Handle button navigation when buttons have focus - if fn.ButtonGroup != nil && fn.ButtonGroup.HasFocus { - return fn.handleButtonNavigation(key) - } - - return false, "", "" -} - -// handleListNavigation handles keys when the list has focus -func (fn *FormNavigation) handleListNavigation(key string) (handled bool, action string, listAction string) { - switch key { - case "up", "k": - if fn.ListCursor > 0 { - fn.ListCursor-- - if fn.ListSetCursor != nil { - fn.ListSetCursor(fn.ListCursor) - } - return true, "", "" - } - return true, "", "" // At top, stay there - - case "down", "j": - if fn.ListCursor < fn.ListLength-1 { - fn.ListCursor++ - if fn.ListSetCursor != nil { - fn.ListSetCursor(fn.ListCursor) - } - return true, "", "" - } else { - // At bottom, move focus to buttons - fn.ListFocused = false - if fn.ListSetFocus != nil { - fn.ListSetFocus(false) - } - if fn.ButtonGroup != nil { - fn.ButtonGroup.SetFocus(true) - } - return true, "", "" - } - - case " ", "enter": - // List-specific action (e.g., toggle checkbox) - return true, "", "toggle" - - case "left", "right", "h", "l": - // Switch focus to buttons when left/right is pressed - fn.ListFocused = false - if fn.ListSetFocus != nil { - fn.ListSetFocus(false) - } - if fn.ButtonGroup != nil { - fn.ButtonGroup.SetFocus(true) - } - // Then handle the key for button navigation - return fn.handleButtonNavigation(key) - } - - // Let list handle other keys (e.g., custom navigation) - if fn.ListNavigate != nil { - if fn.ListNavigate(key) { - return true, "", "" - } - } - - return false, "", "" -} - -// handleButtonNavigation handles keys when buttons have focus -func (fn *FormNavigation) handleButtonNavigation(key string) (handled bool, action string, listAction string) { - switch key { - case "up", "k": - // Move focus back to list - fn.ListFocused = true - if fn.ListSetFocus != nil { - fn.ListSetFocus(true) - } - if fn.ButtonGroup != nil { - fn.ButtonGroup.SetFocus(false) - } - return true, "", "" - - case "left", "right", "h", "l", "tab": - // Navigate buttons - if fn.ButtonGroup != nil { - buttonAction := fn.ButtonGroup.HandleKey(key) - if buttonAction != "" { - return true, buttonAction, "" - } - return true, "", "" - } - - case "enter": - // Activate selected button - if fn.ButtonGroup != nil { - buttonAction := fn.ButtonGroup.HandleKey(key) - if buttonAction != "" { - return true, buttonAction, "" - } - return true, "", "" - } - } - - return false, "", "" -} - -// SetListFocus sets whether the list has focus -func (fn *FormNavigation) SetListFocus(focused bool) { - fn.ListFocused = focused - if fn.ListSetFocus != nil { - fn.ListSetFocus(focused) - } - if fn.ButtonGroup != nil { - fn.ButtonGroup.SetFocus(!focused) - } -} - -// SetListCursor sets the list cursor position -func (fn *FormNavigation) SetListCursor(cursor int) { - if cursor >= 0 && cursor < fn.ListLength { - fn.ListCursor = cursor - if fn.ListSetCursor != nil { - fn.ListSetCursor(cursor) - } - } -} - -// GetListCursor returns the current list cursor position -func (fn *FormNavigation) GetListCursor() int { - if fn.ListGetCursor != nil { - return fn.ListGetCursor() - } - return fn.ListCursor -} diff --git a/internal/components/inline_prompt.go b/internal/components/inline_prompt.go deleted file mode 100644 index 7b6cd67..0000000 --- a/internal/components/inline_prompt.go +++ /dev/null @@ -1,240 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "fontget/internal/ui" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -// TextPromptModel is a simple inline text input prompt -type TextPromptModel struct { - promptText string - textInput textinput.Model - value string - quitting bool - confirmed bool -} - -// NewTextPrompt creates a new inline text prompt -func NewTextPrompt(promptText, placeholder string, width int) *TextPromptModel { - if width <= 0 { - width = 60 - } - - ti := textinput.New() - ti.Placeholder = placeholder - ti.Focus() - ti.Width = width - ti.TextStyle = ui.FormInput - ti.PlaceholderStyle = ui.FormPlaceholder - - return &TextPromptModel{ - promptText: promptText, - textInput: ti, - value: "", - } -} - -func (m TextPromptModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m *TextPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - m.value = strings.TrimSpace(m.textInput.Value()) - if m.value == "" { - // Use placeholder if empty - m.value = m.textInput.Placeholder - } - m.confirmed = true - m.quitting = true - return m, tea.Quit - case tea.KeyEsc, tea.KeyCtrlC: - m.confirmed = false - m.quitting = true - return m, tea.Quit - } - } - - m.textInput, cmd = m.textInput.Update(msg) - return m, cmd -} - -func (m TextPromptModel) View() string { - if m.quitting { - return "" - } - prompt := m.promptText - if prompt == "" { - prompt = "Enter value:" - } - return fmt.Sprintf( - "%s\n%s\n%s", - ui.Text.Render(prompt), - m.textInput.View(), - ui.Text.Render("(Enter to confirm, Esc to cancel)"), - ) -} - -// Value returns the entered value -func (m *TextPromptModel) Value() string { - return m.value -} - -// Confirmed returns whether the prompt was confirmed (Enter pressed) -func (m *TextPromptModel) Confirmed() bool { - return m.confirmed -} - -// RunTextPrompt runs a simple inline text prompt and returns the value -func RunTextPrompt(promptText, placeholder string, width int) (string, bool, error) { - model := NewTextPrompt(promptText, placeholder, width) - program := tea.NewProgram(model) - - finalModel, err := program.Run() - if err != nil { - return "", false, fmt.Errorf("failed to run text prompt: %w", err) - } - - if promptModel, ok := finalModel.(*TextPromptModel); ok { - if promptModel.Confirmed() { - return promptModel.Value(), true, nil - } - return "", false, nil - } - - return "", false, nil -} - -// CheckboxPromptModel is a simple inline checkbox selection prompt -type CheckboxPromptModel struct { - title string - items []CheckboxItem - checkboxList *CheckboxList - quitting bool - confirmed bool -} - -// NewCheckboxPrompt creates a new inline checkbox prompt -func NewCheckboxPrompt(title string, items []CheckboxItem) *CheckboxPromptModel { - checkboxList := NewCheckboxList(items) - checkboxList.SetFocus(true) - - return &CheckboxPromptModel{ - title: title, - items: items, - checkboxList: checkboxList, - quitting: false, - confirmed: false, - } -} - -func (m CheckboxPromptModel) Init() tea.Cmd { - return nil -} - -func (m *CheckboxPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - key := msg.String() - - switch key { - case "enter": - // Confirm selection - m.confirmed = true - m.quitting = true - return m, tea.Quit - case "esc", "ctrl+c": - // Cancel - m.confirmed = false - m.quitting = true - return m, tea.Quit - } - - // Handle checkbox navigation - if m.checkboxList != nil { - m.checkboxList.HandleKey(key) - } - } - - return m, nil -} - -func (m CheckboxPromptModel) View() string { - if m.quitting { - return "" - } - - var result strings.Builder - if m.title != "" { - result.WriteString(ui.Text.Render(m.title)) - result.WriteString("\n") - } - result.WriteString("\n") - - if m.checkboxList != nil { - result.WriteString(m.checkboxList.Render()) - } - - result.WriteString("\n") - commands := []string{ - ui.RenderKeyWithDescription("↑/↓", "Navigate"), - ui.RenderKeyWithDescription("Space", "Toggle"), - ui.RenderKeyWithDescription("Enter", "Confirm"), - ui.RenderKeyWithDescription("Esc", "Cancel"), - } - helpText := strings.Join(commands, " ") - result.WriteString(helpText) - - return result.String() -} - -// GetSelectedIndices returns the indices of selected items -func (m *CheckboxPromptModel) GetSelectedIndices() []int { - selected := []int{} - if m.checkboxList != nil { - for i, item := range m.checkboxList.Items { - if item.Checked { - selected = append(selected, i) - } - } - } - return selected -} - -// Confirmed returns whether the prompt was confirmed -func (m *CheckboxPromptModel) Confirmed() bool { - return m.confirmed -} - -// RunCheckboxPrompt runs a simple inline checkbox prompt and returns selected indices -func RunCheckboxPrompt(title string, items []CheckboxItem) ([]int, bool, error) { - model := NewCheckboxPrompt(title, items) - program := tea.NewProgram(model) - - finalModel, err := program.Run() - if err != nil { - return nil, false, fmt.Errorf("failed to run checkbox prompt: %w", err) - } - - if promptModel, ok := finalModel.(*CheckboxPromptModel); ok { - if promptModel.Confirmed() { - selected := promptModel.GetSelectedIndices() - if len(selected) > 0 { - return selected, true, nil - } - } - return nil, false, nil - } - - return nil, false, nil -} diff --git a/internal/components/overlay.go b/internal/components/overlay.go index 8acd817..23c4e94 100644 --- a/internal/components/overlay.go +++ b/internal/components/overlay.go @@ -48,20 +48,6 @@ type OverlayOptions struct { BorderWidth int // Width of the border (0 = auto, will calculate from content) } -// NewOverlay creates a new overlay model -func NewOverlay(foreground, background tea.Model, xPos, yPos Position, xOffset, yOffset int) *OverlayModel { - return &OverlayModel{ - Foreground: foreground, - Background: background, - XPosition: xPos, - YPosition: yPos, - XOffset: xOffset, - YOffset: yOffset, - ShowBorder: false, - BorderWidth: 0, - } -} - // NewOverlayWithOptions creates a new overlay model with options func NewOverlayWithOptions(foreground, background tea.Model, xPos, yPos Position, xOffset, yOffset int, options OverlayOptions) *OverlayModel { return &OverlayModel{ diff --git a/internal/components/table_custom.go b/internal/components/table_custom.go index fac0f87..32a1240 100644 --- a/internal/components/table_custom.go +++ b/internal/components/table_custom.go @@ -10,7 +10,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" + "github.com/charmbracelet/x/ansi" ) // CustomTable is a custom table component with full viewport control @@ -298,13 +298,12 @@ func (ct *CustomTable) renderRow(rowIndex int, isSelected bool) string { cellValue = row[i] } - // Use runewidth.Truncate for proper truncation // Truncate if: column is marked truncatable, OR content exceeds column width (after scaling) // This ensures content never wraps, even for non-truncatable columns that were scaled down var truncated string - contentWidth := runewidth.StringWidth(cellValue) + contentWidth := ansi.StringWidth(cellValue) if col.Truncatable || contentWidth > width { - truncated = runewidth.Truncate(cellValue, width, "…") + truncated = ansi.Truncate(cellValue, width, "…") } else { truncated = cellValue } @@ -342,7 +341,7 @@ func (ct *CustomTable) renderRow(rowIndex int, isSelected bool) string { // If total exceeds viewport, truncate the entire row if ct.viewport.Width > 0 && expectedTotalWidth > ct.viewport.Width { // Truncate to fit viewport - rowContent = runewidth.Truncate(rowContent, ct.viewport.Width, "") + rowContent = ansi.Truncate(rowContent, ct.viewport.Width, "") } // Apply width constraint to prevent wrapping diff --git a/internal/components/table_utils.go b/internal/components/table_utils.go index 0f044cc..998e5f1 100644 --- a/internal/components/table_utils.go +++ b/internal/components/table_utils.go @@ -45,6 +45,17 @@ type ColumnConfig struct { Align string // "left", "right", "center" (default: "left") } +// DefaultFontTableColumns returns the standard Name/ID/Categories/License/Source column layout. +func DefaultFontTableColumns() []ColumnConfig { + return []ColumnConfig{ + {Header: "Font Name", Truncatable: true, Hideable: false, MinWidth: 18, Priority: 2, PercentWidth: 26.0}, + {Header: "Font ID", Truncatable: false, Hideable: false, Priority: 1, PercentWidth: 34.0}, + {Header: "Categories", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 3, PercentWidth: 15.0}, + {Header: "License", Truncatable: true, MaxWidth: 8, Hideable: true, Priority: 4, PercentWidth: 10.0}, + {Header: "Source", Truncatable: true, MaxWidth: 14, Hideable: true, Priority: 5, PercentWidth: 15.0}, + } +} + const ( // DefaultMaxTableWidth is the default maximum width for tables // This prevents tables from becoming too wide on ultrawide screens diff --git a/internal/components/unified_form.go b/internal/components/unified_form.go deleted file mode 100644 index 6f1ccc9..0000000 --- a/internal/components/unified_form.go +++ /dev/null @@ -1,687 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "fontget/internal/ui" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// FormComponentType represents different types of form components -type FormComponentType int - -const ( - ComponentTextInput FormComponentType = iota - ComponentCheckboxList - ComponentButtonGroup - ComponentCustom // For future extensibility -) - -// FormComponent represents a single component in a form -type FormComponent struct { - Type FormComponentType - ID string // Unique identifier for the component - Label string // Optional label for text inputs - - // Component instances (only one will be set based on Type) - TextInput *textinput.Model - CheckboxList *CheckboxList - ButtonGroup *ButtonGroup - - // Navigation - CanReceiveFocus bool - FocusOrder int // Order in tab sequence - - // Validation - Required bool - Validator func(value interface{}) error - - // Custom renderer (optional) - CustomRenderer func() string -} - -// UnifiedFormModel manages a complete form with mixed components -type UnifiedFormModel struct { - Title string - Components []FormComponent - FocusedIdx int // Index of currently focused component - Error string - Width int - Height int - - // Callbacks - OnSubmit func(values map[string]interface{}) error - OnCancel func() - OnValidate func() error // Custom validation - - // Navigation settings - WrapNavigation bool // Whether Tab wraps around -} - -// NewUnifiedFormModel creates a new unified form model -func NewUnifiedFormModel(title string) *UnifiedFormModel { - return &UnifiedFormModel{ - Title: title, - Components: []FormComponent{}, - FocusedIdx: 0, - Width: 80, - Height: 24, - WrapNavigation: true, - } -} - -// AddTextInput adds a text input component to the form -func (m *UnifiedFormModel) AddTextInput(id, label, placeholder string, required bool) { - input := textinput.New() - input.Placeholder = placeholder - input.Width = 50 // Default width, will be updated on window resize - input.TextStyle = ui.FormInput - input.PlaceholderStyle = ui.FormPlaceholder - - comp := FormComponent{ - Type: ComponentTextInput, - ID: id, - Label: label, - TextInput: &input, - CanReceiveFocus: true, - FocusOrder: len(m.Components), - Required: required, - } - - m.Components = append(m.Components, comp) - - // Focus first component - if len(m.Components) == 1 { - input.Focus() - } -} - -// AddCheckboxList adds a checkbox list component to the form -func (m *UnifiedFormModel) AddCheckboxList(id string, items []CheckboxItem) { - checkboxList := NewCheckboxList(items) - - comp := FormComponent{ - Type: ComponentCheckboxList, - ID: id, - CheckboxList: checkboxList, - CanReceiveFocus: true, - FocusOrder: len(m.Components), - Required: false, - } - - m.Components = append(m.Components, comp) -} - -// AddButtonGroup adds a button group component to the form -func (m *UnifiedFormModel) AddButtonGroup(id string, buttons []string, defaultIdx int) { - buttonGroup := NewButtonGroup(buttons, defaultIdx) - - comp := FormComponent{ - Type: ComponentButtonGroup, - ID: id, - ButtonGroup: buttonGroup, - CanReceiveFocus: true, - FocusOrder: len(m.Components), - Required: false, - } - - m.Components = append(m.Components, comp) -} - -// Init initializes the form model -func (m UnifiedFormModel) Init() tea.Cmd { - // Return blink command if first component is a text input - if len(m.Components) > 0 && m.Components[0].Type == ComponentTextInput { - return textinput.Blink - } - return nil -} - -// Update handles messages and updates the form -func (m *UnifiedFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - // Handle window resize - if msg, ok := msg.(tea.WindowSizeMsg); ok { - m.Width = msg.Width - m.Height = msg.Height - m.updateInputWidths() - return m, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - key := msg.String() - - // Handle Tab keys - if msg.Type == tea.KeyTab { - m.navigateForward() - if m.FocusedIdx < len(m.Components) && m.Components[m.FocusedIdx].Type == ComponentTextInput { - return m, textinput.Blink - } - return m, nil - } - - switch key { - case "esc": - if m.OnCancel != nil { - m.OnCancel() - } - return m, tea.Quit - - case "shift+tab": - m.navigateBackward() - if m.FocusedIdx < len(m.Components) && m.Components[m.FocusedIdx].Type == ComponentTextInput { - return m, textinput.Blink - } - return m, nil - - case "enter": - // Handle enter based on focused component - if m.FocusedIdx < len(m.Components) { - comp := &m.Components[m.FocusedIdx] - switch comp.Type { - case ComponentButtonGroup: - // Button group handles enter internally - action := comp.ButtonGroup.HandleKey(key) - if action != "" { - // Button was activated - could trigger submit - if m.OnSubmit != nil { - values := m.GetValues() - if err := m.OnSubmit(values); err != nil { - m.Error = err.Error() - return m, nil - } - return m, tea.Quit - } - } - case ComponentCheckboxList: - // Toggle checkbox - if comp.CheckboxList.Cursor >= 0 && comp.CheckboxList.Cursor < len(comp.CheckboxList.Items) { - comp.CheckboxList.Items[comp.CheckboxList.Cursor].Checked = !comp.CheckboxList.Items[comp.CheckboxList.Cursor].Checked - } - default: - // Text input - validate and submit - if m.validateForm() { - if m.OnSubmit != nil { - values := m.GetValues() - if err := m.OnSubmit(values); err != nil { - m.Error = err.Error() - return m, nil - } - return m, tea.Quit - } - } - } - } - - case "ctrl+c": - return m, tea.Quit - } - } - - // Update focused component - if m.FocusedIdx < len(m.Components) { - comp := &m.Components[m.FocusedIdx] - switch comp.Type { - case ComponentTextInput: - if comp.TextInput != nil { - var cmd tea.Cmd - *comp.TextInput, cmd = comp.TextInput.Update(msg) - return m, cmd - } - case ComponentCheckboxList: - if comp.CheckboxList != nil { - handled := comp.CheckboxList.HandleKey(msg.(tea.KeyMsg).String()) - if handled { - return m, nil - } - } - case ComponentButtonGroup: - if comp.ButtonGroup != nil { - action := comp.ButtonGroup.HandleKey(msg.(tea.KeyMsg).String()) - if action != "" { - // Button action - could trigger submit - if m.OnSubmit != nil { - values := m.GetValues() - if err := m.OnSubmit(values); err != nil { - m.Error = err.Error() - return m, nil - } - return m, tea.Quit - } - } - return m, nil - } - } - } - - return m, cmd -} - -// navigateForward moves focus to the next component -func (m *UnifiedFormModel) navigateForward() { - if len(m.Components) == 0 { - return - } - if m.WrapNavigation { - m.FocusedIdx = (m.FocusedIdx + 1) % len(m.Components) - } else { - if m.FocusedIdx < len(m.Components)-1 { - m.FocusedIdx++ - } - } - m.updateFocus() -} - -// navigateBackward moves focus to the previous component -func (m *UnifiedFormModel) navigateBackward() { - if len(m.Components) == 0 { - return - } - if m.WrapNavigation { - m.FocusedIdx = (m.FocusedIdx - 1 + len(m.Components)) % len(m.Components) - } else { - if m.FocusedIdx > 0 { - m.FocusedIdx-- - } - } - m.updateFocus() -} - -// updateFocus updates which component is focused -func (m *UnifiedFormModel) updateFocus() { - // Blur all components - for i := range m.Components { - m.blurComponent(i) - } - - // Focus current component - if m.FocusedIdx < len(m.Components) { - m.focusComponent(m.FocusedIdx) - } -} - -// focusComponent focuses a specific component -func (m *UnifiedFormModel) focusComponent(idx int) { - if idx >= len(m.Components) { - return - } - - comp := &m.Components[idx] - if !comp.CanReceiveFocus { - return - } - - switch comp.Type { - case ComponentTextInput: - if comp.TextInput != nil { - comp.TextInput.Focus() - } - case ComponentCheckboxList: - if comp.CheckboxList != nil { - comp.CheckboxList.HasFocus = true - } - case ComponentButtonGroup: - if comp.ButtonGroup != nil { - comp.ButtonGroup.SetFocus(true) - } - } -} - -// blurComponent blurs a specific component -func (m *UnifiedFormModel) blurComponent(idx int) { - if idx >= len(m.Components) { - return - } - - comp := &m.Components[idx] - - switch comp.Type { - case ComponentTextInput: - if comp.TextInput != nil { - comp.TextInput.Blur() - } - case ComponentCheckboxList: - if comp.CheckboxList != nil { - comp.CheckboxList.HasFocus = false - } - case ComponentButtonGroup: - if comp.ButtonGroup != nil { - comp.ButtonGroup.SetFocus(false) - } - } -} - -// View renders the form -func (m UnifiedFormModel) View() string { - var result strings.Builder - - // Title - if m.Title != "" { - result.WriteString(ui.PageTitle.Render(m.Title)) - result.WriteString("\n\n") - } - - // Render components - for i, comp := range m.Components { - result.WriteString(m.renderComponent(comp)) - if i < len(m.Components)-1 { - result.WriteString("\n") - } - } - - // Render error if any - if m.Error != "" { - result.WriteString("\n") - result.WriteString(ui.RenderError(m.Error)) - result.WriteString("\n") - } - - // Render commands - commands := m.getCommands() - helpText := strings.Join(commands, " ") - result.WriteString("\n") - result.WriteString(helpText) - - return result.String() -} - -// renderComponent renders a single component -func (m UnifiedFormModel) renderComponent(comp FormComponent) string { - switch comp.Type { - case ComponentTextInput: - return m.renderTextInput(comp) - case ComponentCheckboxList: - if comp.CheckboxList != nil { - return comp.CheckboxList.Render() - } - case ComponentButtonGroup: - if comp.ButtonGroup != nil { - return comp.ButtonGroup.Render() - } - case ComponentCustom: - if comp.CustomRenderer != nil { - return comp.CustomRenderer() - } - } - return "" -} - -// renderTextInput renders a text input component -func (m UnifiedFormModel) renderTextInput(comp FormComponent) string { - if comp.TextInput == nil { - return "" - } - - fieldValue := comp.TextInput.View() - - // Apply background styling if needed (similar to old TextInput component) - colors := ui.GetCurrentColors() - if colors != nil { - width := comp.TextInput.Width - if width == 0 { - width = 50 - } - contentWidth := width - 2 // Account for padding - - // Ensure the input view is at least the content width - actualInputWidth := lipgloss.Width(fieldValue) - if actualInputWidth < contentWidth { - paddingNeeded := contentWidth - actualInputWidth - padding := strings.Repeat(" ", paddingNeeded) - paddingStyle := lipgloss.NewStyle(). - Background(lipgloss.Color(colors.Base)) - paddedInputView := fieldValue + paddingStyle.Render(padding) - fieldValue = paddedInputView - } - - // Create background style - bgStyle := lipgloss.NewStyle(). - Background(lipgloss.Color(colors.Base)). - Width(width). - Padding(0, 1) - - fieldValue = bgStyle.Render(fieldValue) - } - - if comp.Label != "" { - styledLabel := ui.FormLabel.Render(comp.Label) - return fmt.Sprintf(" %s %s %s", styledLabel, " ", fieldValue) - } - - return fieldValue -} - -// getCommands returns the command help text -func (m UnifiedFormModel) getCommands() []string { - return []string{ - ui.RenderKeyWithDescription("Tab/Shift+Tab", "Move"), - ui.RenderKeyWithDescription("Enter", "Submit/Select"), - ui.RenderKeyWithDescription("Esc", "Cancel"), - } -} - -// updateInputWidths updates the width of text inputs based on terminal size -func (m *UnifiedFormModel) updateInputWidths() { - width := m.calculateInputWidth() - for i := range m.Components { - if m.Components[i].Type == ComponentTextInput && m.Components[i].TextInput != nil { - m.Components[i].TextInput.Width = width - } - } -} - -// calculateInputWidth calculates the appropriate input width based on terminal size -func (m UnifiedFormModel) calculateInputWidth() int { - width := m.Width - 20 // Account for margins and labels - if width < 30 { - width = 30 - } - if width > 80 { - width = 80 - } - return width -} - -// validateForm validates the form inputs -func (m *UnifiedFormModel) validateForm() bool { - // Custom validation - if m.OnValidate != nil { - if err := m.OnValidate(); err != nil { - m.Error = err.Error() - return false - } - } - - // Component-level validation - for i, comp := range m.Components { - if comp.Required { - var value interface{} - switch comp.Type { - case ComponentTextInput: - if comp.TextInput != nil { - value = strings.TrimSpace(comp.TextInput.Value()) - } - case ComponentCheckboxList: - if comp.CheckboxList != nil { - // Check if at least one is selected - hasSelected := false - for _, item := range comp.CheckboxList.Items { - if item.Checked { - hasSelected = true - break - } - } - value = hasSelected - } - } - - // Check if required field is empty - if value == nil || value == "" || value == false { - m.Error = fmt.Sprintf("%s is required", comp.Label) - m.FocusedIdx = i - m.updateFocus() - return false - } - } - - // Custom validator - if comp.Validator != nil { - var value interface{} - switch comp.Type { - case ComponentTextInput: - if comp.TextInput != nil { - value = comp.TextInput.Value() - } - case ComponentCheckboxList: - if comp.CheckboxList != nil { - value = comp.CheckboxList - } - case ComponentButtonGroup: - if comp.ButtonGroup != nil { - value = comp.ButtonGroup - } - } - - if err := comp.Validator(value); err != nil { - m.Error = err.Error() - m.FocusedIdx = i - m.updateFocus() - return false - } - } - } - - m.Error = "" - return true -} - -// GetValues returns a map of component values -func (m UnifiedFormModel) GetValues() map[string]interface{} { - values := make(map[string]interface{}) - - for _, comp := range m.Components { - switch comp.Type { - case ComponentTextInput: - if comp.TextInput != nil { - values[comp.ID] = strings.TrimSpace(comp.TextInput.Value()) - } - case ComponentCheckboxList: - if comp.CheckboxList != nil { - // Return selected items - selected := []int{} - for i, item := range comp.CheckboxList.Items { - if item.Checked { - selected = append(selected, i) - } - } - values[comp.ID] = selected - } - case ComponentButtonGroup: - if comp.ButtonGroup != nil { - values[comp.ID] = comp.ButtonGroup.Selected - } - } - } - - return values -} - -// SetValue sets a value for a component by ID -func (m *UnifiedFormModel) SetValue(id string, value interface{}) { - for i := range m.Components { - if m.Components[i].ID == id { - switch m.Components[i].Type { - case ComponentTextInput: - if m.Components[i].TextInput != nil { - if str, ok := value.(string); ok { - m.Components[i].TextInput.SetValue(str) - } - } - } - break - } - } -} - -// GetTextInputValue gets a text input value by ID -func (m UnifiedFormModel) GetTextInputValue(id string) string { - for _, comp := range m.Components { - if comp.ID == id && comp.Type == ComponentTextInput && comp.TextInput != nil { - return comp.TextInput.Value() - } - } - return "" -} - -// GetCheckboxListSelected gets selected indices from a checkbox list by ID -func (m UnifiedFormModel) GetCheckboxListSelected(id string) []int { - for _, comp := range m.Components { - if comp.ID == id && comp.Type == ComponentCheckboxList && comp.CheckboxList != nil { - selected := []int{} - for i, item := range comp.CheckboxList.Items { - if item.Checked { - selected = append(selected, i) - } - } - return selected - } - } - return []int{} -} - -// SetError sets the error message -func (m *UnifiedFormModel) SetError(err string) { - m.Error = err -} - -// Helper functions for common component creation - -// NewTextInputComponent creates a FormComponent for a text input -func NewTextInputComponent(id, label, placeholder string, required bool) FormComponent { - input := textinput.New() - input.Placeholder = placeholder - input.Width = 50 - input.TextStyle = ui.FormInput - input.PlaceholderStyle = ui.FormPlaceholder - - return FormComponent{ - Type: ComponentTextInput, - ID: id, - Label: label, - TextInput: &input, - CanReceiveFocus: true, - Required: required, - } -} - -// NewCheckboxListComponent creates a FormComponent for a checkbox list -func NewCheckboxListComponent(id string, items []CheckboxItem) FormComponent { - checkboxList := NewCheckboxList(items) - - return FormComponent{ - Type: ComponentCheckboxList, - ID: id, - CheckboxList: checkboxList, - CanReceiveFocus: true, - Required: false, - } -} - -// NewButtonGroupComponent creates a FormComponent for a button group -func NewButtonGroupComponent(id string, buttons []string, defaultIdx int) FormComponent { - buttonGroup := NewButtonGroup(buttons, defaultIdx) - - return FormComponent{ - Type: ComponentButtonGroup, - ID: id, - ButtonGroup: buttonGroup, - CanReceiveFocus: true, - Required: false, - } -} diff --git a/internal/components/unified_form_test.go b/internal/components/unified_form_test.go deleted file mode 100644 index 7cf50e0..0000000 --- a/internal/components/unified_form_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package components - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestUnifiedFormModel_AddTextInput(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("name", "Name", "Enter name", true) - - if len(form.Components) != 1 { - t.Errorf("AddTextInput() len(Components) = %d, want 1", len(form.Components)) - } - - comp := form.Components[0] - if comp.Type != ComponentTextInput { - t.Errorf("AddTextInput() Type = %v, want ComponentTextInput", comp.Type) - } - if comp.ID != "name" { - t.Errorf("AddTextInput() ID = %q, want %q", comp.ID, "name") - } - if comp.Label != "Name" { - t.Errorf("AddTextInput() Label = %q, want %q", comp.Label, "Name") - } - if comp.TextInput == nil { - t.Error("AddTextInput() TextInput is nil") - } - if !comp.Required { - t.Error("AddTextInput() Required = false, want true") - } -} - -func TestUnifiedFormModel_AddCheckboxList(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - items := []CheckboxItem{ - {Label: "Item 1", Checked: false, Enabled: true}, - {Label: "Item 2", Checked: true, Enabled: true}, - } - form.AddCheckboxList("scopes", items) - - if len(form.Components) != 1 { - t.Errorf("AddCheckboxList() len(Components) = %d, want 1", len(form.Components)) - } - - comp := form.Components[0] - if comp.Type != ComponentCheckboxList { - t.Errorf("AddCheckboxList() Type = %v, want ComponentCheckboxList", comp.Type) - } - if comp.ID != "scopes" { - t.Errorf("AddCheckboxList() ID = %q, want %q", comp.ID, "scopes") - } - if comp.CheckboxList == nil { - t.Error("AddCheckboxList() CheckboxList is nil") - } -} - -func TestUnifiedFormModel_AddButtonGroup(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddButtonGroup("actions", []string{"OK", "Cancel"}, 0) - - if len(form.Components) != 1 { - t.Errorf("AddButtonGroup() len(Components) = %d, want 1", len(form.Components)) - } - - comp := form.Components[0] - if comp.Type != ComponentButtonGroup { - t.Errorf("AddButtonGroup() Type = %v, want ComponentButtonGroup", comp.Type) - } - if comp.ID != "actions" { - t.Errorf("AddButtonGroup() ID = %q, want %q", comp.ID, "actions") - } - if comp.ButtonGroup == nil { - t.Error("AddButtonGroup() ButtonGroup is nil") - } -} - -func TestUnifiedFormModel_Navigation(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("field1", "Field 1", "Enter value", false) - form.AddTextInput("field2", "Field 2", "Enter value", false) - form.AddButtonGroup("actions", []string{"OK"}, 0) - - // Test forward navigation - if form.FocusedIdx != 0 { - t.Errorf("Initial FocusedIdx = %d, want 0", form.FocusedIdx) - } - - form.navigateForward() - if form.FocusedIdx != 1 { - t.Errorf("navigateForward() FocusedIdx = %d, want 1", form.FocusedIdx) - } - - form.navigateForward() - if form.FocusedIdx != 2 { - t.Errorf("navigateForward() FocusedIdx = %d, want 2", form.FocusedIdx) - } - - // Test wrap around - form.navigateForward() - if form.FocusedIdx != 0 { - t.Errorf("navigateForward() with wrap FocusedIdx = %d, want 0", form.FocusedIdx) - } - - // Test backward navigation - form.navigateBackward() - if form.FocusedIdx != 2 { - t.Errorf("navigateBackward() FocusedIdx = %d, want 2", form.FocusedIdx) - } -} - -func TestUnifiedFormModel_GetValues(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("name", "Name", "Enter name", false) - form.AddCheckboxList("scopes", []CheckboxItem{ - {Label: "Item 1", Checked: true, Enabled: true}, - {Label: "Item 2", Checked: false, Enabled: true}, - {Label: "Item 3", Checked: true, Enabled: true}, - }) - form.AddButtonGroup("actions", []string{"OK", "Cancel"}, 1) - - // Set text input value - if form.Components[0].TextInput != nil { - form.Components[0].TextInput.SetValue("Test Name") - } - - values := form.GetValues() - - // Check text input value - if name, ok := values["name"].(string); !ok || name != "Test Name" { - t.Errorf("GetValues() name = %v, want %q", values["name"], "Test Name") - } - - // Check checkbox list selected indices - if selected, ok := values["scopes"].([]int); !ok { - t.Errorf("GetValues() scopes type = %T, want []int", values["scopes"]) - } else { - if len(selected) != 2 { - t.Errorf("GetValues() scopes len = %d, want 2", len(selected)) - } - if selected[0] != 0 || selected[1] != 2 { - t.Errorf("GetValues() scopes = %v, want [0, 2]", selected) - } - } - - // Check button group selected - if selected, ok := values["actions"].(int); !ok || selected != 1 { - t.Errorf("GetValues() actions = %v, want 1", values["actions"]) - } -} - -func TestUnifiedFormModel_ValidateForm(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("name", "Name", "Enter name", true) - form.AddTextInput("optional", "Optional", "Enter value", false) - - // Test required field validation - if form.validateForm() { - t.Error("validateForm() should fail when required field is empty") - } - if form.Error == "" { - t.Error("validateForm() should set Error when validation fails") - } - - // Set required field value - if form.Components[0].TextInput != nil { - form.Components[0].TextInput.SetValue("Test Name") - } - - if !form.validateForm() { - t.Error("validateForm() should pass when required field is filled") - } - if form.Error != "" { - t.Errorf("validateForm() Error = %q, want empty", form.Error) - } -} - -func TestUnifiedFormModel_Update_TabNavigation(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("field1", "Field 1", "Enter value", false) - form.AddTextInput("field2", "Field 2", "Enter value", false) - - // Test Tab navigation - msg := tea.KeyMsg{Type: tea.KeyTab} - _, _ = form.Update(msg) - - if form.FocusedIdx != 1 { - t.Errorf("Update(Tab) FocusedIdx = %d, want 1", form.FocusedIdx) - } - - // Test Shift+Tab navigation - msg = tea.KeyMsg{Type: tea.KeyShiftTab} - _, _ = form.Update(msg) - - if form.FocusedIdx != 0 { - t.Errorf("Update(Shift+Tab) FocusedIdx = %d, want 0", form.FocusedIdx) - } -} - -func TestUnifiedFormModel_Update_Escape(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("field1", "Field 1", "Enter value", false) - - cancelled := false - form.OnCancel = func() { - cancelled = true - } - - msg := tea.KeyMsg{Type: tea.KeyEsc} - _, cmd := form.Update(msg) - - if !cancelled { - t.Error("Update(Esc) should call OnCancel") - } - - _ = cmd // Suppress unused variable warning -} - -func TestUnifiedFormModel_GetTextInputValue(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("name", "Name", "Enter name", false) - - if form.Components[0].TextInput != nil { - form.Components[0].TextInput.SetValue("Test Value") - } - - value := form.GetTextInputValue("name") - if value != "Test Value" { - t.Errorf("GetTextInputValue() = %q, want %q", value, "Test Value") - } - - // Test non-existent ID - value = form.GetTextInputValue("nonexistent") - if value != "" { - t.Errorf("GetTextInputValue(nonexistent) = %q, want empty", value) - } -} - -func TestUnifiedFormModel_GetCheckboxListSelected(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddCheckboxList("scopes", []CheckboxItem{ - {Label: "Item 1", Checked: true, Enabled: true}, - {Label: "Item 2", Checked: false, Enabled: true}, - {Label: "Item 3", Checked: true, Enabled: true}, - }) - - selected := form.GetCheckboxListSelected("scopes") - if len(selected) != 2 { - t.Errorf("GetCheckboxListSelected() len = %d, want 2", len(selected)) - } - if selected[0] != 0 || selected[1] != 2 { - t.Errorf("GetCheckboxListSelected() = %v, want [0, 2]", selected) - } - - // Test non-existent ID - selected = form.GetCheckboxListSelected("nonexistent") - if len(selected) != 0 { - t.Errorf("GetCheckboxListSelected(nonexistent) = %v, want []", selected) - } -} - -func TestUnifiedFormModel_SetValue(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("name", "Name", "Enter name", false) - - form.SetValue("name", "New Value") - - value := form.GetTextInputValue("name") - if value != "New Value" { - t.Errorf("SetValue() value = %q, want %q", value, "New Value") - } -} - -func TestUnifiedFormModel_FocusManagement(t *testing.T) { - form := NewUnifiedFormModel("Test Form") - form.AddTextInput("field1", "Field 1", "Enter value", false) - form.AddCheckboxList("scopes", []CheckboxItem{ - {Label: "Item 1", Checked: false, Enabled: true}, - }) - - // Test focus component - form.focusComponent(0) - if form.Components[0].TextInput == nil || !form.Components[0].TextInput.Focused() { - t.Error("focusComponent(0) should focus text input") - } - - form.focusComponent(1) - if form.Components[1].CheckboxList == nil || !form.Components[1].CheckboxList.HasFocus { - t.Error("focusComponent(1) should focus checkbox list") - } - - // Test blur component - form.blurComponent(0) - if form.Components[0].TextInput != nil && form.Components[0].TextInput.Focused() { - t.Error("blurComponent(0) should blur text input") - } - - form.blurComponent(1) - if form.Components[1].CheckboxList != nil && form.Components[1].CheckboxList.HasFocus { - t.Error("blurComponent(1) should blur checkbox list") - } -} - -func TestNewTextInputComponent(t *testing.T) { - comp := NewTextInputComponent("test", "Test Label", "Enter value", true) - - if comp.Type != ComponentTextInput { - t.Errorf("NewTextInputComponent() Type = %v, want ComponentTextInput", comp.Type) - } - if comp.ID != "test" { - t.Errorf("NewTextInputComponent() ID = %q, want %q", comp.ID, "test") - } - if comp.Label != "Test Label" { - t.Errorf("NewTextInputComponent() Label = %q, want %q", comp.Label, "Test Label") - } - if comp.TextInput == nil { - t.Error("NewTextInputComponent() TextInput is nil") - } - if !comp.Required { - t.Error("NewTextInputComponent() Required = false, want true") - } -} - -func TestNewCheckboxListComponent(t *testing.T) { - items := []CheckboxItem{ - {Label: "Item 1", Checked: false, Enabled: true}, - } - comp := NewCheckboxListComponent("test", items) - - if comp.Type != ComponentCheckboxList { - t.Errorf("NewCheckboxListComponent() Type = %v, want ComponentCheckboxList", comp.Type) - } - if comp.ID != "test" { - t.Errorf("NewCheckboxListComponent() ID = %q, want %q", comp.ID, "test") - } - if comp.CheckboxList == nil { - t.Error("NewCheckboxListComponent() CheckboxList is nil") - } -} - -func TestNewButtonGroupComponent(t *testing.T) { - comp := NewButtonGroupComponent("test", []string{"OK", "Cancel"}, 0) - - if comp.Type != ComponentButtonGroup { - t.Errorf("NewButtonGroupComponent() Type = %v, want ComponentButtonGroup", comp.Type) - } - if comp.ID != "test" { - t.Errorf("NewButtonGroupComponent() ID = %q, want %q", comp.ID, "test") - } - if comp.ButtonGroup == nil { - t.Error("NewButtonGroupComponent() ButtonGroup is nil") - } -} diff --git a/internal/functions/doc.go b/internal/functions/doc.go deleted file mode 100644 index 9eeff8e..0000000 --- a/internal/functions/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package functions contains feature/domain-specific helpers. -// -// Prefer this package when helpers are tightly coupled to specific domain types (e.g. sources -// management). For CLI-agnostic, broadly reusable utilities, prefer `internal/shared`. -package functions diff --git a/internal/functions/sort.go b/internal/functions/sort.go deleted file mode 100644 index a2b6bcc..0000000 --- a/internal/functions/sort.go +++ /dev/null @@ -1,65 +0,0 @@ -package functions - -import ( - "fontget/internal/config" - "sort" -) - -// SourceItem represents a source for sorting purposes -type SourceItem struct { - Name string - Prefix string - URL string - Enabled bool - IsBuiltIn bool - Priority int -} - -// SortSources sorts sources by type (built-in first) then by priority order -func SortSources(sources []SourceItem) { - sort.Slice(sources, func(i, j int) bool { - // Built-in sources come first - if sources[i].IsBuiltIn != sources[j].IsBuiltIn { - return sources[i].IsBuiltIn - } - // Within same type, sort by priority (lower number = higher priority) - if sources[i].Priority != sources[j].Priority { - return sources[i].Priority < sources[j].Priority - } - // If priorities are equal, sort by name - return sources[i].Name < sources[j].Name - }) -} - -// GetEnabledSourcesInOrder returns enabled sources in priority order from config manifest -func GetEnabledSourcesInOrder(manifest *config.Manifest) []string { - var sources []SourceItem - - for name, source := range manifest.Sources { - if source.Enabled { - sources = append(sources, SourceItem{ - Name: name, - Priority: source.Priority, - }) - } - } - - // Sort by priority - SortSources(sources) - - var result []string - for _, source := range sources { - result = append(result, source.Name) - } - return result -} - -// FindSourceIndex finds the index of a source by name -func FindSourceIndex(sources []SourceItem, name string) int { - for i, source := range sources { - if source.Name == name { - return i - } - } - return -1 -} diff --git a/internal/installations/lock.go b/internal/installations/lock.go index 0d13991..157a450 100644 --- a/internal/installations/lock.go +++ b/internal/installations/lock.go @@ -7,7 +7,7 @@ import ( "path/filepath" "time" - "fontget/internal/shared" + "fontget/internal/network" ) func registryLockPath() string { @@ -28,11 +28,11 @@ func withRegistryFileLock(ctx context.Context, fn func() error) error { func lockFile(ctx context.Context, path string) (func(), error) { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { - return nil, fmt.Errorf("%w: lock dir: %v", shared.ErrLocalFailure, err) + return nil, fmt.Errorf("%w: lock dir: %v", network.ErrLocalFailure, err) } f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { - return nil, fmt.Errorf("%w: open lock: %v", shared.ErrLocalFailure, err) + return nil, fmt.Errorf("%w: open lock: %v", network.ErrLocalFailure, err) } deadline := time.Now().Add(30 * time.Second) for { diff --git a/internal/installations/recovery.go b/internal/installations/recovery.go index ee52d47..e569d84 100644 --- a/internal/installations/recovery.go +++ b/internal/installations/recovery.go @@ -8,6 +8,7 @@ import ( "time" "fontget/internal/config" + "fontget/internal/platform" "fontget/internal/shared" ) @@ -45,7 +46,11 @@ func SaveRecoveryRecord(rec RecoveryRecord) (string, error) { } name := rec.OperationID if rec.PackageID != "" { - name = rec.OperationID + "-" + sanitizeFilePart(rec.PackageID) + part := platform.SanitizePathPart(rec.PackageID) + if part == "" { + part = "package" + } + name = rec.OperationID + "-" + part } path := filepath.Join(dir, name+".json") payload, err := json.MarshalIndent(rec, "", " ") @@ -57,22 +62,3 @@ func SaveRecoveryRecord(rec RecoveryRecord) (string, error) { } return path, nil } - -func sanitizeFilePart(s string) string { - out := make([]rune, 0, len(s)) - for _, r := range s { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': - out = append(out, r) - default: - out = append(out, '_') - } - } - if len(out) > 80 { - out = out[:80] - } - if len(out) == 0 { - return "package" - } - return string(out) -} diff --git a/internal/license/license.go b/internal/license/license.go deleted file mode 100644 index 9c04954..0000000 --- a/internal/license/license.go +++ /dev/null @@ -1,178 +0,0 @@ -package license - -import ( - "bufio" - "fmt" - "os" - "strings" - - "fontget/internal/components" - "fontget/internal/config" - "fontget/internal/onboarding" - "fontget/internal/repo" - "fontget/internal/ui" -) - -// PromptForSourceAcceptance prompts the user to accept licenses for a source -// This function uses styled UI components for better presentation -func PromptForSourceAcceptance(sourceName string) (bool, error) { - // Display terms of use (shared with onboarding) - fmt.Println() - fmt.Println(ui.PageTitle.Render("Terms of Use")) - fmt.Println() - fmt.Println(ui.Text.Render(onboarding.TermsOfUseIntroText())) - fmt.Println() - fmt.Printf("%s %s\n", ui.InfoText.Render("Source:"), ui.TableSourceName.Render(sourceName)) - fmt.Println() - fmt.Println(ui.Text.Render("To review a particular font's license, run:")) - fmt.Printf(" %s\n", ui.Text.Render("fontget info --license")) - fmt.Println() - - // Use confirmation dialog for better UX - message := fmt.Sprintf("Do you accept the license agreements from %s?", ui.TableSourceName.Render(sourceName)) - confirmed, err := components.RunConfirm( - "", - message, - ) - if err != nil { - // Fallback to basic prompt if confirmation dialog fails - // User-friendly error handling per verbose/debug guidelines - return promptForSourceAcceptanceFallback(sourceName) - } - - // Section ends - confirmation dialog handles its own spacing via alt screen - return confirmed, nil -} - -// promptForSourceAcceptanceFallback provides a basic fallback if the confirmation dialog fails -func promptForSourceAcceptanceFallback(sourceName string) (bool, error) { - fmt.Print("Do you accept? (y/n): ") - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - // User-friendly error message per verbose/debug guidelines - return false, fmt.Errorf("unable to read response: %w", err) - } - - response = strings.ToLower(strings.TrimSpace(response)) - return response == "y" || response == "yes", nil -} - -// CheckAndPromptForSource checks if a source is accepted and prompts if needed -func CheckAndPromptForSource(sourceName string) error { - // Check if source is already accepted - accepted, err := config.IsSourceAccepted(sourceName) - if err != nil { - return fmt.Errorf("failed to check source acceptance: %w", err) - } - - if accepted { - return nil // Source already accepted - } - - // Prompt user for acceptance - accepted, err = PromptForSourceAcceptance(sourceName) - if err != nil { - // User-friendly error message per verbose/debug guidelines - return fmt.Errorf("unable to prompt for license acceptance: %w", err) - } - - if !accepted { - return fmt.Errorf("license acceptance required to continue") - } - - // Save acceptance - if err := config.AcceptSource(sourceName); err != nil { - // User-friendly error message per verbose/debug guidelines - return fmt.Errorf("unable to save license acceptance: %w", err) - } - - return nil -} - -// CheckFirstRunAndPrompt checks if this is the first run and prompts for Google Fonts acceptance -// DEPRECATED: This function is kept for backward compatibility. -// New code should use onboarding.RunFirstRunOnboarding() directly from cmd/root.go -func CheckFirstRunAndPrompt() error { - // Check if this is the first run - isFirstRun, err := config.IsFirstRun() - if err != nil { - return fmt.Errorf("failed to check first run status: %w", err) - } - - if !isFirstRun { - return nil // Not first run, continue normally - } - - // Show welcome message with styled UI - fmt.Println() - fmt.Println(ui.PageTitle.Render("Welcome to FontGet!")) - fmt.Println() - fmt.Println(ui.Text.Render("This is your first time using FontGet. Let's get you set up.")) - fmt.Println() - - // Check if Google Fonts is already accepted (shouldn't be on first run, but just in case) - accepted, err := config.IsSourceAccepted("google-fonts") - if err != nil { - return fmt.Errorf("failed to check Google Fonts acceptance: %w", err) - } - - if accepted { - // Mark first run as completed and continue - return config.MarkFirstRunCompleted() - } - - // Prompt for Google Fonts acceptance - if err := CheckAndPromptForSource("google-fonts"); err != nil { - return err - } - - // Mark first run as completed - return config.MarkFirstRunCompleted() -} - -// GetLicenseURL returns the license URL for a font from a specific source -func GetLicenseURL(fontName, source string) string { - switch source { - case "google-fonts": - // Google Fonts OFL license pattern - normalizedName := strings.ToLower(strings.ReplaceAll(fontName, " ", "")) - return fmt.Sprintf("https://raw.githubusercontent.com/google/fonts/main/ofl/%s/OFL.txt", normalizedName) - // Future sources can be added here - default: - return "" - } -} - -// FetchLicenseText fetches license text from a URL with cross-platform compatibility -func FetchLicenseText(url string) (string, error) { - return repo.FetchURLContent(url) -} - -// DisplayLicenseText displays license text with cross-platform pagination -func DisplayLicenseText(content string) error { - lines := strings.Split(content, "\n") - - for i, line := range lines { - fmt.Println(line) - - // Pagination every 20 lines (cross-platform) - if (i+1)%20 == 0 && i < len(lines)-1 { - fmt.Print("\nPress Enter to continue...") - reader := bufio.NewReader(os.Stdin) - if _, err := reader.ReadString('\n'); err != nil { - return fmt.Errorf("read pagination input: %w", err) - } - } - } - - return nil -} - -// HandleLicenseError displays a user-friendly error message for license issues -func HandleLicenseError(fontName string, err error) { - fmt.Printf("License not found for \"%s\". Please review the license agreement for this font before using it.\n\n", fontName) - fmt.Println("You can try:") - fmt.Printf("- fontget info \"%s\" (to see available license info)\n", fontName) - fmt.Println("- Visit the font's source URL for license details") -} diff --git a/internal/logging/config.go b/internal/logging/config.go index 2562353..d7b51eb 100644 --- a/internal/logging/config.go +++ b/internal/logging/config.go @@ -1,13 +1,5 @@ package logging -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "runtime" -) - // DefaultConfig returns the default logging configuration func DefaultConfig() Config { return Config{ @@ -18,92 +10,3 @@ func DefaultConfig() Config { Compress: true, // Compress old logs } } - -// LoadConfig loads the logging configuration from a file -func LoadConfig(configPath string) (Config, error) { - // If no config path is provided, use the default location - if configPath == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - return DefaultConfig(), fmt.Errorf("failed to get user home directory: %w", err) - } - - switch runtime.GOOS { - case "windows": - configPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "FontGet", "config", "logging.json") - case "darwin": - configPath = filepath.Join(homeDir, "Library", "Application Support", "fontget", "config", "logging.json") - default: // Linux and others - configPath = filepath.Join(homeDir, ".config", "fontget", "logging.json") - } - } - - // Create config directory if it doesn't exist - configDir := filepath.Dir(configPath) - if err := os.MkdirAll(configDir, 0750); err != nil { - return DefaultConfig(), fmt.Errorf("failed to create config directory: %w", err) - } - - // If config file doesn't exist, create it with defaults - if _, err := os.Stat(configPath); os.IsNotExist(err) { - config := DefaultConfig() - if err := SaveConfig(configPath, config); err != nil { - return config, fmt.Errorf("failed to create default config: %w", err) - } - return config, nil - } - - // Read and parse the config file - data, err := os.ReadFile(configPath) - if err != nil { - return DefaultConfig(), fmt.Errorf("failed to read config file: %w", err) - } - - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return DefaultConfig(), fmt.Errorf("failed to parse config file: %w", err) - } - - return config, nil -} - -// SaveConfig saves the logging configuration to a file -func SaveConfig(configPath string, config Config) error { - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - if err := os.WriteFile(configPath, data, 0600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - return nil -} - -// UpdateConfig updates specific fields in the logging configuration -func UpdateConfig(configPath string, updates map[string]interface{}) error { - config, err := LoadConfig(configPath) - if err != nil { - return err - } - - // Update config fields based on the updates map - if level, ok := updates["level"].(int); ok { - config.Level = LogLevel(level) - } - if maxSize, ok := updates["maxSize"].(int); ok { - config.MaxSize = maxSize - } - if maxBackups, ok := updates["maxBackups"].(int); ok { - config.MaxBackups = maxBackups - } - if maxAge, ok := updates["maxAge"].(int); ok { - config.MaxAge = maxAge - } - if compress, ok := updates["compress"].(bool); ok { - config.Compress = compress - } - - return SaveConfig(configPath, config) -} diff --git a/internal/network/classify.go b/internal/network/classify.go index b14c80b..4719723 100644 --- a/internal/network/classify.go +++ b/internal/network/classify.go @@ -44,8 +44,6 @@ const ( ActionRateLimit // ActionExternalFallback means a recognised challenge and external tools are allowed. ActionExternalFallback - // ActionFailCandidate means a permanent candidate failure without blind same-URL tool retries. - ActionFailCandidate // ActionFailPackage means stop the package (checksum, cancel, unassociated digest). ActionFailPackage // ActionFailLocal means a local I/O/permission error; switching transports cannot repair it. @@ -96,12 +94,12 @@ func ClassifyHTTPStatus(statusCode int, botChallenge bool) DownloadAction { return ActionRetrySame default: if statusCode >= 400 && statusCode < 500 { - return ActionFailCandidate + return ActionAdvanceCandidate } if statusCode >= 500 { return ActionRetrySame } - return ActionFailCandidate + return ActionAdvanceCandidate } } diff --git a/internal/network/classify_test.go b/internal/network/classify_test.go index 5e58eb2..89199f7 100644 --- a/internal/network/classify_test.go +++ b/internal/network/classify_test.go @@ -21,8 +21,8 @@ func TestClassifyHTTPStatus(t *testing.T) { {404, false, ActionAdvanceCandidate}, {410, false, ActionAdvanceCandidate}, {429, false, ActionRateLimit}, - {403, false, ActionFailCandidate}, - {401, false, ActionFailCandidate}, + {403, false, ActionAdvanceCandidate}, + {401, false, ActionAdvanceCandidate}, {500, false, ActionRetrySame}, {502, false, ActionRetrySame}, {202, true, ActionExternalFallback}, diff --git a/internal/network/download_fallbacks.go b/internal/network/download_fallbacks.go index bfc0906..b07acd6 100644 --- a/internal/network/download_fallbacks.go +++ b/internal/network/download_fallbacks.go @@ -17,6 +17,7 @@ import ( type CommandRunner interface { LookPath(file string) (string, error) CombinedOutput(name string, args ...string) ([]byte, error) + CombinedOutputContext(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) } type execRunner struct{} @@ -53,7 +54,8 @@ func (opts DownloadFallbackOptions) maxAttempts() int { return 3 } -func isZipMagic(b []byte) bool { +// IsZipMagic reports whether b starts with a ZIP local/EOCD/span signature. +func IsZipMagic(b []byte) bool { if len(b) < 4 { return false } @@ -157,7 +159,7 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do expectZip = strings.Contains(accept, "application/zip") } if expectZip { - if !isZipMagic(buf[:n]) { + if !IsZipMagic(buf[:n]) { return fmt.Errorf("output is not a ZIP archive (missing PK header)") } } @@ -167,7 +169,7 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do stopChain := func(err error) (*DownloadFallbackReport, error) { action := ClassifyDownloadError(err) cause := err - if action == ActionAdvanceCandidate || action == ActionFailCandidate || action == ActionFailPackage || action == ActionFailLocal || action == ActionRateLimit { + if action == ActionAdvanceCandidate || action == ActionFailPackage || action == ActionFailLocal || action == ActionRateLimit { return rep, &FallbackAttemptError{URL: url, Report: rep, attempt: compact, cause: cause} } return nil, err @@ -186,7 +188,7 @@ func downloadWithFallbacks(runner CommandRunner, url, targetPath string, opts Do err = NewHTTPStatusError(code, url, 0) } switch action { - case ActionAdvanceCandidate, ActionFailCandidate, ActionFailPackage, ActionFailLocal, ActionRateLimit: + case ActionAdvanceCandidate, ActionFailPackage, ActionFailLocal, ActionRateLimit: return false, err default: return false, nil @@ -282,10 +284,7 @@ func runTool(ctx context.Context, runner CommandRunner, opts DownloadFallbackOpt if ctx.Err() != nil { return nil, ctx.Err() } - if cr, ok := runner.(ContextCommandRunner); ok { - return cr.CombinedOutputContext(ctx, opts.Exec, name, args...) - } - return runner.CombinedOutput(name, args...) + return runner.CombinedOutputContext(ctx, opts.Exec, name, args...) } func runCurl(ctx context.Context, runner CommandRunner, curlPath, url, targetPath string, opts DownloadFallbackOptions) (finalStatus string, _ error) { @@ -326,7 +325,7 @@ func runCurl(ctx context.Context, runner CommandRunner, curlPath, url, targetPat action := ClassifyHTTPStatus(code, false) httpErr := NewHTTPStatusError(code, url, 0) switch action { - case ActionAdvanceCandidate, ActionFailCandidate, ActionFailPackage, ActionFailLocal: + case ActionAdvanceCandidate, ActionFailPackage, ActionFailLocal: return finalStatus, httpErr case ActionRateLimit: return finalStatus, httpErr @@ -359,7 +358,7 @@ func runCurl(ctx context.Context, runner CommandRunner, curlPath, url, targetPat switch action { case ActionSuccess: return finalStatus, nil - case ActionAdvanceCandidate, ActionFailCandidate, ActionFailPackage, ActionFailLocal, ActionRateLimit: + case ActionAdvanceCandidate, ActionFailPackage, ActionFailLocal, ActionRateLimit: return finalStatus, httpErr case ActionRetrySame: lastErr = httpErr @@ -495,7 +494,10 @@ func scrapeHTTPStatus(out []byte, err error) string { return "" } -func sleepCtx(ctx context.Context, d time.Duration) error { +func SleepCtx(ctx context.Context, d time.Duration) error { + if ctx == nil { + ctx = context.Background() + } if d <= 0 { return ctx.Err() } @@ -509,6 +511,10 @@ func sleepCtx(ctx context.Context, d time.Duration) error { } } +func sleepCtx(ctx context.Context, d time.Duration) error { + return SleepCtx(ctx, d) +} + func normalizeToolError(out []byte, err error) string { msg := strings.TrimSpace(string(bytes.TrimSpace(out))) if msg != "" { diff --git a/internal/network/download_fallbacks_test.go b/internal/network/download_fallbacks_test.go index e7df301..98e6ee8 100644 --- a/internal/network/download_fallbacks_test.go +++ b/internal/network/download_fallbacks_test.go @@ -1,6 +1,7 @@ package network import ( + "context" "errors" "os" "path/filepath" @@ -55,6 +56,13 @@ func (r *fakeRunner) CombinedOutput(name string, args ...string) ([]byte, error) return []byte("no result configured"), errors.New("failed") } +func (r *fakeRunner) CombinedOutputContext(ctx context.Context, _ ExecOptions, name string, args ...string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return r.CombinedOutput(name, args...) +} + func TestDownloadWithFallbacks_NoTools(t *testing.T) { r := &fakeRunner{ paths: map[string]string{}, diff --git a/internal/network/exec.go b/internal/network/exec.go index 4ba93ae..8bfb936 100644 --- a/internal/network/exec.go +++ b/internal/network/exec.go @@ -29,7 +29,6 @@ const ( type ExecOptions struct { InactivityTimeout time.Duration TerminateWait time.Duration - MaxOutputBytes int // ProgressPath, when set, is stat'd to detect downloaded-byte progress (file size growth). ProgressPath string } @@ -48,19 +47,6 @@ func (o ExecOptions) terminateWait() time.Duration { return DefaultExternalTerminateWait } -func (o ExecOptions) maxOutput() int { - if o.MaxOutputBytes > 0 { - return o.MaxOutputBytes - } - return DefaultMaxCapturedOutput -} - -// ContextCommandRunner is an optional CommandRunner extension used by production execution. -type ContextCommandRunner interface { - CommandRunner - CombinedOutputContext(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) -} - func (execRunner) CombinedOutputContext(ctx context.Context, opts ExecOptions, name string, args ...string) ([]byte, error) { return RunCancellable(ctx, opts, name, args...) } @@ -80,7 +66,7 @@ func RunCancellable(ctx context.Context, opts ExecOptions, name string, args ... prepareProcessGroup(cmd) var out limitedBuffer - out.max = opts.maxOutput() + out.max = DefaultMaxCapturedOutput stdoutR, stdoutW, err := os.Pipe() if err != nil { diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 1501320..bb6c7dc 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -9,78 +9,6 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// OnboardingStep represents a single step in the onboarding flow -// This interface allows for easy extension - just implement it to add new steps -type OnboardingStep interface { - // Name returns a human-readable name for this step (for logging/debugging) - Name() string - - // Execute runs the step and returns: - // - shouldContinue: true if onboarding should continue to next step, false to abort - // - error: any error that occurred during execution - Execute() (shouldContinue bool, err error) - - // CanSkip returns true if this step can be skipped - // If true, the step may offer a skip option to the user - CanSkip() bool -} - -// OnboardingFlow manages the execution of onboarding steps -type OnboardingFlow struct { - steps []OnboardingStep -} - -// NewOnboardingFlow creates a new onboarding flow -func NewOnboardingFlow() *OnboardingFlow { - return &OnboardingFlow{ - steps: make([]OnboardingStep, 0), - } -} - -// AddStep adds a step to the onboarding flow -// Steps are executed in the order they are added -func (f *OnboardingFlow) AddStep(step OnboardingStep) { - f.steps = append(f.steps, step) -} - -// Run executes all steps in the flow sequentially -// Stops if any step returns shouldContinue=false or an error -func (f *OnboardingFlow) Run() error { - for _, step := range f.steps { - shouldContinue, err := step.Execute() - if err != nil { - // User-friendly error message per verbose/debug guidelines - // The step's error message is already user-friendly, so we preserve it - return err - } - if !shouldContinue { - // User declined or aborted - this is expected behavior, not an error - // Return a sentinel error for cancellation - return shared.ErrOnboardingCancelled - } - } - return nil -} - -// RunStep executes a single step (useful for testing or conditional execution) -func (f *OnboardingFlow) RunStep(step OnboardingStep) (bool, error) { - return step.Execute() -} - -// NewDefaultOnboardingFlow creates the default onboarding flow with all standard steps -// This is the main entry point for first-run onboarding -func NewDefaultOnboardingFlow() *OnboardingFlow { - flow := NewOnboardingFlow() - - // Add steps in order: Welcome -> License -> Settings -> Completion - flow.AddStep(NewWelcomeStep()) - flow.AddStep(NewLicenseStep()) - flow.AddStep(NewSettingsStep()) - flow.AddStep(NewCompletionStep()) - - return flow -} - // RunFirstRunOnboarding checks if this is the first run and executes onboarding if needed // This is the main function to call from cmd/root.go func RunFirstRunOnboarding() error { diff --git a/internal/onboarding/steps.go b/internal/onboarding/steps.go deleted file mode 100644 index 9f6719b..0000000 --- a/internal/onboarding/steps.go +++ /dev/null @@ -1,607 +0,0 @@ -package onboarding - -import ( - "bufio" - "fmt" - "os" - "strings" - - "fontget/internal/config" - "fontget/internal/shared" - "fontget/internal/sources" - "fontget/internal/ui" - - tea "github.com/charmbracelet/bubbletea" -) - -// WelcomeStep displays the welcome message to new users -type WelcomeStep struct{} - -func NewWelcomeStep() *WelcomeStep { - return &WelcomeStep{} -} - -func (s *WelcomeStep) Name() string { - return "Welcome" -} - -func (s *WelcomeStep) CanSkip() bool { - return false // Welcome cannot be skipped -} - -func (s *WelcomeStep) Execute() (bool, error) { - // Clear screen for this step - clearScreen() - - // Display styled welcome message - // Section starts with blank line per spacing guidelines - fmt.Println() - fmt.Println(ui.PageTitle.Render("Welcome to FontGet!")) - fmt.Println() - fmt.Println(ui.Text.Render("This is your first time using FontGet. Let's get you set up.")) - fmt.Println() - fmt.Println(ui.InfoText.Render("FontGet is a powerful command-line font manager that helps you")) - fmt.Println(ui.InfoText.Render("install and manage fonts from various sources.")) - fmt.Println() - - // Wait for user to continue to next screen - if err := waitForContinue(); err != nil { - return false, fmt.Errorf("unable to read input: %w", err) - } - - return true, nil -} - -// LicenseStep handles license acceptance for all default font sources -type LicenseStep struct{} - -func NewLicenseStep() *LicenseStep { - return &LicenseStep{} -} - -func (s *LicenseStep) Name() string { - return "Terms of Use" -} - -func (s *LicenseStep) CanSkip() bool { - return false // License acceptance cannot be skipped -} - -func (s *LicenseStep) Execute() (bool, error) { - // Get all default sources - defaultSources := sources.DefaultSources() - - // Check if all default sources are already accepted - allAccepted := true - for sourceName := range defaultSources { - accepted, err := config.IsSourceAccepted(sourceName) - if err != nil { - return false, fmt.Errorf("unable to check source acceptance: %w", err) - } - if !accepted { - allAccepted = false - break - } - } - - if allAccepted { - return true, nil // Already accepted, continue - } - - // Use custom confirmation dialog that shows all info in alt-screen - confirmed, err := runLicenseConfirmation(defaultSources) - if err != nil { - return false, fmt.Errorf("unable to show license prompt: %w", err) - } - - if !confirmed { - // User declined - show message and end section with blank line - fmt.Println() - fmt.Println(ui.WarningText.Render("License acceptance is required to use FontGet.")) - fmt.Println(ui.Text.Render("You can review licenses and accept them later.")) - fmt.Println() // Section ends with blank line per spacing guidelines - return false, nil - } - - // Save acceptance for all default sources - for sourceName := range defaultSources { - if err := config.AcceptSource(sourceName); err != nil { - return false, fmt.Errorf("unable to save license acceptance for %s: %w", sourceName, err) - } - } - - // Success message - section ends with blank line - fmt.Println() - fmt.Println(ui.SuccessText.Render("Terms of use accepted.")) - fmt.Println() // Section ends with blank line per spacing guidelines - - return true, nil -} - -// SettingsStep displays and confirms default settings -type SettingsStep struct{} - -func NewSettingsStep() *SettingsStep { - return &SettingsStep{} -} - -func (s *SettingsStep) Name() string { - return "Settings Configuration" -} - -func (s *SettingsStep) CanSkip() bool { - return true // Settings can be skipped (will use defaults) -} - -func (s *SettingsStep) Execute() (bool, error) { - // Get default settings - defaults := config.DefaultUserPreferences() - - // Confirm settings using alt-screen confirmation (all info visible in alt-screen) - confirmed, err := runSettingsConfirmation(defaults) - if err != nil { - // User-friendly error message per verbose/debug guidelines - return false, fmt.Errorf("unable to show settings confirmation: %w", err) - } - - if !confirmed { - // User declined, but we'll continue with defaults anyway - // Section ends with blank line per spacing guidelines - fmt.Println() - fmt.Println(ui.WarningText.Render("Using default settings. You can change them later with 'fontget config edit'.")) - fmt.Println() // Section ends with blank line per spacing guidelines - } - - // Ensure config file exists with defaults - // This is safe to call even if file exists - it won't overwrite - if err := config.GenerateInitialUserPreferences(); err != nil { - // User-friendly error message per verbose/debug guidelines - return false, fmt.Errorf("unable to create default configuration: %w", err) - } - - return true, nil -} - -// CompletionStep shows the completion message -type CompletionStep struct{} - -func NewCompletionStep() *CompletionStep { - return &CompletionStep{} -} - -func (s *CompletionStep) Name() string { - return "Completion" -} - -func (s *CompletionStep) CanSkip() bool { - return false // Completion cannot be skipped -} - -func (s *CompletionStep) Execute() (bool, error) { - // Clear screen for this step - clearScreen() - - // Completion message - section starts with blank line per spacing guidelines - fmt.Println() - fmt.Println(ui.SuccessText.Render("Setup complete!")) - fmt.Println() - fmt.Println(ui.Text.Render("You're all set to start using FontGet.")) - fmt.Println() - fmt.Println(ui.InfoText.Render("Try these commands to get started:")) - fmt.Printf(" %s %s\n", ui.Text.Render("fontget search "), ui.Text.Render("Search for fonts")) - fmt.Printf(" %s %s\n", ui.Text.Render("fontget list"), ui.Text.Render("List installed fonts")) - fmt.Printf(" %s %s\n", ui.Text.Render("fontget add "), ui.Text.Render("Install a font")) - fmt.Printf(" %s %s\n", ui.Text.Render("fontget --help"), ui.Text.Render("See all available commands")) - fmt.Println() // Section ends with blank line per spacing guidelines - - // Wait for user to continue (final screen, just to acknowledge) - if err := waitForContinue(); err != nil { - return false, fmt.Errorf("unable to read input: %w", err) - } - - return true, nil -} - -// Helper functions - -func formatBool(value bool) string { - if value { - return ui.SuccessText.Render("Enabled") - } - return ui.WarningText.Render("Disabled") -} - -func formatSorting(usePopularity bool) string { - if usePopularity { - return ui.SuccessText.Render("Popularity-based") - } - return ui.Text.Render("Alphabetical") -} - -// LicenseConfirmModel represents a license confirmation dialog with all source info -type LicenseConfirmModel struct { - sources map[string]sources.SourceInfo - Confirmed bool - Quit bool - Width int - Height int -} - -// NewLicenseConfirmModel creates a new license confirmation model -func NewLicenseConfirmModel(sourcesMap map[string]sources.SourceInfo) *LicenseConfirmModel { - return &LicenseConfirmModel{ - sources: sourcesMap, - Width: 80, - Height: 24, - } -} - -// Init initializes the license confirmation dialog -func (m LicenseConfirmModel) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the license confirmation dialog -func (m LicenseConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "y", "Y", "enter": - m.Confirmed = true - return m, tea.Quit - case "n", "N", "esc": - m.Confirmed = false - return m, tea.Quit - case "ctrl+c": - m.Confirmed = false - return m, tea.Quit - } - case tea.WindowSizeMsg: - m.Width = msg.Width - m.Height = msg.Height - return m, nil - } - - return m, nil -} - -// View renders the license confirmation dialog with all source information -func (m LicenseConfirmModel) View() string { - var result strings.Builder - - // Get actual terminal width for proper text wrapping - terminalWidth := shared.GetTerminalWidth() - - // Calculate available width (account for margins) - availableWidth := terminalWidth - 4 // Leave some margin - if availableWidth < 60 { - availableWidth = 60 // Minimum readable width - } - - result.WriteString("\n") - for _, section := range TermsOfUseSections() { - render := StyleRenderer(section.Style) - if len(section.Items) > 0 { - for _, item := range section.Items { - result.WriteString(fmt.Sprintf(" %s %s\n", "•", render(item))) - } - } else if section.Content != "" { - for _, line := range shared.WrapText(section.Content, availableWidth) { - result.WriteString(render(line)) - result.WriteString("\n") - } - } - result.WriteString("\n") - } - result.WriteString(ui.InfoText.Render("To review a particular font's license, run:")) - result.WriteString("\n") - result.WriteString(fmt.Sprintf(" %s\n", ui.Text.Render("fontget info --license"))) - result.WriteString("\n") - - // Confirmation prompt is handled separately by promptConfirmSimple - // No need to include it in View() since we're using regular output, not alt-screen - - return result.String() -} - -// renderSourcesInfo renders the sources information screen -func renderSourcesInfo(sourcesMap map[string]sources.SourceInfo, width int) string { - var result strings.Builder - - // Calculate available width (account for margins) - availableWidth := width - 4 // Leave some margin - if availableWidth < 60 { - availableWidth = 60 // Minimum readable width - } - - // Start with blank line - result.WriteString("\n") - - // Title - result.WriteString(ui.PageTitle.Render("Sources")) - result.WriteString("\n\n") - - // Introduction text - plain text, wrapped - n := len(sourcesMap) - introText := fmt.Sprintf("Sources are how FontGet finds and installs fonts. FontGet has the following %d default sources built-in:", n) - introLines := shared.WrapText(introText, availableWidth) - for _, line := range introLines { - result.WriteString(line) - result.WriteString("\n") - } - result.WriteString("\n") - - // Source URL mapping to website URLs - sourceURLs := map[string]string{ - "Google Fonts": "https://fonts.google.com/", - "Nerd Fonts": "https://www.nerdfonts.com/", - "The League of Moveable Type": "https://www.theleagueofmoveabletype.com/", - "Fontshare": "https://www.fontshare.com/", - "Fontsource": "https://fontsource.org/", - "Font Squirrel": "https://www.fontsquirrel.com/", - } - - // Display all default sources with website URLs on same line, no space between - sourceOrder := sources.DefaultSourceNamesInPriorityOrder() - for _, sourceName := range sourceOrder { - if _, exists := sourcesMap[sourceName]; exists { - websiteURL := sourceURLs[sourceName] - sourceInfo := sourcesMap[sourceName] - // Source name in pink, URL on same line with dash - line := fmt.Sprintf(" %s %s - %s", "•", ui.TableSourceName.Render(sourceName), websiteURL) - // Add disabled note if source is disabled - if !sourceInfo.Enabled { - line += fmt.Sprintf(" %s", ui.WarningText.Render("(disabled by default)")) - } - result.WriteString(line + "\n") - } - } - result.WriteString("\n") - - // Custom sources section - InfoText header, plain text body - result.WriteString(ui.InfoText.Render("Custom Sources:")) - result.WriteString("\n") - customText := "You can add custom font sources to FontGet. If you add custom font sources, you are solely responsible for ensuring compliance with those sources' license agreements. FontGet does not verify or guarantee license compliance for custom sources." - customLines := shared.WrapText(customText, availableWidth) - for _, line := range customLines { - result.WriteString(line) - result.WriteString("\n") - } - result.WriteString("\n") - - // Source management information - InfoText (mauve) - result.WriteString(ui.InfoText.Render("Managing Sources:")) - result.WriteString("\n") - manageText := "You can manage sources (enable, disable, or add custom sources) using the command:" - manageLines := shared.WrapText(manageText, availableWidth) - for _, line := range manageLines { - result.WriteString(line) - result.WriteString("\n") - } - result.WriteString(fmt.Sprintf(" %s\n", ui.Text.Render("fontget sources manage"))) - result.WriteString("\n") - - return result.String() -} - -// runLicenseConfirmation runs the license confirmation dialog with all source info -// Uses regular output (no alt-screen) so users can scroll -func runLicenseConfirmation(sourcesMap map[string]sources.SourceInfo) (bool, error) { - model := NewLicenseConfirmModel(sourcesMap) - - // Get actual terminal width for proper text wrapping - terminalWidth := shared.GetTerminalWidth() - - // Clear screen and display the license information screen - clearScreen() - fmt.Print(model.View()) - - // Wait for user to continue to sources screen - if err := waitForContinue(); err != nil { - return false, fmt.Errorf("unable to read input: %w", err) - } - - // Clear screen and display sources information on separate screen - clearScreen() - fmt.Print(renderSourcesInfo(sourcesMap, terminalWidth)) - - // Wait for user to continue to confirmation - if err := waitForContinue(); err != nil { - return false, fmt.Errorf("unable to read input: %w", err) - } - - // Use simple confirmation prompt (no alt-screen) - confirmed, err := promptConfirmSimple("Do you accept the terms of use?") - if err != nil { - return false, fmt.Errorf("unable to read response: %w", err) - } - - return confirmed, nil -} - -// SettingsConfirmModel represents a settings confirmation dialog -type SettingsConfirmModel struct { - defaults *config.AppConfig - Confirmed bool - Quit bool - Width int - Height int -} - -// NewSettingsConfirmModel creates a new settings confirmation model -func NewSettingsConfirmModel(defaults *config.AppConfig) *SettingsConfirmModel { - return &SettingsConfirmModel{ - defaults: defaults, - Width: 80, - Height: 24, - } -} - -// Init initializes the settings confirmation dialog -func (m SettingsConfirmModel) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the settings confirmation dialog -func (m SettingsConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "y", "Y", "enter": - m.Confirmed = true - return m, tea.Quit - case "n", "N", "esc": - m.Confirmed = false - return m, tea.Quit - case "ctrl+c": - m.Confirmed = false - return m, tea.Quit - } - case tea.WindowSizeMsg: - m.Width = msg.Width - m.Height = msg.Height - return m, nil - } - - return m, nil -} - -// View renders the settings confirmation dialog with all settings info -func (m SettingsConfirmModel) View() string { - var result strings.Builder - - // Calculate available width (account for margins) - availableWidth := m.Width - 4 // Leave some margin - if availableWidth < 60 { - availableWidth = 60 // Minimum readable width - } - - // Title - result.WriteString(ui.PageTitle.Render("Default Settings")) - result.WriteString("\n\n") - - // Introduction - plain text, wrapped - introText := "FontGet will use the following default settings:" - introLines := shared.WrapText(introText, availableWidth) - for _, line := range introLines { - result.WriteString(line) - result.WriteString("\n") - } - result.WriteString("\n") - - // Display each setting with clear explanation - settings := []struct { - name string - value string - description string - }{ - { - name: "Check for updates", - value: formatBool(m.defaults.Update.CheckForUpdates), - description: "FontGet will automatically check for new versions when you start the application. When an update is available, you'll be prompted to confirm before installing.", - }, - { - name: "Sorting method", - value: formatSorting(m.defaults.Search.EnablePopularitySort), - description: "When searching for fonts, results will be sorted by popularity first (most commonly used fonts appear first), then alphabetically. This helps you find popular fonts more easily.", - }, - } - - for _, setting := range settings { - result.WriteString(fmt.Sprintf(" %s %s\n", "•", ui.InfoText.Render(setting.name))) - result.WriteString(fmt.Sprintf(" Setting: %s\n", setting.value)) - // Wrap description text with indentation - opts := shared.WrapOptions{ - Width: availableWidth - 4, // Account for indentation - Indent: " ", - } - descLines := shared.WrapTextWithOptions(setting.description, opts) - for _, line := range descLines { - result.WriteString(line) - result.WriteString("\n") - } - result.WriteString("\n") - } - - // Footer - plain text - result.WriteString("These settings can be changed later using 'fontget config edit'.") - result.WriteString("\n") - - return result.String() -} - -// runSettingsConfirmation runs the settings confirmation dialog with all settings info -func runSettingsConfirmation(defaults *config.AppConfig) (bool, error) { - model := NewSettingsConfirmModel(defaults) - - // Get actual terminal width for proper text wrapping - terminalWidth := shared.GetTerminalWidth() - model.Width = terminalWidth - - // Clear screen and display the settings information screen - clearScreen() - fmt.Print(model.View()) - - // Wait for user to continue to confirmation - if err := waitForContinue(); err != nil { - return false, fmt.Errorf("unable to read input: %w", err) - } - - // Use simple confirmation prompt (no alt-screen) - confirmed, err := promptConfirmSimple("Accept these default settings?") - if err != nil { - return false, fmt.Errorf("unable to read response: %w", err) - } - - return confirmed, nil -} - -// clearScreen clears the terminal screen using ANSI escape codes -func clearScreen() { - // ANSI escape code to clear screen and move cursor to top-left - fmt.Print("\033[2J\033[H") -} - -// waitForContinue waits for the user to press Enter to continue to the next screen -func waitForContinue() error { - fmt.Println() - fmt.Print(ui.InfoText.Render("Press Enter to continue...")) - - reader := bufio.NewReader(os.Stdin) - _, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("unable to read input: %w", err) - } - - // Clear screen before showing next step - clearScreen() - - return nil -} - -// promptConfirmSimple provides a simple confirmation prompt without alt-screen -// This is better for onboarding where we want to keep previous content visible -func promptConfirmSimple(message string) (bool, error) { - // Display the prompt with styled UI - fmt.Printf("%s\n", ui.Text.Render(message)) - fmt.Println() - - // Show keyboard shortcuts - commands := []string{ - ui.RenderKeyWithDescription("Y", "Yes"), - ui.RenderKeyWithDescription("N", "No"), - } - helpText := strings.Join(commands, " ") - fmt.Println(helpText) - fmt.Print("\n> ") - - // Read user input - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return false, fmt.Errorf("unable to read response: %w", err) - } - - response = strings.ToLower(strings.TrimSpace(response)) - return response == "y" || response == "yes" || response == "", nil // Empty/Enter defaults to yes -} diff --git a/internal/platform/mutation.go b/internal/platform/mutation.go index 29ce566..d23a5b9 100644 --- a/internal/platform/mutation.go +++ b/internal/platform/mutation.go @@ -76,10 +76,6 @@ func placeFontFile(src, dest string, force bool, opts *InstallFontOptions) (File } func createNewFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { - if opts != nil && opts.FailPoint == InstallFailCopy { - return failPointError(InstallFailCopy) - } - staged := uniqueArtifact(dest, "new") trackArtifact(mut, staged) if err := copyFile(src, staged); err != nil { @@ -115,10 +111,6 @@ func createNewFontFile(src, dest string, mut *FileMutation, opts *InstallFontOpt } func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { - if opts != nil && opts.FailPoint == InstallFailBackup { - return failPointError(InstallFailBackup) - } - backup := uniqueArtifact(dest, "bak") if err := copyFile(dest, backup); err != nil { return fmt.Errorf("backup existing font: %w", err) @@ -127,10 +119,6 @@ func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallF mut.Replaced = true trackArtifact(mut, backup) - if opts != nil && opts.FailPoint == InstallFailCopy { - return failPointError(InstallFailCopy) - } - staged := uniqueArtifact(dest, "new") trackArtifact(mut, staged) if err := copyFile(src, staged); err != nil { diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 36339fe..fdd18e7 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -32,8 +32,6 @@ type InstallFailPoint string const ( InstallFailNone InstallFailPoint = "" - InstallFailBackup InstallFailPoint = "backup" - InstallFailCopy InstallFailPoint = "copy" InstallFailCopyAfterWrite InstallFailPoint = "copy-after-write" InstallFailReplace InstallFailPoint = "replace" InstallFailRegister InstallFailPoint = "register" diff --git a/internal/platform/temp.go b/internal/platform/temp.go index 721830d..09a98c1 100644 --- a/internal/platform/temp.go +++ b/internal/platform/temp.go @@ -9,9 +9,7 @@ import ( const ( // TempDirName is the name of our temporary directory (shared container only). - TempDirName = "Fontget" - // TempFontsDir is the name of the fonts subdirectory (legacy shared staging). - TempFontsDir = "fonts" + TempDirName = "Fontget" operationDirPrefix = "op-" ) @@ -32,22 +30,6 @@ func GetTempDir() (string, error) { return fontgetTempDir, nil } -// GetTempFontsDir returns the path to the legacy shared temporary fonts directory. -// New installation code should use NewOperationStaging instead. -func GetTempFontsDir() (string, error) { - tempDir, err := GetTempDir() - if err != nil { - return "", err - } - - fontsDir := filepath.Join(tempDir, TempFontsDir) - if err := os.MkdirAll(fontsDir, 0755); err != nil { - return "", fmt.Errorf("failed to create fonts directory: %w", err) - } - - return fontsDir, nil -} - // CleanupTempDir removes the shared Fontget temp container if it is empty. // It never deletes unrelated sibling directories. Prefer OperationStaging.Cleanup. func CleanupTempDir() error { @@ -65,21 +47,6 @@ func CleanupTempDir() error { return nil } -// CleanupTempFontsDir removes the legacy shared fonts subdirectory if empty. -func CleanupTempFontsDir() error { - fontsDir, err := GetTempFontsDir() - if err != nil { - return err - } - if err := os.Remove(fontsDir); err != nil && !os.IsNotExist(err) { - if isNotEmptyDirErr(err) { - return nil - } - return fmt.Errorf("failed to cleanup fonts directory: %w", err) - } - return nil -} - func isNotEmptyDirErr(err error) bool { if err == nil { return false @@ -107,52 +74,26 @@ func NewOperationStaging() (*OperationStaging, error) { return &OperationStaging{Root: dir}, nil } -// PackageDir returns a dedicated directory for one catalogue package (font ID). -func (s *OperationStaging) PackageDir(packageID string) (string, error) { +// VariantDir returns a dedicated directory for one variant inside a package. +func (s *OperationStaging) VariantDir(packageID, variant string) (string, error) { if s == nil || s.Root == "" { return "", fmt.Errorf("nil operation staging") } - name := sanitizeStagingName(packageID) - if name == "" { - name = "package" - } - dir := filepath.Join(s.Root, "pkg-"+name) - if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("failed to create package staging directory: %w", err) - } - return dir, nil -} - -// VariantDir returns a dedicated directory for one variant inside a package. -func (s *OperationStaging) VariantDir(packageID, variant string) (string, error) { - pkg, err := s.PackageDir(packageID) - if err != nil { - return "", err + pkgName := SanitizePathPart(packageID) + if pkgName == "" { + pkgName = "package" } - name := sanitizeStagingName(variant) - if name == "" { - name = "variant" + varName := SanitizePathPart(variant) + if varName == "" { + varName = "variant" } - dir := filepath.Join(pkg, "var-"+name) + dir := filepath.Join(s.Root, "pkg-"+pkgName, "var-"+varName) if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create variant staging directory: %w", err) } return dir, nil } -// AttemptDir allocates a unique directory for one download/extract candidate attempt. -func (s *OperationStaging) AttemptDir(packageID, variant string) (string, error) { - parent, err := s.VariantDir(packageID, variant) - if err != nil { - return "", err - } - dir, err := os.MkdirTemp(parent, "attempt-*") - if err != nil { - return "", fmt.Errorf("failed to create attempt staging directory: %w", err) - } - return dir, nil -} - // Cleanup removes this operation's disposable staging tree only. func (s *OperationStaging) Cleanup() error { if s == nil || s.Root == "" { @@ -166,7 +107,8 @@ func (s *OperationStaging) Cleanup() error { return nil } -func sanitizeStagingName(s string) string { +// SanitizePathPart returns a filesystem-safe fragment for staging and recovery filenames. +func SanitizePathPart(s string) string { s = strings.TrimSpace(s) if s == "" { return "" diff --git a/internal/platform/temp_test.go b/internal/platform/temp_test.go index 552fcfb..9b3c791 100644 --- a/internal/platform/temp_test.go +++ b/internal/platform/temp_test.go @@ -85,33 +85,3 @@ func TestOperationStagingConcurrentCleanup(t *testing.T) { } } } - -func TestAttemptDirIsolatedFromSiblingVariant(t *testing.T) { - s, err := NewOperationStaging() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = s.Cleanup() }) - - okDir, err := s.VariantDir("pkg", "regular") - if err != nil { - t.Fatal(err) - } - keep := filepath.Join(okDir, "ok.ttf") - if err := os.WriteFile(keep, []byte("ok"), 0644); err != nil { - t.Fatal(err) - } - attempt, err := s.AttemptDir("pkg", "bold") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(attempt, "bad.bin"), []byte("bad"), 0644); err != nil { - t.Fatal(err) - } - if err := os.RemoveAll(attempt); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(keep); err != nil { - t.Fatalf("successful variant staging lost: %v", err) - } -} diff --git a/internal/repo/checksum.go b/internal/repo/checksum.go index 78d0d57..38ddc14 100644 --- a/internal/repo/checksum.go +++ b/internal/repo/checksum.go @@ -35,17 +35,11 @@ func ParseExpectedSHA256(expected string) (string, error) { if strings.HasPrefix(expected, "sha256:") || strings.HasPrefix(expected, "SHA256:") { expected = strings.TrimSpace(expected[7:]) } - if len(expected) != 64 { + b, err := hex.DecodeString(expected) + if err != nil || len(b) != 32 { return "", fmt.Errorf("%w: expected 64 hex characters", ErrMalformedChecksum) } - for i := 0; i < len(expected); i++ { - c := expected[i] - hexDigit := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') - if !hexDigit { - return "", fmt.Errorf("%w: non-hex character", ErrMalformedChecksum) - } - } - return strings.ToLower(expected), nil + return hex.EncodeToString(b), nil } // VerifyFileSHA256 compares path bytes against an already-validated expected hex digest. diff --git a/internal/repo/font.go b/internal/repo/font.go index 1456b80..bdca462 100644 --- a/internal/repo/font.go +++ b/internal/repo/font.go @@ -52,13 +52,6 @@ func resolveDownloadUserAgent() string { // DownloadUserAgent is Network.DownloadUserAgent from preferences (embedded default if unset). func DownloadUserAgent() string { return resolveDownloadUserAgent() } -func isZipMagic(b []byte) bool { - if len(b) < 4 { - return false - } - return b[0] == 'P' && b[1] == 'K' && ((b[2] == 3 && b[3] == 4) || (b[2] == 5 && b[3] == 6) || (b[2] == 7 && b[3] == 8)) -} - var ( downloadHostMu sync.Mutex downloadHostSlots = map[string]chan struct{}{} @@ -325,7 +318,7 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( retryAfter, _ := network.ParseRetryAfter(resp.Header, time.Now()) network.DrainAndCloseBody(resp.Body) switch action { - case network.ActionAdvanceCandidate, network.ActionFailCandidate: + case network.ActionAdvanceCandidate: return "", network.NewHTTPStatusError(resp.StatusCode, font.DownloadURL, retryAfter) case network.ActionRateLimit: if !network.RetryAfterWithinBudget(retryAfter) { @@ -409,7 +402,7 @@ func DownloadFont(font *FontFile, targetDir string, opts *DownloadFontOptions) ( if expectZIP { br := bufio.NewReader(reader) if hdr, peekErr := br.Peek(4); peekErr == nil { - if !isZipMagic(hdr) { + if !network.IsZipMagic(hdr) { return "", fmt.Errorf("download did not return a ZIP archive (possible upstream challenge): %s", network.RedactDownloadURL(font.DownloadURL)) } } @@ -533,17 +526,7 @@ func sleepRequest(req *http.Request, d time.Duration) error { if req != nil && req.Context() != nil { ctx = req.Context() } - if d <= 0 { - return ctx.Err() - } - t := time.NewTimer(d) - defer t.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-t.C: - return nil - } + return network.SleepCtx(ctx, d) } func isHTTP2HeaderTimeout(err error) bool { @@ -641,7 +624,7 @@ func DownloadAndExtractFont(font *FontFile, targetDir string, opts *DownloadFont switch action { case network.ActionFailPackage, network.ActionFailLocal, network.ActionRateLimit: return nil, err - case network.ActionAdvanceCandidate, network.ActionFailCandidate, network.ActionRetrySame, network.ActionExternalFallback: + case network.ActionAdvanceCandidate, network.ActionRetrySame, network.ActionExternalFallback: if i+1 < len(candidates) { output.GetDebug().State("DownloadAndExtractFont: trying next format candidate") } diff --git a/internal/repo/font_matches.go b/internal/repo/font_matches.go index ab2974f..8b12586 100644 --- a/internal/repo/font_matches.go +++ b/internal/repo/font_matches.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "fontget/internal/normalize" "fontget/internal/output" ) @@ -76,7 +75,7 @@ func buildFontIndex(manifest *FontManifest) *fontIndex { } // Index by normalized font name - normalizedName := normalize.FontKey(font.Name) + normalizedName := FontKey(font.Name) index.byName[normalizedName] = append(index.byName[normalizedName], entry) // Index by font ID name (without prefix) @@ -84,12 +83,12 @@ func buildFontIndex(manifest *FontManifest) *fontIndex { idParts := strings.Split(fontID, ".") if len(idParts) > 1 { idName := strings.Join(idParts[1:], ".") - normalizedIDName := normalize.FontKey(idName) + normalizedIDName := FontKey(idName) index.byIDName[normalizedIDName] = append(index.byIDName[normalizedIDName], entry) } } else { // Font ID without prefix - normalizedFontID := normalize.FontKey(fontID) + normalizedFontID := FontKey(fontID) index.byIDName[normalizedFontID] = append(index.byIDName[normalizedFontID], entry) } } @@ -129,12 +128,12 @@ func MatchInstalledFontToRepository(familyName string, index *fontIndex, isProte return nil, nil } - normalizedFamily := normalize.FontKey(familyName) + normalizedFamily := FontKey(familyName) // Extract base name (removes common suffixes like " Nerd Font") // This allows matching "JetBrainsMono Nerd Font" to "nerd.jetbrains-mono" - baseFontName := normalize.BaseFamilyName(familyName) - normalizedBaseName := normalize.FontKey(baseFontName) + baseFontName := BaseFamilyName(familyName) + normalizedBaseName := FontKey(baseFontName) hasSuffix := baseFontName != familyName // True if we extracted a base name (e.g., Nerd Font pattern) // If we detected a suffix pattern, infer the expected source name from the pattern diff --git a/internal/normalize/normalize.go b/internal/repo/normalize.go similarity index 98% rename from internal/normalize/normalize.go rename to internal/repo/normalize.go index a0c58ca..512fd38 100644 --- a/internal/normalize/normalize.go +++ b/internal/repo/normalize.go @@ -1,4 +1,4 @@ -package normalize +package repo import "strings" diff --git a/internal/shared/doc.go b/internal/shared/doc.go index f3a2ebf..0ff7d50 100644 --- a/internal/shared/doc.go +++ b/internal/shared/doc.go @@ -1,5 +1,5 @@ // Package shared contains general-purpose helpers shared across commands and internal packages. // // Keep this package CLI-agnostic: no Cobra assumptions, no prompting, and no direct terminal UI. -// For feature/domain-specific helpers (e.g. sources management), prefer `internal/functions`. +// For feature/domain-specific helpers (e.g. sources management), keep helpers in the owning package. package shared diff --git a/internal/shared/errors.go b/internal/shared/errors.go index 5ec867c..ff4fac7 100644 --- a/internal/shared/errors.go +++ b/internal/shared/errors.go @@ -41,30 +41,6 @@ func (e *FontRemovalError) Error() string { return fmt.Sprintf("failed to remove %d out of %d fonts", e.FailedCount, e.TotalCount) } -// ConfigurationError represents configuration-related errors -type ConfigurationError struct { - Field string - Value string - Hint string -} - -func (e *ConfigurationError) Error() string { - if e.Hint != "" { - return fmt.Sprintf("configuration error in field '%s' with value '%s': %s", e.Field, e.Value, e.Hint) - } - return fmt.Sprintf("configuration error in field '%s' with value '%s'", e.Field, e.Value) -} - -// ElevationError represents elevation-related errors -type ElevationError struct { - Operation string - Platform string -} - -func (e *ElevationError) Error() string { - return fmt.Sprintf("elevation required for operation '%s' on platform '%s'", e.Operation, e.Platform) -} - // Classified operation errors. Callers should use errors.Is; do not match message text. var ( // ErrOperationCancelled is a sentinel error used to indicate that an operation was cancelled by the user. @@ -72,8 +48,6 @@ var ( // ErrRecoveryRequired is returned when installation rollback could not fully restore prior state. ErrRecoveryRequired = errors.New("installation recovery required") - // ErrLocalFailure is returned for local permission or disk errors that network fallback cannot repair. - ErrLocalFailure = errors.New("local filesystem failure") ) // DisplayedError wraps an error whose user-facing message has already been printed. diff --git a/internal/shared/file.go b/internal/shared/file.go index 7f257ac..853964c 100644 --- a/internal/shared/file.go +++ b/internal/shared/file.go @@ -6,24 +6,18 @@ import ( "strings" ) -// FormatFileSize formats bytes for general display (B / KB / MB at 1024 base). -// cmd/sources.go also defines formatFileSize (full KMGTPE range for cache sizes); outputs differ intentionally—unify only with tests. +// FormatFileSize formats bytes for display (B / KB / MB / GB / TB / PB / EB at 1024 base). func FormatFileSize(bytes int64) string { - if bytes == 0 { - return "" + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) } - - const ( - KB = 1024 - MB = KB * 1024 - ) - - if bytes >= MB { - return fmt.Sprintf("%.1fMB", float64(bytes)/float64(MB)) - } else if bytes >= KB { - return fmt.Sprintf("%.0fKB", float64(bytes)/float64(KB)) + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ } - return fmt.Sprintf("%dB", bytes) + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } // SanitizeForZipPath sanitizes a string for use as a path component in a zip archive. diff --git a/internal/shared/textwrap.go b/internal/shared/textwrap.go index 6bf1212..15720b0 100644 --- a/internal/shared/textwrap.go +++ b/internal/shared/textwrap.go @@ -5,8 +5,8 @@ import ( "fmt" "os" "strings" - "unicode" + "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" ) @@ -155,7 +155,7 @@ func wrapParagraph(text string, width int, indent string) []string { } // Calculate display width (accounting for potential multi-byte characters) - testWidth := calculateDisplayWidth(testLine) + testWidth := ansi.StringWidth(testLine) if testWidth <= width { currentLine = testLine @@ -166,7 +166,7 @@ func wrapParagraph(text string, width int, indent string) []string { currentLine = word } else { // Word itself is longer than width - add it anyway and break if needed - if calculateDisplayWidth(word) > width { + if ansi.StringWidth(word) > width { // Break long word (preserve as much as possible) lines = append(lines, indent+word) currentLine = "" @@ -185,29 +185,6 @@ func wrapParagraph(text string, width int, indent string) []string { return lines } -// calculateDisplayWidth calculates the display width of a string -// This accounts for multi-byte characters and ANSI escape codes -func calculateDisplayWidth(s string) int { - // Simple implementation: count runes (works for most cases) - // For more accurate width calculation with emoji/wide chars, we'd need - // a library like github.com/mattn/go-runewidth, but we'll keep it simple - // for now to avoid dependencies - width := 0 - for _, r := range s { - if unicode.IsPrint(r) { - // Most characters are 1 width, but some (like emoji) are 2 - // For now, we'll use a simple heuristic - if r > 0x1F000 && r < 0x1FAFF { - // Emoji range - typically 2 width - width += 2 - } else { - width++ - } - } - } - return width -} - // stripMarkdown removes markdown formatting from text while preserving content func stripMarkdown(text string) string { // Remove markdown headers (# ## ###) diff --git a/internal/templates/command_template.go b/internal/templates/command_template.go deleted file mode 100644 index 3fa2855..0000000 --- a/internal/templates/command_template.go +++ /dev/null @@ -1,321 +0,0 @@ -// This is a template for building new commands. -// It is not used in the project - it's a development template. -// Use this as a starting point when creating new FontGet commands. - -package templates - -import ( - "fmt" - "strings" - - fontgetCmd "fontget/cmd" - "fontget/internal/cmdutils" - "fontget/internal/output" - "fontget/internal/repo" - "fontget/internal/ui" - - "github.com/spf13/cobra" -) - -// Template for new commands. Replace "command" with your command name -var commandCmd = &cobra.Command{ - Use: "command ", - Short: "One-line description of what the command does", - SilenceUsage: true, // Prevents full help display on validation errors - Long: `Detailed description of what the command does and how it works. - -usage: fontget command []`, - Example: ` fontget command example1 - fontget command "example with quotes" - fontget command example3 --flag value - fontget command example4 -f value`, - // Use one of these Args validators: - // cobra.NoArgs - Command doesn't accept any arguments - // cobra.ExactArgs(n) - Command requires exactly n arguments - // cobra.MinimumNArgs(n) - Command requires at least n arguments - // cobra.MaximumNArgs(n) - Command accepts at most n arguments - // cobra.RangeArgs(min, max) - Command accepts between min and max arguments - Args: func(cmd *cobra.Command, args []string) error { - // Get flags - flagValue, _ := cmd.Flags().GetString("flag-name") - - // Get arguments - var argValue string - if len(args) > 0 { - argValue = args[0] - } - - // Validate input using modern error handling pattern - // Pattern: Print error with ui.RenderError, show hint, return nil - if argValue == "" && flagValue == "" { - fmt.Printf("\n%s\n", ui.RenderError("A required argument is missing")) - fmt.Printf("Use 'fontget command --help' for more information.\n\n") - return nil // Return nil to prevent duplicate error from Cobra - } - return nil - }, - // Optional: Add argument completion (cache-only; never spinners during fontget __complete) - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - r, err := repo.GetRepositoryForShellCompletion() - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - // Get all fonts using repository method - results, err := r.SearchFonts("", "") - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - // Filter and return font names - var completions []string - for _, result := range results { - if strings.HasPrefix(strings.ToLower(result.Name), strings.ToLower(toComplete)) { - completions = append(completions, result.Name) - } - } - - return completions, cobra.ShellCompDirectiveNoFileComp - }, - RunE: func(cmd *cobra.Command, args []string) error { - // Always log operation start (file logging, not console) - fontgetCmd.GetLogger().Info("Starting command operation") - - // Ensure manifest system is initialized (required for repository access) - if err := cmdutils.EnsureManifestInitialized(func() cmdutils.Logger { return fontgetCmd.GetLogger() }); err != nil { - fontgetCmd.GetLogger().Error("Failed to initialize manifest: %v", err) - return err - } - - // Double check args to prevent panic - flagValue, _ := cmd.Flags().GetString("flag-name") - var argValue string - if len(args) > 0 { - argValue = args[0] - } - if argValue == "" && flagValue == "" { - return nil // Args validator will have already shown the error - } - - // Log parameters (always log to file) - fontgetCmd.GetLogger().Info("Command parameters - Arg: %s, Flag: %s", argValue, flagValue) - - output.GetDebug().Message("Debug mode enabled - showing detailed diagnostic information") - - // Print styled title using modern UI components (if needed) - // Note: Not all commands need PageTitle - use only when appropriate - // fmt.Printf("\n%s\n", ui.PageTitle.Render("Command Results")) - - // Verbose-level information for users - output.GetVerbose().Info("Processing command with argument: %s", argValue) - if flagValue != "" { - output.GetVerbose().Info("Using flag value: %s", flagValue) - } - - // Debug state information for developers - output.GetDebug().State("Arguments received: %d, Flag provided: %t", len(args), flagValue != "") - - // Use optimized repository access (smart caching like search/list commands) - r, err := repo.GetRepository() - if err != nil { - fontgetCmd.GetLogger().Error("Failed to initialize repository: %v", err) - return fmt.Errorf("failed to initialize repository: %w", err) - } - - // Get manifest from repository - manifest, err := r.GetManifest() - if err != nil { - fontgetCmd.GetLogger().Error("Failed to get manifest: %v", err) - return fmt.Errorf("failed to get manifest: %w", err) - } - - // Example: Process fonts from manifest - // Replace this with your actual command logic - for _, sourceInfo := range manifest.Sources { - for fontID, fontInfo := range sourceInfo.Fonts { - // Example processing - replace with your actual logic - _ = fontID - _ = fontInfo - // Add your processing logic here - } - } - - // Print results using modern UI styling - fmt.Printf("\nFound %d items matching '%s'", 0, ui.TableSourceName.Render(argValue)) - if flagValue != "" { - fmt.Printf(" with flag '%s'", ui.TableSourceName.Render(flagValue)) - } - fmt.Println() - - // Verbose information about the operation - output.GetVerbose().Info("Operation completed successfully") - output.GetVerbose().Detail("Results", "Found %d matches", 0) - - // Debug performance information - output.GetDebug().Performance("Operation completed in ") - - // Log operation completion (always log to file) - fontgetCmd.GetLogger().Info("Command operation completed successfully") - return nil - }, -} - -func init() { - // 1. Add the command to the root command - // Note: Replace rootCmd with the actual root command variable from cmd package - // rootCmd.AddCommand(commandCmd) - - // 2. Add subcommands if needed (for commands like sources, config, etc.) - // commandCmd.AddCommand(subCommand1) - // commandCmd.AddCommand(subCommand2) - - // 3. Add flags - // String flag with short version - commandCmd.Flags().StringP("flag-name", "f", "", "Description of the flag") - // Boolean flag - commandCmd.Flags().BoolP("bool-flag", "b", false, "Description of the boolean flag") - // String slice flag - commandCmd.Flags().StringSliceP("slice-flag", "s", []string{}, "Description of the slice flag") - - // 4. Add flag completion if needed - commandCmd.RegisterFlagCompletionFunc("flag-name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Example flag completion - completions := []string{ - "flag-value1", - "flag-value2", - "flag-value3", - } - return completions, cobra.ShellCompDirectiveNoFileComp - }) - - // 5. Add required flags if any - // commandCmd.MarkFlagRequired("flag-name") -} - -/* -Usage Instructions: - -1. Copy this template to a new file in the cmd/ directory (e.g., cmd/command.go) -2. Replace "command" with your command name in the variable name and all references -3. Update the Use, Short, Long, and Example fields -4. Choose the appropriate Args validator -5. Implement the ValidArgsFunction if needed -6. Implement the RunE function with your command's logic -7. Add and configure flags in the init function -8. Add flag completion if needed -9. Mark flags as required if needed -10. For commands with subcommands, follow the sources command pattern -11. Register the command in cmd/root.go: rootCmd.AddCommand(commandCmd) - -IMPORTANT NOTES: - -- GetLogger() is available from cmd/root.go - when copying this template to cmd/ package, remove the "fontgetCmd" import alias and change fontgetCmd.GetLogger() to GetLogger() -- Always use SilenceUsage: true to prevent full help display on validation errors -- Always call cmdutils.EnsureManifestInitialized() before using repository -- Always log operation start, parameters, errors, and completion to file -- Use ui.RenderError() for error messages, return nil (not cmd.Help()) -- Use output.GetVerbose() for user-friendly detailed output -- Use output.GetDebug() for developer diagnostic output - -PERFORMANCE BEST PRACTICES: -- ALWAYS use repo.GetRepository() for normal operations (smart caching) -- In ValidArgsFunction / shell completion, use repo.GetRepositoryForShellCompletion() (cache-only, no spinners) -- ONLY use repo.GetManifest() directly when you need fresh data -- ONLY use repo.GetManifestWithRefresh() when forcing updates -- This ensures consistent performance across all commands - -STYLING BEST PRACTICES: -- Use ui.RenderError() for error messages -- Use ui.TableHeader.Render() for table headers -- Use ui.TableSourceName.Render() for highlighted text -- Use ui.Text.Render() for regular text -- Use ui.SuccessText.Render() for success messages -- Use ui.WarningText.Render() for warnings -- Use ui.InfoText.Render() for info messages -- Use ui.ErrorText.Render() for error messages - -LOGGING BEST PRACTICES: -- Always use GetLogger() from cmd/root.go (no placeholder needed) -- In this template, we use fontgetCmd.GetLogger() to avoid naming conflict with the cmd parameter -- When copying to cmd/ package, remove the import alias and use GetLogger() directly -- Use logger.Info() for operation start/completion and parameters -- Use logger.Error() for all errors -- Use logger.Warn() for warnings -- Use logger.Debug() for detailed debugging information -- ALWAYS log to file regardless of verbose/debug flags -- Logger level is controlled by config (ErrorLevel/InfoLevel/DebugLevel based on flags) - -VERBOSE/DEBUG MODE BEST PRACTICES: -- Use output.GetVerbose().Info(format, args...) for user-friendly detailed output -- Use output.GetVerbose().Warning/Error/Success(format, args...) for different message types -- Use output.GetVerbose().Detail(prefix, format, args...) for indented details -- Use output.GetDebug().Message(format, args...) for developer diagnostic output -- Use output.GetDebug().State/Performance/Error/Warning(format, args...) for debug diagnostics -- Clean, consistent interface - no manual styling needed -- Users can combine --verbose --debug for maximum detail -- Keep normal output clean and ensure verbose/debug doesn't interfere with operation - -ERROR HANDLING PATTERN: -- In Args validator: Print error with ui.RenderError(), show hint, return nil -- In RunE: Return fmt.Errorf() with wrapped errors for actual failures -- Always log errors to file with GetLogger().Error() - -EXAMPLES: - -// Verbose output (user-friendly) -output.GetVerbose().Info("Installing fonts to: %s", fontDir) -output.GetVerbose().Detail("Info", "Font exists at: %s", path) -output.GetVerbose().Warning("Font may be corrupted") -output.GetVerbose().Error("Installation failed: %s", err.Error()) - -// Debug output (developer diagnostics) -output.GetDebug().Message("Debug mode enabled - detailed diagnostics") -output.GetDebug().State("Current working directory: %s", dir) -output.GetDebug().Performance("Operation completed in %v", duration) -output.GetDebug().Error("Critical system error: %v", err) - -// Error handling in Args validator -Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 || strings.TrimSpace(args[0]) == "" { - fmt.Printf("\n%s\n", ui.RenderError("A font name is required")) - fmt.Printf("Use 'fontget command --help' for more information.\n\n") - return nil // Prevents duplicate error from Cobra - } - return nil -}, - -// Error handling in RunE -if err != nil { - fontgetCmd.GetLogger().Error("Operation failed: %v", err) - return fmt.Errorf("operation failed: %w", err) -} - -Standard Help Formatting: -- Use winget-style help with "usage:" line -- Include subcommands in "The following sub-commands are available:" section -- Include flags in "The following options are available:" section -- End with "For more details on a specific command, pass it the help argument. [-?]" - -Table Formatting (for list/search commands): -- Use consistent column widths matching search command -- Standard columns: Name, ID, License, Categories, Source -- Use ui.Text.Render() for header separator -- Include manifest info at bottom using ui.Text.Render() - -IMPORT STRUCTURE: -Follow standard Go import grouping: -1. Standard library (fmt, strings, etc.) -2. Internal packages (fontget/internal/...) -3. Third-party packages (github.com/...) - -COMMAND STRUCTURE: -1. Package declaration -2. Imports (grouped: stdlib, internal, third-party) -3. Constants (if any) -4. Types (if any) -5. Command definition (var commandCmd) -6. Helper functions (if any) -7. init() function - -For commands with subcommands, see cmd/sources.go for reference. -*/ diff --git a/internal/ui/components.go b/internal/ui/components.go index 649003e..ad7f784 100644 --- a/internal/ui/components.go +++ b/internal/ui/components.go @@ -3,7 +3,6 @@ package ui import ( "fmt" "os" - "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -40,48 +39,6 @@ func RenderTitleWithSubtitle(title, subtitle string) string { Text.Render(subtitle) + "\n" } -// RenderStatusReport renders a status report with consistent styling -func RenderStatusReport(title string, items map[string]int) string { - var content strings.Builder - - content.WriteString("\n") - content.WriteString(TextBold.Render(title)) - content.WriteString("\n") - content.WriteString("---------------------------------------------") - content.WriteString("\n") - - // Status items - var statusItems []string - for label, count := range items { - var style lipgloss.Style - switch label { - case "Installed", "Updated", "Success": - style = SuccessText - case "Failed", "Error": - style = ErrorText - case "Skipped", "Warning": - style = WarningText - default: - style = Text - } - statusItems = append(statusItems, fmt.Sprintf("%s: %d", style.Render(label), count)) - } - - content.WriteString(strings.Join(statusItems, " | ")) - content.WriteString("\n") - - return content.String() -} - -// RenderCommandHelp renders command help with consistent styling -func RenderCommandHelp(commands []string) string { - var helpItems []string - for _, cmd := range commands { - helpItems = append(helpItems, TextBold.Render(cmd)) - } - return strings.Join(helpItems, " ") -} - // RenderSearchResults renders search results with consistent formatting func RenderSearchResults(query string, count int) string { return RenderTitleWithSubtitle( @@ -99,15 +56,6 @@ func RenderLoadingScreen(message string) string { ) } -// RenderErrorScreen renders an error screen -func RenderErrorScreen(title, message string) string { - return fmt.Sprintf("\n%s\n\n%s\n\n%s", - PageTitle.Render(title), - RenderError(message), - TextBold.Render("Press 'Q' to quit"), - ) -} - // RenderSuccessScreen renders a success screen func RenderSuccessScreen(title, message string) string { return fmt.Sprintf("\n%s\n\n%s\n\n%s", @@ -150,137 +98,3 @@ func RunSpinner(msg, doneMsg string, fn func() error) error { return nil } - -// SimpleProgressBar provides a simple inline progress bar without TUI -// It renders the title on one line and updates the progress bar inline using carriage returns -type SimpleProgressBar struct { - title string - barWidth int - startColor string - endColor string -} - -// NewSimpleProgressBar creates a new simple progress bar -func NewSimpleProgressBar(title string) *SimpleProgressBar { - startColor, endColor := GetProgressBarGradient() - return &SimpleProgressBar{ - title: title, - barWidth: 15, - startColor: startColor, - endColor: endColor, - } -} - -// Run executes the operation and updates the progress bar -// The update function is called with a callback that accepts a percentage (0-100) -func (p *SimpleProgressBar) Run(operation func(update func(percent float64)) error) error { - // Print the title on its own line - fmt.Println(p.title) - - // Print initial empty progress bar line so updates don't overwrite the title - fmt.Print("\n") - - // Run the operation with progress updates - err := operation(func(percent float64) { - p.update(percent) - }) - - // Add newline after progress bar (operation should have already called update(100.0)) - fmt.Println() - - return err -} - -// update renders the progress bar inline using carriage return -func (p *SimpleProgressBar) update(percent float64) { - // Clamp percent to 0-100 - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - - // Calculate filled and empty portions - filled := int(float64(p.barWidth) * percent / 100.0) - empty := p.barWidth - filled - - // Build the progress bar with gradient - var bar strings.Builder - - // Filled portion with gradient - for i := 0; i < filled; i++ { - var ratio float64 - if filled > 1 { - ratio = float64(i) / float64(filled-1) - } else { - ratio = 0.0 - } - if ratio > 1.0 { - ratio = 1.0 - } - if ratio < 0.0 { - ratio = 0.0 - } - - // Interpolate color - color := interpolateHexColor(p.startColor, p.endColor, ratio) - style := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) - bar.WriteString(style.Render("█")) - } - - // Empty portion - emptyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")) - for i := 0; i < empty; i++ { - bar.WriteString(emptyStyle.Render("░")) - } - - // Render with carriage return to overwrite the line, with square brackets - fmt.Fprintf(os.Stdout, "\r[%s] %3.0f%%", bar.String(), percent) - os.Stdout.Sync() -} - -// interpolateHexColor interpolates between two hex colors -func interpolateHexColor(startHex, endHex string, ratio float64) string { - // Parse start color - startR, startG, startB := parseHexColor(startHex) - // Parse end color - endR, endG, endB := parseHexColor(endHex) - - // Interpolate - r := int(float64(startR) + (float64(endR)-float64(startR))*ratio) - g := int(float64(startG) + (float64(endG)-float64(startG))*ratio) - b := int(float64(startB) + (float64(endB)-float64(startB))*ratio) - - // Clamp to valid range - if r < 0 { - r = 0 - } - if r > 255 { - r = 255 - } - if g < 0 { - g = 0 - } - if g > 255 { - g = 255 - } - if b < 0 { - b = 0 - } - if b > 255 { - b = 255 - } - - return fmt.Sprintf("#%02x%02x%02x", r, g, b) -} - -// parseHexColor parses a hex color string (e.g., "#ff00ff") into RGB values -func parseHexColor(hex string) (r, g, b int) { - hex = strings.TrimPrefix(hex, "#") - if len(hex) != 6 { - return 0, 0, 0 - } - fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) - return r, g, b -} diff --git a/internal/update/config.go b/internal/update/config.go deleted file mode 100644 index 5d57a66..0000000 --- a/internal/update/config.go +++ /dev/null @@ -1,47 +0,0 @@ -package update - -import ( - "time" -) - -// UpdateConfig represents update configuration settings -type UpdateConfig struct { - AutoCheck bool - AutoUpdate bool - CheckInterval int // Hours between checks - LastChecked time.Time - UpdateChannel string // stable/beta/nightly -} - -// ShouldCheckForUpdatesConfig determines if an update check should be performed -// based on the UpdateConfig struct values. -func ShouldCheckForUpdatesConfig(config *UpdateConfig) bool { - if !config.AutoCheck { - return false - } - - // If never checked, should check - if config.LastChecked.IsZero() { - return true - } - - // Check if interval has passed - interval := time.Duration(config.CheckInterval) * time.Hour - return time.Since(config.LastChecked) >= interval -} - -// MarkChecked updates the LastChecked timestamp to now (UTC) -func MarkChecked(config *UpdateConfig) { - config.LastChecked = time.Now().UTC() -} - -// DefaultUpdateConfig returns default update configuration -func DefaultUpdateConfig() *UpdateConfig { - return &UpdateConfig{ - AutoCheck: true, - AutoUpdate: false, - CheckInterval: 24, // 24 hours - LastChecked: time.Time{}, - UpdateChannel: "stable", - } -} diff --git a/internal/update/release.go b/internal/update/release.go index 757ad5b..9589fd7 100644 --- a/internal/update/release.go +++ b/internal/update/release.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/blang/semver" + "golang.org/x/mod/semver" ) const ( @@ -60,7 +60,7 @@ func newReleaseClient() (*releaseClient, error) { // latestVersion resolves GitHub's stable-release permalink without following // the redirect. The redirect target is treated only as a version identifier; // all asset URLs are constructed locally from the validated tag. -func (c *releaseClient) latestVersion(ctx context.Context) (semver.Version, error) { +func (c *releaseClient) latestVersion(ctx context.Context) (string, error) { latest := *c.baseURL latest.Path = strings.TrimRight(c.baseURL.Path, "/") + "/latest" latest.RawQuery = "" @@ -71,7 +71,7 @@ func (c *releaseClient) latestVersion(ctx context.Context) (semver.Version, erro req, err := http.NewRequestWithContext(ctx, http.MethodGet, latest.String(), nil) if err != nil { - return semver.Version{}, fmt.Errorf("failed to create latest-release request: %w", err) + return "", fmt.Errorf("failed to create latest-release request: %w", err) } req.Header.Set("User-Agent", updateUserAgent) @@ -82,33 +82,33 @@ func (c *releaseClient) latestVersion(ctx context.Context) (semver.Version, erro resp, err := noRedirect.Do(req) if err != nil { - return semver.Version{}, fmt.Errorf("failed to resolve latest release: %w", err) + return "", fmt.Errorf("failed to resolve latest release: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return semver.Version{}, errReleaseNotFound + return "", errReleaseNotFound } if !isRedirectStatus(resp.StatusCode) { - return semver.Version{}, fmt.Errorf("latest release returned HTTP %d instead of a redirect", resp.StatusCode) + return "", fmt.Errorf("latest release returned HTTP %d instead of a redirect", resp.StatusCode) } location := resp.Header.Get("Location") if location == "" { - return semver.Version{}, fmt.Errorf("latest release redirect did not include Location") + return "", fmt.Errorf("latest release redirect did not include Location") } return parseLatestRedirect(c.baseURL, location) } -func (c *releaseClient) checksums(ctx context.Context, version semver.Version) ([]byte, error) { +func (c *releaseClient) checksums(ctx context.Context, version string) ([]byte, error) { return c.downloadAsset(ctx, version, "checksums.txt", maxChecksumsBytes, releaseRequestTimeout) } -func (c *releaseClient) archive(ctx context.Context, version semver.Version, name string) ([]byte, error) { +func (c *releaseClient) archive(ctx context.Context, version string, name string) ([]byte, error) { return c.downloadAsset(ctx, version, name, maxReleaseAssetBytes, archiveDownloadTimeout) } -func (c *releaseClient) downloadAsset(ctx context.Context, version semver.Version, name string, limit int64, timeout time.Duration) ([]byte, error) { +func (c *releaseClient) downloadAsset(ctx context.Context, version string, name string, limit int64, timeout time.Duration) ([]byte, error) { assetURL, err := c.assetURL(version, name) if err != nil { return nil, err @@ -147,13 +147,13 @@ func (c *releaseClient) downloadAsset(ctx context.Context, version semver.Versio return body, nil } -func (c *releaseClient) assetURL(version semver.Version, name string) (*url.URL, error) { +func (c *releaseClient) assetURL(version string, name string) (*url.URL, error) { if name == "" || name != url.PathEscape(name) || strings.ContainsAny(name, `/\`) { return nil, fmt.Errorf("invalid release asset name %q", name) } u := *c.baseURL - u.Path = strings.TrimRight(c.baseURL.Path, "/") + "/download/v" + version.String() + "/" + name + u.Path = strings.TrimRight(c.baseURL.Path, "/") + "/download/v" + version + "/" + name u.RawQuery = "" u.Fragment = "" if err := validateDownloadURL(&u, c.baseURL); err != nil { @@ -162,35 +162,35 @@ func (c *releaseClient) assetURL(version semver.Version, name string) (*url.URL, return &u, nil } -func parseLatestRedirect(base *url.URL, location string) (semver.Version, error) { +func parseLatestRedirect(base *url.URL, location string) (string, error) { target, err := base.Parse(location) if err != nil { - return semver.Version{}, fmt.Errorf("invalid latest release redirect: %w", err) + return "", fmt.Errorf("invalid latest release redirect: %w", err) } if target.User != nil || target.RawQuery != "" || target.Fragment != "" { - return semver.Version{}, fmt.Errorf("latest release redirect contains unexpected URL components") + return "", fmt.Errorf("latest release redirect contains unexpected URL components") } if !strings.EqualFold(target.Scheme, base.Scheme) || !strings.EqualFold(target.Host, base.Host) { - return semver.Version{}, fmt.Errorf("latest release redirected to unexpected origin %q", target.Host) + return "", fmt.Errorf("latest release redirected to unexpected origin %q", target.Host) } tagPrefix := strings.TrimRight(base.Path, "/") + "/tag/v" if !strings.HasPrefix(target.EscapedPath(), tagPrefix) { - return semver.Version{}, fmt.Errorf("latest release redirected to unexpected path %q", target.EscapedPath()) + return "", fmt.Errorf("latest release redirected to unexpected path %q", target.EscapedPath()) } rawVersion := strings.TrimPrefix(target.EscapedPath(), tagPrefix) if rawVersion == "" || strings.Contains(rawVersion, "/") { - return semver.Version{}, fmt.Errorf("latest release redirect contains invalid version %q", rawVersion) + return "", fmt.Errorf("latest release redirect contains invalid version %q", rawVersion) } decodedVersion, err := url.PathUnescape(rawVersion) if err != nil { - return semver.Version{}, fmt.Errorf("latest release redirect contains invalid version encoding: %w", err) + return "", fmt.Errorf("latest release redirect contains invalid version encoding: %w", err) } - version, err := semver.Parse(decodedVersion) - if err != nil { - return semver.Version{}, fmt.Errorf("latest release redirect contains invalid semantic version %q: %w", decodedVersion, err) + mod := "v" + strings.TrimPrefix(decodedVersion, "v") + if !semver.IsValid(mod) { + return "", fmt.Errorf("latest release redirect contains invalid semantic version %q", decodedVersion) } - return version, nil + return strings.TrimPrefix(semver.Canonical(mod), "v"), nil } func validateReleaseBaseURL(base *url.URL) error { diff --git a/internal/update/update.go b/internal/update/update.go index ab066b5..165cc8d 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -11,7 +11,7 @@ import ( "fontget/internal/logging" "fontget/internal/version" - "github.com/blang/semver" + "golang.org/x/mod/semver" ) // executablePath resolves the current binary. Tests override this. @@ -51,11 +51,11 @@ func CheckForUpdates() (*UpdateResult, error) { currentVersion, err := parseVersion(currentVersionStr) if err != nil { - needsUpdate := latestVersion.String() != currentVersionStr + needsUpdate := latestVersion != currentVersionStr return &UpdateResult{ Available: true, Current: currentVersionStr, - Latest: latestVersion.String(), + Latest: latestVersion, NeedsUpdate: needsUpdate, }, nil } @@ -63,8 +63,8 @@ func CheckForUpdates() (*UpdateResult, error) { return &UpdateResult{ Available: true, Current: currentVersionStr, - Latest: latestVersion.String(), - NeedsUpdate: latestVersion.GT(currentVersion), + Latest: latestVersion, + NeedsUpdate: versionGreater(latestVersion, currentVersion), }, nil } @@ -95,8 +95,8 @@ func UpdateToVersion(targetVersion string) error { return applyVersion(client, targetSemver) } -func applyVersion(client *releaseClient, releaseVersion semver.Version) error { - archiveName := currentArchiveName(releaseVersion.String()) +func applyVersion(client *releaseClient, releaseVersion string) error { + archiveName := currentArchiveName(releaseVersion) ctx := context.Background() checksumBytes, err := client.checksums(ctx, releaseVersion) if err != nil { @@ -149,18 +149,31 @@ func cleanupOldBinary(execPath string) { } } -// parseVersion parses a version string to semver.Version. +// parseVersion parses a version string to a canonical semver without a "v" prefix. // Handles "dev" (and other dev-prefixed strings) and versions with or without a "v" prefix. -func parseVersion(versionStr string) (semver.Version, error) { +func parseVersion(versionStr string) (string, error) { trimmed := strings.TrimSpace(versionStr) if trimmed == "" { - return semver.Version{}, fmt.Errorf("empty version") + return "", fmt.Errorf("empty version") } if trimmed == "dev" || strings.HasPrefix(trimmed, "dev-") || strings.HasPrefix(trimmed, "dev+") { - return semver.MustParse("0.0.0"), nil + return "0.0.0", nil } + mod := toModVersion(trimmed) + if !semver.IsValid(mod) { + return "", fmt.Errorf("invalid semantic version %q", versionStr) + } + return strings.TrimPrefix(semver.Canonical(mod), "v"), nil +} + +func toModVersion(versionStr string) string { + trimmed := strings.TrimSpace(versionStr) trimmed = strings.TrimPrefix(trimmed, "v") - return semver.Parse(trimmed) + return "v" + trimmed +} + +func versionGreater(a, b string) bool { + return semver.Compare(toModVersion(a), toModVersion(b)) > 0 } // mapLibraryError converts update errors to user-friendly messages diff --git a/internal/update/update_test.go b/internal/update/update_test.go index b339ed6..d202fb3 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -101,7 +101,7 @@ func TestParseLatestRedirect(t *testing.T) { if err != nil { t.Fatal(err) } - if got.String() != tc.want { + if got != tc.want { t.Fatalf("got %q, want %q", got, tc.want) } }) @@ -221,7 +221,7 @@ func TestLatestVersionDiscovery(t *testing.T) { if err != nil { t.Fatal(err) } - if got.String() != "1.2.3" { + if got != "1.2.3" { t.Fatalf("got %q, want 1.2.3", got) } } @@ -500,7 +500,7 @@ func TestParseVersion(t *testing.T) { if err != nil { t.Fatalf("%q: %v", tc.input, err) } - if got.String() != tc.want { + if got != tc.want { t.Fatalf("%q: got %q, want %q", tc.input, got, tc.want) } } From 1c911e38c989036a45b76d59354bb9f628012bc3 Mon Sep 17 00:00:00 2001 From: graphixa Date: Sun, 13 Sep 2026 23:39:33 +1000 Subject: [PATCH 07/23] Refactor progress reporting and enhance cancellation handling in font operations --- cmd/add.go | 194 ++++++++++++---------- cmd/add_precheck_test.go | 123 ++++++++++++++ cmd/browse_model.go | 69 ++++++-- cmd/export.go | 52 ++++-- cmd/import.go | 52 +++--- cmd/progress_steps.go | 239 +++++++++++++++++----------- cmd/progress_steps_test.go | 70 ++++++++ cmd/remove.go | 120 +++++++++----- internal/components/progress_bar.go | 9 +- internal/shared/progress.go | 52 ------ internal/shared/progress_test.go | 25 --- 11 files changed, 677 insertions(+), 328 deletions(-) create mode 100644 cmd/add_precheck_test.go diff --git a/cmd/add.go b/cmd/add.go index 05829ca..f1c1ef4 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -705,29 +705,22 @@ Use --scope to set installation location: percent := float64(itemIndex) / float64(len(fontsToInstall)) * 100 send(components.ProgressUpdateMsg{Percent: percent}) - lastStep := "" - lastPctBucket := -1 - onProgress := func(step string, stepPct float64) { - bucket := int(shared.Clamp01(stepPct) * 20.0) - if step == lastStep && bucket == lastPctBucket { + var th progressThrottle + onProgress := func(u ProgressUpdate) { + pct := OverallWorkPercent(itemIndex, len(fontsToInstall), u) + if !th.ShouldSend(u, pct) { return } - lastStep = step - lastPctBucket = bucket - - msg := step + "..." - if step == installStepDownload { + msg := FormatProgressActivity(u.Phase, u.Detail) + if u.Phase == installStepDownload && u.Detail == "" { msg = "Downloading from " + fontGroup.SourceName } - send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", Message: msg, }) - send(components.ProgressUpdateMsg{ - Percent: OverallInstallPercent(itemIndex, len(fontsToInstall), step, stepPct), - }) + send(components.ProgressUpdateMsg{Percent: pct}) } result, err := installFont( ctx, @@ -817,7 +810,7 @@ Use --scope to set installation location: Scope: scopeLabel, }) - send(components.ProgressUpdateMsg{Percent: OverallInstallPercent(itemIndex, len(fontsToInstall), installStepCompleted, 1)}) + send(components.ProgressUpdateMsg{Percent: OverallWorkPercent(itemIndex, len(fontsToInstall), ProgressUpdate{Phase: installStepCompleted})}) } return nil @@ -946,7 +939,7 @@ func archiveSourcePrefixFromFontID(fontID string) string { } // downloadFontVariants downloads all variants of a font family -func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, staging *platform.OperationStaging, fontID string, archiveSourcePrefix string, downloadOpts *repo.DownloadFontOptions, onProgress StepProgressFunc) ([]string, error) { +func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, staging *platform.OperationStaging, fontID string, archiveSourcePrefix string, downloadOpts *repo.DownloadFontOptions, onProgress ProgressFunc) ([]string, error) { start := time.Now() var allFontPaths []string @@ -958,7 +951,12 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin } } if onProgress != nil && total > 0 { - onProgress(installStepDownload, float64(i)/float64(total)) + onProgress(ProgressUpdate{ + Phase: installStepDownload, + Kind: ProgressCount, + Done: float64(i), + Total: float64(total), + }) } opts := downloadOpts if opts == nil { @@ -972,16 +970,25 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin opts.ArchiveFontID = fontID if onProgress != nil { opts.OnBytesDownloaded = func(doneBytes int64, totalBytes int64) { - if totalBytes > 0 { - onProgress(installStepDownload, float64(doneBytes)/float64(totalBytes)) - } + onProgress(ProgressUpdate{ + Phase: installStepDownload, + Kind: ProgressBytes, + Done: float64(doneBytes), + Total: float64(totalBytes), + }) } opts.OnExtractProgress = func(done int, total int) { - if total > 0 { - onProgress(installStepExtract, float64(done)/float64(total)) - return + t := float64(total) + if total <= 0 { + // Package-mode streams (e.g. nerd tar.xz) don't know M until EOF. + t = float64(done + 12) } - onProgress(installStepExtract, float64(done)/float64(done+12)) + onProgress(ProgressUpdate{ + Phase: installStepExtract, + Kind: ProgressCount, + Done: float64(done), + Total: t, + }) } } @@ -1003,15 +1010,15 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin } if onProgress != nil { - onProgress(installStepDownload, 1) - onProgress(installStepExtract, 1) + onProgress(ProgressUpdate{Phase: installStepDownload, Kind: ProgressBytes, Done: 1, Total: 0}) + onProgress(ProgressUpdate{Phase: installStepExtract, Kind: ProgressCount, Done: 1, Total: 0}) } output.GetDebug().State("downloadFontVariants: files=%d extracted=%d total=%dms", len(fontFiles), len(allFontPaths), time.Since(start).Milliseconds()) return allFontPaths, nil } // installDownloadedFonts installs downloaded font files to system -func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress StepProgressFunc, tc *installTestControl) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { +func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress ProgressFunc, tc *installTestControl) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { start := time.Now() var installedFiles []string var skippedFiles []string @@ -1035,10 +1042,16 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager break } } + fontDisplayName := filepath.Base(fontPath) if onProgress != nil && total > 0 { - onProgress(installStepInstall, float64(i)/float64(total)) + onProgress(ProgressUpdate{ + Phase: installStepInstall, + Detail: fmt.Sprintf("variants (%d of %d)", i+1, total), + Kind: ProgressCount, + Done: float64(i), + Total: float64(total), + }) } - fontDisplayName := filepath.Base(fontPath) if fileInfo, statErr := os.Stat(fontPath); statErr == nil { downloadSize += fileInfo.Size() @@ -1109,12 +1122,24 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager } if onProgress != nil { - onProgress(installStepInstall, 1) + onProgress(ProgressUpdate{ + Phase: installStepInstall, + Kind: ProgressCount, + Done: float64(total), + Total: float64(max(total, 1)), + }) } + if err == nil && installed > 0 { + if ctx != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + } + } + } if err == nil && installed > 0 { if onProgress != nil { - onProgress(installStepFinalize, 0) + onProgress(ProgressUpdate{Phase: installStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) } if flushErr := fontManager.FlushFontCache(installScope); flushErr != nil { errStr := flushErr.Error() @@ -1126,7 +1151,7 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager } } if onProgress != nil { - onProgress(installStepFinalize, 1) + onProgress(ProgressUpdate{Phase: installStepFinalize, Kind: ProgressFlag, Done: 1, Total: 1}) } } @@ -1186,14 +1211,14 @@ func installFont( fontDir string, staging *platform.OperationStaging, suppressVerboseDownloads bool, - onProgress StepProgressFunc, + onProgress ProgressFunc, tc *installTestControl, ) (*InstallResult, error) { if ctx == nil { ctx = context.Background() } if onProgress != nil { - onProgress(installStepPrecheck, 0) + onProgress(ProgressUpdate{Phase: installStepPrecheck, Kind: ProgressFlag, Done: 0, Total: 1}) } if !force && fontID != "" && len(fontFiles) > 0 { fontName := "" @@ -1205,8 +1230,8 @@ func installFont( GetLogger().Warn("Failed to check if font is already installed (ID: %s): %v. Proceeding with installation.", fontID, checkErr) } else if alreadyInstalled { if onProgress != nil { - onProgress(installStepPrecheck, 1) - onProgress(installStepCompleted, 1) + onProgress(ProgressUpdate{Phase: installStepPrecheck, Kind: ProgressFlag, Done: 1, Total: 1}) + onProgress(ProgressUpdate{Phase: installStepCompleted}) } output.GetDebug().State("Font %s (ID: %s) is already installed, skipping download", fontName, fontID) var details []string @@ -1217,7 +1242,7 @@ func installFont( } } if onProgress != nil { - onProgress(installStepPrecheck, 1) + onProgress(ProgressUpdate{Phase: installStepPrecheck, Kind: ProgressFlag, Done: 1, Total: 1}) } if staging == nil { @@ -1237,7 +1262,7 @@ func installFont( } archivePrefix := archiveSourcePrefixFromFontID(fontID) if onProgress != nil { - onProgress(installStepDownload, 0) + onProgress(ProgressUpdate{Phase: installStepDownload, Kind: ProgressBytes, Done: 0, Total: 1}) } allFontPaths, downloadErr := downloadFontVariants(ctx, fontFiles, staging, fontID, archivePrefix, downloadOpts, onProgress) if downloadErr != nil { @@ -1274,6 +1299,15 @@ func installFont( res := buildInstallResult(status, message, installed, skipped, failed, details, instErrs, downloadSize) if status == InstallStatusCompleted && installed > 0 { + if err := ctx.Err(); err != nil { + rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, err) + res.Status = InstallStatusFailed + res.Success = 0 + if rbErr != nil { + return res, rbErr + } + return res, err + } if tc != nil && tc.failProvenance { rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, fmt.Errorf("injected provenance failure")) res.Status = InstallStatusFailed @@ -1475,85 +1509,53 @@ func makeUserFriendlyError(fontName string, err error) string { } // checkFontsAlreadyInstalled checks if a font is already installed in the specified scope. -// It uses the same matching logic as the list command (collectFonts and MatchAllInstalledFonts) -// to match by Font ID (most accurate) and family name (fallback). -// Returns true if the font is already installed, false otherwise. -// Note: This function scans the font directory each time it's called. For multiple fonts, -// checkFontsAlreadyInstalled checks if a font is already installed in the specified scope. -// -// It collects installed fonts from the target scope, matches them against the repository to get -// Font IDs, and checks if the provided fontID matches any installed font. -// -// This function is used to avoid unnecessary downloads when a font is already installed. -// Note: For performance with many fonts, consider pre-collecting fonts and using a cached approach. // -// Parameters: -// - fontID: Font identifier to check -// - fontName: Font name (used for fallback matching if Font ID matching fails) -// - scope: Installation scope to check (user or machine) -// - fontManager: Platform-specific font manager -// -// Returns: -// - bool: true if font is already installed, false otherwise -// - error: Error if font collection or matching fails +// Order: installations registry (Font ID + scope + all files present), then directory +// scan + repository match (Font ID, then family-name fallback). Used to skip download +// when safe. Registry load failures fall through to the scan path. func checkFontsAlreadyInstalled(fontID string, fontName string, scope platform.InstallationScope, fontManager platform.FontManager) (bool, error) { - // Early return if fontID is empty (can't check without ID) if fontID == "" { return false, nil } - // Collect installed fonts from the target scope - // Suppress verbose output since this is an internal check, not a primary operation + if ok, handled := checkInstalledViaRegistry(fontID, scope); handled { + return ok, nil + } + + // Fallback: scan installed fonts and match against the repository. scopes := []platform.InstallationScope{scope} fonts, err := collectFonts(scopes, fontManager, "", true) if err != nil { return false, fmt.Errorf("failed to collect installed fonts: %w", err) } - - // Early return if no fonts found if len(fonts) == 0 { return false, nil } - // Group fonts by family name families := groupByFamily(fonts) if len(families) == 0 { return false, nil } - // Get all family names var familyNames []string for familyName := range families { familyNames = append(familyNames, familyName) } - // Match installed fonts to repository entries matches, err := repo.MatchAllInstalledFonts(familyNames, shared.IsCriticalSystemFont) if err != nil { - // If matching fails, we can't determine if font is installed, so return false - // This allows the installation to proceed (fail-safe) - // Note: Error is not returned to caller, but this is intentional for fail-safe behavior + // Fail-open: proceed with install if matching fails. return false, nil } - // Normalize font ID for comparison (case-insensitive) - do this once fontIDLower := strings.ToLower(fontID) - - // Check if any installed font matches the target Font ID (most accurate match) for _, match := range matches { - if match != nil { - // Match by Font ID (most accurate) - matchIDLower := strings.ToLower(match.FontID) - if matchIDLower == fontIDLower { - return true, nil - } + if match != nil && strings.ToLower(match.FontID) == fontIDLower { + return true, nil } } - // Fallback: check by family name if Font ID didn't match - // This handles cases where the font might be installed but not matched to repository - // Note: This fallback may have false positives (e.g., "Roboto" might match "Roboto Mono") - // but it's acceptable as a fallback for fonts not in the repository + // Family-name fallback for fonts not matched to the repository. if fontName != "" { fontNameLower := strings.ToLower(fontName) fontNameNorm := strings.ReplaceAll(fontNameLower, " ", "") @@ -1566,7 +1568,6 @@ func checkFontsAlreadyInstalled(fontID string, fontName string, scope platform.I familyNorm = strings.ReplaceAll(familyNorm, "-", "") familyNorm = strings.ReplaceAll(familyNorm, "_", "") - // Check for exact match (normalized) if familyLower == fontNameLower || familyNorm == fontNameNorm { return true, nil } @@ -1576,6 +1577,33 @@ func checkFontsAlreadyInstalled(fontID string, fontName string, scope platform.I return false, nil } +// checkInstalledViaRegistry returns (installed, handled). +// handled=false means the caller should use the scan/match fallback +// (no record, load error, or empty file list). +func checkInstalledViaRegistry(fontID string, scope platform.InstallationScope) (installed bool, handled bool) { + reg, err := installations.Load() + if err != nil { + GetLogger().Warn("Failed to load installation registry for precheck (ID: %s): %v. Falling back to font scan.", fontID, err) + return false, false + } + inst := reg.FindByFontID(fontID) + if inst == nil || !inst.HasFaces() { + return false, false + } + if !strings.EqualFold(strings.TrimSpace(inst.Scope), strings.TrimSpace(string(scope))) { + return false, true // recorded under a different scope → not installed here + } + for _, f := range inst.FlatFiles() { + if strings.TrimSpace(f.Path) == "" { + return false, true + } + if _, err := os.Stat(f.Path); err != nil { + return false, true // vanished/partial → allow repair install + } + } + return true, true +} + func init() { rootCmd.AddCommand(addCmd) addCmd.Flags().StringP("scope", "s", "", "Installation scope (user or machine)") diff --git a/cmd/add_precheck_test.go b/cmd/add_precheck_test.go new file mode 100644 index 0000000..2f879fa --- /dev/null +++ b/cmd/add_precheck_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "fontget/internal/installations" + "fontget/internal/platform" + "fontget/internal/testutil" +) + +func TestCheckInstalledViaRegistry_allFilesPresent(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + file := filepath.Join(fontDir, "Noto.ttf") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "nerd.noto", + CatalogName: "Noto", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: file, SFNT: installations.SFNTSnapshot{Family: "Noto"}}, + }, + }); err != nil { + t.Fatal(err) + } + + ok, handled := checkInstalledViaRegistry("nerd.noto", platform.UserScope) + if !handled || !ok { + t.Fatalf("got installed=%v handled=%v want true,true", ok, handled) + } + // Case-insensitive Font ID + ok, handled = checkInstalledViaRegistry("NERD.NOTO", platform.UserScope) + if !handled || !ok { + t.Fatalf("case-insensitive: installed=%v handled=%v", ok, handled) + } +} + +func TestCheckInstalledViaRegistry_missingFile(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + missing := filepath.Join(t.TempDir(), "gone.ttf") + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "nerd.noto", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: missing, SFNT: installations.SFNTSnapshot{Family: "Noto"}}, + }, + }); err != nil { + t.Fatal(err) + } + + ok, handled := checkInstalledViaRegistry("nerd.noto", platform.UserScope) + if !handled || ok { + t.Fatalf("got installed=%v handled=%v want false,true", ok, handled) + } +} + +func TestCheckInstalledViaRegistry_wrongScope(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + file := filepath.Join(fontDir, "Noto.ttf") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "nerd.noto", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: file, SFNT: installations.SFNTSnapshot{Family: "Noto"}}, + }, + }); err != nil { + t.Fatal(err) + } + + ok, handled := checkInstalledViaRegistry("nerd.noto", platform.MachineScope) + if !handled || ok { + t.Fatalf("got installed=%v handled=%v want false,true", ok, handled) + } +} + +func TestCheckInstalledViaRegistry_noRecord(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + + ok, handled := checkInstalledViaRegistry("nerd.missing", platform.UserScope) + if handled || ok { + t.Fatalf("got installed=%v handled=%v want false,false", ok, handled) + } +} + +func TestCheckFontsAlreadyInstalled_registryShortCircuit(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + file := filepath.Join(fontDir, "Pack.ttf") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "nerd.pack", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: file, SFNT: installations.SFNTSnapshot{Family: "Pack"}}, + }, + }); err != nil { + t.Fatal(err) + } + + // nil fontManager is fine: registry hit must not scan. + ok, err := checkFontsAlreadyInstalled("nerd.pack", "Pack", platform.UserScope, nil) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected already installed via registry") + } +} diff --git a/cmd/browse_model.go b/cmd/browse_model.go index 32fb1a5..2a71b2f 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "strings" "sync" @@ -118,7 +119,8 @@ type browseModel struct { statusProgress float64 statusPhase string - opMsgCh <-chan tea.Msg + opMsgCh <-chan tea.Msg + opCancel context.CancelFunc debounceGen int @@ -423,6 +425,9 @@ func browseResultFromInstall(fontName, source string, msg installFinishedMsg) (t } source = browseNormalizeSourceLabel(source) if msg.err != nil { + if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, shared.ErrOperationCancelled) { + return "Cancelled", false, ui.InfoText.Render("Installation cancelled.") + } return "Error", true, ui.RenderError(msg.err.Error()) } if msg.result == nil { @@ -451,6 +456,9 @@ func browseResultFromUninstall(fontName string, installScope platform.Installati fontName = shared.PlaceholderNA } if msg.err != nil { + if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, shared.ErrOperationCancelled) { + return "Cancelled", false, ui.InfoText.Render("Uninstall cancelled.") + } return "Error", true, ui.RenderError(msg.err.Error()) } if msg.result == nil { @@ -545,10 +553,14 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea force := m.force fontDir := m.fontDir + ctx, cancel := context.WithCancel(context.Background()) + m.opCancel = cancel + ch := make(chan tea.Msg, 32) m.opMsgCh = ch go func() { defer close(ch) + defer cancel() res, err := shared.ResolveFontQuery(fontID) if err != nil { ch <- installFinishedMsg{err: err, fontID: fontID} @@ -559,11 +571,22 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea return } - onProgress := func(step string, stepPct float64) { - ch <- browseOpProgressMsg{phase: step, percent: OverallInstallPercent(0, 1, step, stepPct)} + var th progressThrottle + onProgress := func(u ProgressUpdate) { + if ctx.Err() != nil { + return + } + pct := OverallWorkPercent(0, 1, u) + if !th.ShouldSend(u, pct) { + return + } + ch <- browseOpProgressMsg{phase: FormatProgressActivity(u.Phase, u.Detail), percent: pct} } - ir, ierr := installFont(context.Background(), res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress, nil) + ir, ierr := installFont(ctx, res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress, nil) + if ctx.Err() != nil && ierr == nil { + ierr = shared.ErrOperationCancelled + } ch <- installFinishedMsg{result: ir, err: ierr, fontID: fontID} }() return m.waitForOpMsg() @@ -584,15 +607,30 @@ func (m *browseModel) startUninstallByID(fontID, fontName, sourceLabel string) t fontDir := m.fontDir repository := m.repository + ctx, cancel := context.WithCancel(context.Background()) + m.opCancel = cancel + ch := make(chan tea.Msg, 8) m.opMsgCh = ch go func() { defer close(ch) - onProgress := func(step string, stepPct float64) { - ch <- browseOpProgressMsg{phase: step, percent: OverallRemovePercent(0, 1, step, stepPct)} + defer cancel() + var th progressThrottle + onProgress := func(u ProgressUpdate) { + if ctx.Err() != nil { + return + } + pct := OverallWorkPercent(0, 1, u) + if !th.ShouldSend(u, pct) { + return + } + ch <- browseOpProgressMsg{phase: FormatProgressActivity(u.Phase, u.Detail), percent: pct} } installReg, _ := m.cachedInstallRegistry() - rr, err := removeFont(fontID, fm, scope, fontDir, repository, installReg, m.cachedManifestFontIDProbe(), onProgress) + rr, err := removeFont(ctx, fontID, fm, scope, fontDir, repository, installReg, m.cachedManifestFontIDProbe(), onProgress) + if ctx.Err() != nil && err == nil { + err = shared.ErrOperationCancelled + } ch <- uninstallFinishedMsg{result: rr, err: err, fontID: fontID} }() return m.waitForOpMsg() @@ -969,6 +1007,9 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.installing && !m.removing { return m, nil } + if m.statusPhase == "Cancelling..." { + return m, m.waitForOpMsg() + } if strings.TrimSpace(msg.phase) != "" { m.statusPhase = msg.phase } @@ -983,6 +1024,7 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusProgress = 0 m.statusPhase = "" m.opMsgCh = nil + m.opCancel = nil title, errTitle, body := browseResultFromUninstall(fontName, m.installScope, msg) m.openResultModal(title, errTitle, body) cmd := m.syncTableDimensions() @@ -997,6 +1039,7 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusProgress = 0 m.statusPhase = "" m.opMsgCh = nil + m.opCancel = nil title, errTitle, body := browseResultFromInstall(fontName, source, msg) m.openResultModal(title, errTitle, body) cmd := m.syncTableDimensions() @@ -1004,8 +1047,16 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.installing || m.removing { - if km, ok := msg.(tea.KeyMsg); ok && km.String() == "ctrl+c" { - return m, tea.Quit + if km, ok := msg.(tea.KeyMsg); ok { + k := km.String() + if k == "ctrl+c" || k == "esc" { + if m.opCancel != nil { + m.opCancel() + m.opCancel = nil + } + m.statusPhase = "Cancelling..." + return m, m.waitForOpMsg() + } } return m, nil } diff --git a/cmd/export.go b/cmd/export.go index 0e4d5ad..4cbeb53 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -234,6 +234,7 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor } // Phase 1: Collect fonts (0-20% progress) + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Scanning", "")}) send(components.ProgressUpdateMsg{Percent: 5.0}) output.GetVerbose().Info("Scanning fonts to determine export scope...") fonts, collectErr := collectFonts(scopes, fontManager, "", true) // Suppress verbose - we have our own high-level message @@ -251,6 +252,7 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor } // Phase 2: Group by family (20-30% progress) + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Grouping", "")}) families := groupByFamily(fonts) output.GetVerbose().Info("Grouped into %d font families", len(families)) send(components.ProgressUpdateMsg{Percent: 30.0}) @@ -269,6 +271,7 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor } sort.Strings(names) + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Matching", "")}) send(components.ProgressUpdateMsg{Percent: 35.0}) matches, matchErr := cmdutils.MatchInstalledFontsToRepository(names, GetLogger(), shared.IsCriticalSystemFont) if matchErr != nil { @@ -288,6 +291,7 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor } // Phase 4: Populate match data and filter fonts (50-60% progress) + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Filtering", "")}) populateFontMatchData(families, matches) fontIDGroups, skippedSystem, skippedUnmatched, skippedByFilter := filterFontsForExport(FilterFontsForExportParams{ @@ -319,6 +323,7 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor } // Phase 5: Perform the actual export operation (60-100% progress) + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Writing", "")}) params := ExportProgressParams{ FontManager: fontManager, Scopes: scopes, @@ -403,13 +408,41 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms } } + if send != nil { + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Building", "")}) + send(components.ProgressUpdateMsg{Percent: 70.0}) + } + // Build export manifest manifest, totalVariants := buildExportManifest( params.FontIDGroups, params.MatchFilter, params.SourceFilter, params.OnlyMatched, params.SkippedSystem, params.SkippedUnmatched, params.SkippedByFilter) - // Update progress + // Mark families complete with i/N activity (70–95%) if send != nil { - send(components.ProgressUpdateMsg{Percent: 50.0}) + n := len(manifest.Fonts) + for i, font := range manifest.Fonts { + if cancelChan != nil { + select { + case <-cancelChan: + return nil, 0, shared.ErrOperationCancelled + default: + } + } + label := font.FontID + if label == "" && len(font.FamilyNames) > 0 { + label = font.FamilyNames[0] + } + pct := 70.0 + if n > 0 { + pct = 70.0 + (float64(i+1)/float64(n))*25.0 + } + send(components.ProgressUpdateMsg{Percent: pct}) + send(components.ItemUpdateMsg{ + Index: i, + Name: label, + Status: "completed", + }) + } } // Check for cancellation before writing @@ -422,6 +455,11 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms } } + if send != nil { + send(components.TitleUpdateMsg{Title: FormatProgressActivity("Writing", "manifest")}) + send(components.ProgressUpdateMsg{Percent: 95.0}) + } + // Write manifest output.GetVerbose().Info("Writing export file...") jsonData, err := json.MarshalIndent(*manifest, "", " ") @@ -451,17 +489,9 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms GetLogger().Info("Export file written successfully: %s", params.OutputFile) output.GetVerbose().Info("Export file written successfully") - // Update progress to 100% if send != nil { + send(components.TitleUpdateMsg{Title: "Exporting Fonts"}) send(components.ProgressUpdateMsg{Percent: 100.0}) - // Mark all items as completed so the count shows correctly - // The progress bar component counts items with status "completed", "failed", or "skipped" - for i := 0; i < params.TotalFamilies; i++ { - send(components.ItemUpdateMsg{ - Index: i, - Status: "completed", - }) - } } return manifest.Fonts, totalVariants, nil diff --git a/cmd/import.go b/cmd/import.go index 06e81fb..0eb5185 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -598,8 +598,25 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni verbose, // Verbose mode: show operational details and file/variant listings debug, // Debug mode: show technical details func(send func(msg tea.Msg), cancelChan <-chan struct{}) error { + opCtx := cmd.Context() + if opCtx == nil { + opCtx = context.Background() + } + ctx, cancel := context.WithCancel(opCtx) + defer cancel() + go func() { + select { + case <-cancelChan: + cancel() + case <-ctx.Done(): + } + }() + // Process each font group for itemIndex, fontGroup := range fontsToInstall { + if err := ctx.Err(); err != nil { + return shared.ErrOperationCancelled + } send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", @@ -609,33 +626,25 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni percent := float64(itemIndex) / float64(len(fontsToInstall)) * 100 send(components.ProgressUpdateMsg{Percent: percent}) - // Install the font - lastStep := "" - lastPctBucket := -1 - onProgress := func(step string, stepPct float64) { - bucket := int(shared.Clamp01(stepPct) * 20.0) - if step == lastStep && bucket == lastPctBucket { + var th progressThrottle + onProgress := func(u ProgressUpdate) { + pct := OverallWorkPercent(itemIndex, len(fontsToInstall), u) + if !th.ShouldSend(u, pct) { return } - lastStep = step - lastPctBucket = bucket - - msg := step + "..." - if step == installStepDownload { + msg := FormatProgressActivity(u.Phase, u.Detail) + if u.Phase == installStepDownload && u.Detail == "" { msg = "Downloading from " + fontGroup.SourceName } - send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", Message: msg, }) - send(components.ProgressUpdateMsg{ - Percent: OverallInstallPercent(itemIndex, len(fontsToInstall), step, stepPct), - }) + send(components.ProgressUpdateMsg{Percent: pct}) } result, err := installFont( - cmd.Context(), + ctx, fontGroup.Fonts, fontGroup.FontID, fontManager, @@ -649,7 +658,11 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni ) if err != nil { - status.Failed += result.Failed + if result != nil { + status.Failed += result.Failed + } else { + status.Failed++ + } GetLogger().Error("Failed to process font %s: %v", fontGroup.FontName, err) errorMsg := err.Error() send(components.ItemUpdateMsg{ @@ -658,6 +671,9 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni Message: "Operation failed", ErrorMessage: errorMsg, }) + if errors.Is(err, shared.ErrOperationCancelled) || errors.Is(err, context.Canceled) { + return shared.ErrOperationCancelled + } continue } @@ -683,7 +699,7 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni Scope: "", // Empty for single-scope operations (cleaner output) }) - send(components.ProgressUpdateMsg{Percent: OverallInstallPercent(itemIndex, len(fontsToInstall), installStepCompleted, 1)}) + send(components.ProgressUpdateMsg{Percent: OverallWorkPercent(itemIndex, len(fontsToInstall), ProgressUpdate{Phase: installStepCompleted})}) } return nil diff --git a/cmd/progress_steps.go b/cmd/progress_steps.go index 74dde2a..69ce932 100644 --- a/cmd/progress_steps.go +++ b/cmd/progress_steps.go @@ -1,11 +1,35 @@ package cmd -import "fontget/internal/shared" +import ( + "fmt" + "path/filepath" + "strings" -// StepProgressFunc reports progress for a named step. stepPct must be in [0,1]. -// Implementations should be lightweight and may be called frequently. -type StepProgressFunc func(step string, stepPct float64) + "fontget/internal/shared" +) + +// ProgressKind selects how Done/Total are interpreted for overall %. +type ProgressKind int + +const ( + ProgressBytes ProgressKind = iota // download + ProgressCount // extract / install / remove / backup file counts + ProgressFlag // precheck / finalize (0 → 1) +) +// ProgressUpdate is one pulse of work-unit progress. +type ProgressUpdate struct { + Phase string + Detail string + Kind ProgressKind + Done float64 + Total float64 // 0 = unknown for this pulse +} + +// ProgressFunc reports progress. Implementations must be lightweight. +type ProgressFunc func(ProgressUpdate) + +// Legacy phase name constants (UI labels + segment keys). const ( installStepPrecheck = "Checking installed" installStepDownload = "Downloading" @@ -13,120 +37,155 @@ const ( installStepInstall = "Installing" installStepFinalize = "Finalizing" installStepCompleted = "Installed" -) - -// Weights are tuned to keep progress moving during the longest phases without byte streaming. -// Download/install dominate. Finalize is small but non-zero so post-install cache flush isn’t a “jump”. -var installStepOrder = []string{ - installStepPrecheck, - installStepDownload, - installStepExtract, - installStepInstall, - installStepFinalize, -} - -var installStepWeights = []float64{ - 0.10, // precheck - 0.50, // download (byte-streaming when available) - 0.05, // extract (file/entry streaming when available) - 0.30, // install (file-by-file) - 0.05, // finalize (cache flush / cleanup) -} -const ( removeStepScan = "Scanning" removeStepRemove = "Removing" removeStepFinalize = "Finalizing" removeStepCompleted = "Removed" ) -var removeStepOrder = []string{ - removeStepScan, - removeStepRemove, - removeStepFinalize, -} - -var removeStepWeights = []float64{ - 0.55, // scan/find (can be expensive on some platforms) - 0.40, // remove files - 0.05, // cache flush / finalize -} +// Reserved bar segments for a single item (monotonic; install/remove owns most of the bar). +const ( + segPrecheckEnd = 0.02 + segDownloadEnd = 0.25 + segExtractEnd = 0.35 + segFilesEnd = 0.98 + segFinalizeEnd = 1.00 + segRemoveScanEnd = 0.35 // scan uses the whole prelude band +) -func removeStepIndex(step string) int { - for i, s := range removeStepOrder { - if s == step { - return i +// FormatProgressActivity builds the status line for progress UIs. +func FormatProgressActivity(phase, detail string) string { + phase = strings.TrimSpace(phase) + detail = strings.TrimSpace(detail) + if phase == "" { + if detail == "" { + return "" } + return detail + } + if detail == "" { + return phase + "..." } - return 0 + return phase + " " + detail } -func OverallRemovePercent(fontIndex int, totalFonts int, step string, stepPct float64) float64 { - if totalFonts <= 0 { - return 0 - } - if fontIndex < 0 { - fontIndex = 0 - } - if fontIndex >= totalFonts { - fontIndex = totalFonts - 1 - stepPct = 1 +// CountDetail formats "i/n name" for count-based phases. +func CountDetail(i, n int, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Sprintf("%d/%d", i, n) } + return fmt.Sprintf("%d/%d %s", i, n, filepath.Base(name)) +} - if step == removeStepCompleted { - return (float64(fontIndex+1) / float64(totalFonts)) * 100.0 +// phaseFrac returns progress within the active phase in [0,1]. +func phaseFrac(u ProgressUpdate) float64 { + switch u.Kind { + case ProgressFlag: + if u.Done >= 1 || (u.Total > 0 && u.Done >= u.Total) { + return 1 + } + return shared.Clamp01(u.Done) + case ProgressBytes, ProgressCount: + if u.Total <= 0 { + if u.Done >= 1 { + return 1 + } + return 0 + } + return shared.Clamp01(u.Done / u.Total) + default: + return 0 } +} - stepIdx := removeStepIndex(step) - perFont := shared.WeightedPhaseProgress(removeStepWeights, stepIdx, stepPct) - overall := (float64(fontIndex) + perFont) / float64(totalFonts) - if overall < 0 { - overall = 0 +// segmentRange returns [start,end) in 0..1 for a phase label. +func segmentRange(phase string) (start, end float64) { + switch phase { + case installStepPrecheck: + return 0, segPrecheckEnd + case installStepDownload: + return segPrecheckEnd, segDownloadEnd + case installStepExtract: + return segDownloadEnd, segExtractEnd + case installStepInstall, removeStepRemove: + return segExtractEnd, segFilesEnd + case installStepFinalize: + return segFilesEnd, segFinalizeEnd + case removeStepScan: + return 0, segRemoveScanEnd + case installStepCompleted, removeStepCompleted: + return 1, 1 + default: + return segExtractEnd, segFilesEnd } - if overall > 1 { - overall = 1 - } - return overall * 100.0 } -func installStepIndex(step string) int { - for i, s := range installStepOrder { - if s == step { - return i - } +// itemFrac maps a ProgressUpdate to 0..1 progress within one catalog item. +func itemFrac(u ProgressUpdate) float64 { + if u.Phase == installStepCompleted || u.Phase == removeStepCompleted { + return 1 } - // Unknown steps map to the current/first phase to avoid breaking callers. - return 0 + start, end := segmentRange(u.Phase) + if end <= start { + return 1 + } + f := phaseFrac(u) + return start + (end-start)*f } -// OverallInstallPercent maps per-font step progress to global 0..100 percent across N fonts. -// - fontIndex is 0-based, totalFonts must be > 0. -func OverallInstallPercent(fontIndex int, totalFonts int, step string, stepPct float64) float64 { - if totalFonts <= 0 { +// OverallWorkPercent maps work-unit progress to global 0..100 across itemCount items. +func OverallWorkPercent(itemIndex, itemCount int, u ProgressUpdate) float64 { + if itemCount <= 0 { return 0 } - if fontIndex < 0 { - fontIndex = 0 + if itemIndex < 0 { + itemIndex = 0 } - if fontIndex >= totalFonts { - fontIndex = totalFonts - 1 - stepPct = 1 + if itemIndex >= itemCount { + itemIndex = itemCount - 1 + return 100 } + frac := itemFrac(u) + overall := (float64(itemIndex) + frac) / float64(itemCount) + return shared.Clamp01(overall) * 100 +} - if step == installStepCompleted { - return (float64(fontIndex+1) / float64(totalFonts)) * 100.0 - } +// OverallInstallPercent maps a simple phase fraction for install (used by thin callers). +func OverallInstallPercent(itemIndex, itemCount int, phase string, phasePct float64) float64 { + return OverallWorkPercent(itemIndex, itemCount, ProgressUpdate{ + Phase: phase, + Kind: ProgressFlag, + Done: shared.Clamp01(phasePct), + Total: 1, + }) +} - stepIdx := installStepIndex(step) - perFont := shared.WeightedPhaseProgress(installStepWeights, stepIdx, stepPct) // 0..1 within this font +// OverallRemovePercent maps a simple phase fraction for remove (used by thin callers). +func OverallRemovePercent(itemIndex, itemCount int, phase string, phasePct float64) float64 { + return OverallWorkPercent(itemIndex, itemCount, ProgressUpdate{ + Phase: phase, + Kind: ProgressFlag, + Done: shared.Clamp01(phasePct), + Total: 1, + }) +} - overall := (float64(fontIndex) + perFont) / float64(totalFonts) // 0..1 overall - if overall < 0 { - overall = 0 - } - if overall > 1 { - overall = 1 - } - return overall * 100.0 +// progressThrottle reduces UI spam for byte pulses while always forwarding phase/detail changes. +type progressThrottle struct { + lastPhase string + lastDetail string + lastBucket int } +func (t *progressThrottle) ShouldSend(u ProgressUpdate, overallPct float64) bool { + bucket := int(overallPct) // 1% steps + if u.Phase != t.lastPhase || u.Detail != t.lastDetail || bucket != t.lastBucket { + t.lastPhase = u.Phase + t.lastDetail = u.Detail + t.lastBucket = bucket + return true + } + return false +} diff --git a/cmd/progress_steps_test.go b/cmd/progress_steps_test.go index 2c186ac..2dd06d0 100644 --- a/cmd/progress_steps_test.go +++ b/cmd/progress_steps_test.go @@ -2,6 +2,59 @@ package cmd import "testing" +func TestFormatProgressActivity(t *testing.T) { + if got := FormatProgressActivity("Installing", ""); got != "Installing..." { + t.Fatalf("got %q", got) + } + if got := FormatProgressActivity("Installing", "variants (12 of 399)"); got != "Installing variants (12 of 399)" { + t.Fatalf("got %q", got) + } +} + +func TestCountDetail(t *testing.T) { + if got := CountDetail(2, 10, `/tmp/dir/Foo.ttf`); got != "2/10 Foo.ttf" { + t.Fatalf("got %q", got) + } +} + +func TestOverallWorkPercent_DownloadThenInstall(t *testing.T) { + midDL := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepDownload, Kind: ProgressBytes, Done: 50, Total: 100, + }) + if midDL < 2 || midDL > 25 { + t.Fatalf("mid download %% = %v, want in (2,25)", midDL) + } + + startInst := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepInstall, Kind: ProgressCount, Done: 0, Total: 400, + }) + if startInst < 34 || startInst > 36 { + t.Fatalf("install start %% = %v, want ~35", startInst) + } + + midInst := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepInstall, Kind: ProgressCount, Done: 200, Total: 400, + }) + wantMid := 35 + (98-35)*0.5 + if midInst < wantMid-2 || midInst > wantMid+2 { + t.Fatalf("install mid %% = %v, want ~%v", midInst, wantMid) + } + + done := OverallWorkPercent(0, 1, ProgressUpdate{Phase: installStepCompleted}) + if done != 100 { + t.Fatalf("completed %% = %v want 100", done) + } +} + +func TestOverallWorkPercent_MultiItem(t *testing.T) { + got := OverallWorkPercent(1, 2, ProgressUpdate{ + Phase: installStepInstall, Kind: ProgressCount, Done: 0, Total: 10, + }) + if got < 65 || got > 70 { + t.Fatalf("multi-item %% = %v, want ~67.5", got) + } +} + func TestOverallInstallPercent_Bounds(t *testing.T) { if got := OverallInstallPercent(0, 0, installStepDownload, 0.5); got != 0 { t.Fatalf("got %v want 0", got) @@ -23,3 +76,20 @@ func TestOverallRemovePercent_Bounds(t *testing.T) { } } +func TestProgressThrottle(t *testing.T) { + var th progressThrottle + u := ProgressUpdate{Phase: installStepDownload, Detail: "", Kind: ProgressBytes, Done: 1, Total: 100} + if !th.ShouldSend(u, 3) { + t.Fatal("first send") + } + if th.ShouldSend(u, 3.4) { + t.Fatal("same 1% bucket should skip") + } + if !th.ShouldSend(u, 4) { + t.Fatal("next percent should send") + } + u.Detail = "1/2 a.ttf" + if !th.ShouldSend(u, 4) { + t.Fatal("detail change should send") + } +} diff --git a/cmd/remove.go b/cmd/remove.go index a24b52d..1e1ad5a 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -397,17 +397,22 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, // RemoveFontFilesParams contains parameters for removeFontFiles function type RemoveFontFilesParams struct { + Ctx context.Context MatchingFonts []string FontManager platform.FontManager Scope platform.InstallationScope FontDir string IsCriticalSystemFont func(string) bool - OnProgress StepProgressFunc + OnProgress ProgressFunc } // removeFontFiles removes font files from system func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int, details []string, errors []string) { - unlock, lockErr := installations.LockDestination(context.Background(), params.FontDir) + ctx := params.Ctx + if ctx == nil { + ctx = context.Background() + } + unlock, lockErr := installations.LockDestination(ctx, params.FontDir) if lockErr != nil { return 0, 0, len(params.MatchingFonts), nil, []string{lockErr.Error()} } @@ -417,8 +422,17 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int total := len(params.MatchingFonts) for i, matchingFont := range params.MatchingFonts { + if err := ctx.Err(); err != nil { + errors = append(errors, err.Error()) + break + } if params.OnProgress != nil && total > 0 { - params.OnProgress(removeStepRemove, float64(i)/float64(total)) + params.OnProgress(ProgressUpdate{ + Phase: removeStepRemove, + Kind: ProgressCount, + Done: float64(i), + Total: float64(total), + }) } // Construct full font path for metadata extraction fontPath := filepath.Join(params.FontDir, matchingFont) @@ -460,13 +474,18 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int } if params.OnProgress != nil { - params.OnProgress(removeStepRemove, 1) + params.OnProgress(ProgressUpdate{ + Phase: removeStepRemove, + Kind: ProgressCount, + Done: float64(total), + Total: float64(max(total, 1)), + }) } // Single cache refresh / font-change notification after all removals (avoids pkill fontd / fc-cache / WM_FONTCHANGE per file) - if removed > 0 { + if removed > 0 && ctx.Err() == nil { if params.OnProgress != nil { - params.OnProgress(removeStepFinalize, 0) + params.OnProgress(ProgressUpdate{Phase: removeStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) } if flushErr := params.FontManager.FlushFontCache(params.Scope); flushErr != nil { errStr := strings.ToLower(flushErr.Error()) @@ -479,7 +498,7 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int } } if params.OnProgress != nil { - params.OnProgress(removeStepFinalize, 1) + params.OnProgress(ProgressUpdate{Phase: removeStepFinalize, Kind: ProgressFlag, Done: 1, Total: 1}) } } @@ -528,6 +547,7 @@ func buildRemoveResult(removed, skipped, failed int, details []string, errors [] // - RemoveResult: Contains removed/skipped/failed counts and details // - error: Removal error if the operation fails func removeFont( + ctx context.Context, fontName string, fontManager platform.FontManager, scope platform.InstallationScope, @@ -535,11 +555,17 @@ func removeFont( repository *repo.Repository, installReg *installations.Registry, manifestProbe installations.ManifestFontIDProbe, - onProgress StepProgressFunc, + onProgress ProgressFunc, ) (*RemoveResult, error) { + if ctx == nil { + ctx = context.Background() + } // Find font files for removal if onProgress != nil { - onProgress(removeStepScan, 0) + onProgress(ProgressUpdate{Phase: removeStepScan, Kind: ProgressFlag, Done: 0, Total: 1}) + } + if err := ctx.Err(); err != nil { + return nil, err } matchingFonts, usedRegistry, err := findFontFilesForRemoval(fontName, fontManager, scope, repository, installReg, manifestProbe) if err != nil { @@ -550,11 +576,12 @@ func removeFont( return result, err } if onProgress != nil { - onProgress(removeStepScan, 1) + onProgress(ProgressUpdate{Phase: removeStepScan, Kind: ProgressFlag, Done: 1, Total: 1}) } // Remove font files removed, skipped, failed, details, errors := removeFontFiles(RemoveFontFilesParams{ + Ctx: ctx, MatchingFonts: matchingFonts, FontManager: fontManager, Scope: scope, @@ -564,6 +591,9 @@ func removeFont( }) res := buildRemoveResult(removed, skipped, failed, details, errors) + if err := ctx.Err(); err != nil { + return res, err + } if usedRegistry && failed == 0 && removed == len(matchingFonts) && removed > 0 { if rmErr := installations.RemoveInstallation(fontName); rmErr != nil { output.GetDebug().Error("remove installation registry entry: %v", rmErr) @@ -1693,6 +1723,7 @@ Use --scope to set removal location: output.GetDebug().State("Calling removeFont(%s, %s, %s)", fontInfo.SearchName, scopeLabelName, fontDir) result, err := removeFont( + context.Background(), fontInfo.SearchName, fontManager, scopeType, @@ -1762,10 +1793,23 @@ Use --scope to set removal location: verbose, // Verbose mode: show operational details and file/variant listings debug, // Debug mode: show technical details func(send func(msg tea.Msg), cancelChan <-chan struct{}) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-cancelChan: + cancel() + case <-ctx.Done(): + } + }() + // Process items based on scope mode if len(scopes) > 1 { // "all" scope - process each font+scope combination individually for _, item := range fontScopeItems { + if err := ctx.Err(); err != nil { + return shared.ErrOperationCancelled + } // Send initial "in_progress" message send(components.ItemUpdateMsg{ Index: item.ItemIndex, @@ -1777,27 +1821,22 @@ Use --scope to set removal location: GetLogger().Info("Processing font: %s in %s scope", item.FontName, item.ScopeLabel) fontDir := fontManager.GetFontDir(item.ScopeType) - lastStep := "" - lastBucket := -1 - onProgress := func(step string, stepPct float64) { - bucket := int(shared.Clamp01(stepPct) * 20.0) - if step == lastStep && bucket == lastBucket { + var th progressThrottle + onProgress := func(u ProgressUpdate) { + pct := OverallWorkPercent(item.ItemIndex, len(operationItems), u) + if !th.ShouldSend(u, pct) { return } - lastStep = step - lastBucket = bucket - send(components.ItemUpdateMsg{ Index: item.ItemIndex, Name: item.ProperName, Status: "in_progress", - Message: step + "...", - }) - send(components.ProgressUpdateMsg{ - Percent: OverallRemovePercent(item.ItemIndex, len(operationItems), step, stepPct), + Message: FormatProgressActivity(u.Phase, u.Detail), }) + send(components.ProgressUpdateMsg{Percent: pct}) } result, err := removeFont( + ctx, item.FontName, fontManager, item.ScopeType, @@ -1807,6 +1846,9 @@ Use --scope to set removal location: manifestProbe, onProgress, ) + if errors.Is(err, context.Canceled) || errors.Is(err, shared.ErrOperationCancelled) { + return shared.ErrOperationCancelled + } // Collect variants for display (only in verbose mode) scopeVariants := []string{} @@ -1865,11 +1907,14 @@ Use --scope to set removal location: }) // Update progress percentage - send(components.ProgressUpdateMsg{Percent: OverallRemovePercent(item.ItemIndex, len(operationItems), removeStepCompleted, 1)}) + send(components.ProgressUpdateMsg{Percent: OverallWorkPercent(item.ItemIndex, len(operationItems), ProgressUpdate{Phase: removeStepCompleted})}) } } else { // Single scope - process each found font for i, fontInfo := range foundFonts { + if err := ctx.Err(); err != nil { + return shared.ErrOperationCancelled + } // Use proper font name from pre-extracted map properFontName := fontInfo.ProperName fontName := fontInfo.SearchName @@ -2005,16 +2050,17 @@ Use --scope to set removal location: // Process removal from user scope fontDir := fontManager.GetFontDir(platform.UserScope) - onProgress := func(step string, stepPct float64) { + onProgress := func(u ProgressUpdate) { // In this special mode we already drive percent via scan math; only update the step label. send(components.ItemUpdateMsg{ Index: i, Name: properFontName, Status: "in_progress", - Message: step + "...", + Message: FormatProgressActivity(u.Phase, u.Detail), }) } result, err := removeFont( + ctx, fontName, fontManager, platform.UserScope, @@ -2026,6 +2072,9 @@ Use --scope to set removal location: ) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, shared.ErrOperationCancelled) { + return shared.ErrOperationCancelled + } if strings.Contains(err.Error(), "not found") { // Font not found - mark as failed and continue fontStatus = StatusFailed @@ -2117,26 +2166,22 @@ Use --scope to set removal location: fontDir := fontManager.GetFontDir(scopeType) // Remove the font using the removeFont helper (it will handle Font ID resolution internally) - lastStep := "" - lastBucket := -1 - onProgress := func(step string, stepPct float64) { - bucket := int(shared.Clamp01(stepPct) * 20.0) - if step == lastStep && bucket == lastBucket { + var th progressThrottle + onProgress := func(u ProgressUpdate) { + pct := OverallWorkPercent(i, len(foundFonts), u) + if !th.ShouldSend(u, pct) { return } - lastStep = step - lastBucket = bucket send(components.ItemUpdateMsg{ Index: i, Name: properFontName, Status: "in_progress", - Message: step + "...", - }) - send(components.ProgressUpdateMsg{ - Percent: OverallRemovePercent(i, len(foundFonts), step, stepPct), + Message: FormatProgressActivity(u.Phase, u.Detail), }) + send(components.ProgressUpdateMsg{Percent: pct}) } result, err := removeFont( + ctx, fontName, fontManager, scopeType, @@ -2148,6 +2193,9 @@ Use --scope to set removal location: ) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, shared.ErrOperationCancelled) { + return shared.ErrOperationCancelled + } if strings.Contains(err.Error(), "not found") { fontStatus = StatusFailed statusMessage = "Font not found" diff --git a/internal/components/progress_bar.go b/internal/components/progress_bar.go index 7dd96f4..dc16b73 100644 --- a/internal/components/progress_bar.go +++ b/internal/components/progress_bar.go @@ -164,13 +164,13 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle cancellation keys (q, esc, ctrl+c, enter, space) - like sources update switch key { case "q", "ctrl+c", "esc", "enter", " ": - if m.quitting { - // Already cancelling or finished: never Quit without joining the worker. - return m, waitForOperationQuit(m.opDone) + if m.cancelled { + return m, tea.Quit } m.quitting = true m.cancelled = true m.err = shared.ErrOperationCancelled + m.Title = "Cancelling..." if m.cancelChan != nil { select { case <-m.cancelChan: @@ -178,7 +178,8 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { close(m.cancelChan) } } - return m, waitForOperationQuit(m.opDone) + // Quit TUI immediately; RunProgressBar joins the worker after p.Run(). + return m, tea.Quit } // If operation is complete, any other key still joins then quits. if m.quitting { diff --git a/internal/shared/progress.go b/internal/shared/progress.go index ee61af5..d06ffd6 100644 --- a/internal/shared/progress.go +++ b/internal/shared/progress.go @@ -10,55 +10,3 @@ func Clamp01(v float64) float64 { } return v } - -// WeightedPhaseProgress returns an overall progress in [0,1] given: -// - phaseWeights: relative weights for each phase (need not sum to 1) -// - phaseIndex: which phase is currently active (0-based) -// - phaseProgress: progress within the active phase in [0,1] -// -// If weights are empty or all zero, it falls back to equal weights. -func WeightedPhaseProgress(phaseWeights []float64, phaseIndex int, phaseProgress float64) float64 { - n := len(phaseWeights) - if n == 0 { - return Clamp01(phaseProgress) - } - if phaseIndex < 0 { - phaseIndex = 0 - } - if phaseIndex >= n { - phaseIndex = n - 1 - phaseProgress = 1 - } - - // Sum weights, falling back to equal weights if needed. - sum := 0.0 - for _, w := range phaseWeights { - if w > 0 { - sum += w - } - } - useEqual := sum <= 0 - if useEqual { - sum = float64(n) - } - - done := 0.0 - for i := 0; i < phaseIndex; i++ { - if useEqual { - done += 1 - } else if phaseWeights[i] > 0 { - done += phaseWeights[i] - } - } - - curW := 1.0 - if !useEqual { - if phaseWeights[phaseIndex] > 0 { - curW = phaseWeights[phaseIndex] - } - } - - p := (done + Clamp01(phaseProgress)*curW) / sum - return Clamp01(p) -} - diff --git a/internal/shared/progress_test.go b/internal/shared/progress_test.go index 7caff8d..3b8e1c0 100644 --- a/internal/shared/progress_test.go +++ b/internal/shared/progress_test.go @@ -13,28 +13,3 @@ func TestClamp01(t *testing.T) { t.Fatalf("Clamp01(2)=%v want 1", got) } } - -func TestWeightedPhaseProgress_EmptyWeights(t *testing.T) { - if got := WeightedPhaseProgress(nil, 0, 0.3); got != 0.3 { - t.Fatalf("got %v want 0.3", got) - } -} - -func TestWeightedPhaseProgress_EqualWeightsFallback(t *testing.T) { - got := WeightedPhaseProgress([]float64{0, 0, 0}, 1, 0.5) - // With 3 equal phases, phase 1 halfway: (1 + 0.5)/3 = 0.5 - if got != 0.5 { - t.Fatalf("got %v want 0.5", got) - } -} - -func TestWeightedPhaseProgress_ClampsAndBounds(t *testing.T) { - weights := []float64{1, 1} - if got := WeightedPhaseProgress(weights, -2, -1); got != 0 { - t.Fatalf("got %v want 0", got) - } - if got := WeightedPhaseProgress(weights, 99, 0.2); got != 1 { - t.Fatalf("got %v want 1", got) - } -} - From c68be9033bef9e1c81bce2ae507356ad53900835 Mon Sep 17 00:00:00 2001 From: graphixa Date: Mon, 14 Sep 2026 01:09:59 +1000 Subject: [PATCH 08/23] Simplify font removal and installation logic to support force replacement without overcomplicated backup first --- cmd/add_reliability_test.go | 9 ++- cmd/remove.go | 92 +++++++++++++++++------- cmd/remove_stale_test.go | 72 +++++++++++++++++++ internal/platform/darwin.go | 4 ++ internal/platform/linux.go | 4 ++ internal/platform/mutation.go | 42 ++++------- internal/platform/mutation_test.go | 111 ++++++++--------------------- internal/platform/platform.go | 5 +- internal/platform/windows.go | 32 ++++++--- 9 files changed, 223 insertions(+), 148 deletions(-) create mode 100644 cmd/remove_stale_test.go diff --git a/cmd/add_reliability_test.go b/cmd/add_reliability_test.go index 7127da4..b69265f 100644 --- a/cmd/add_reliability_test.go +++ b/cmd/add_reliability_test.go @@ -233,7 +233,7 @@ func TestInstallRollbackAfterFirstMutation(t *testing.T) { } } -func TestInstallRollbackRestoresReplacedBytes(t *testing.T) { +func TestInstallForceReplaceNoBackup(t *testing.T) { fontDir := t.TempDir() old := testutil.MinimalTTF("OldFam", "Regular") neu := testutil.MinimalTTF("NewFam", "Regular") @@ -249,6 +249,9 @@ func TestInstallRollbackRestoresReplacedBytes(t *testing.T) { if err != nil { t.Fatal(err) } + if mut.BackupPath != "" { + t.Fatal("force replace must not create a backup") + } got, _ := os.ReadFile(dst) if !bytes.Equal(got, neu) { t.Fatal("replace did not write new bytes") @@ -257,8 +260,8 @@ func TestInstallRollbackRestoresReplacedBytes(t *testing.T) { t.Fatal(err) } got, _ = os.ReadFile(dst) - if !bytes.Equal(got, old) { - t.Fatal("rollback did not restore old bytes") + if !bytes.Equal(got, neu) { + t.Fatal("without backup, rollback leaves replaced bytes") } } diff --git a/cmd/remove.go b/cmd/remove.go index 1e1ad5a..24218e8 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -418,9 +418,18 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int } defer unlock() - batchOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} + unregisterOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true, UnregisterOnly: true} + deleteOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} total := len(params.MatchingFonts) + type pending struct { + base string + display string + } + var toDelete []pending + + // Pass 1: unregister (Windows RemoveFontResource). Defer deletes until after one FlushFontCache + // so the session releases file locks — deleting immediately after UnregisterOnly=false used to fail "in use". for i, matchingFont := range params.MatchingFonts { if err := ctx.Err(); err != nil { errors = append(errors, err.Error()) @@ -430,47 +439,82 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int params.OnProgress(ProgressUpdate{ Phase: removeStepRemove, Kind: ProgressCount, - Done: float64(i), + Done: float64(i) * 0.5, Total: float64(total), }) } - // Construct full font path for metadata extraction fontPath := filepath.Join(params.FontDir, matchingFont) + display := shared.GetDisplayNameFromFilename(matchingFont) - // Check for protected system fonts - ALWAYS enforced - // Critical system fonts should never be removable for system stability if params.IsCriticalSystemFont != nil && params.IsCriticalSystemFont(matchingFont) { skipped++ - fontDisplayName := extractFontDisplayNameFromPath(fontPath) - details = append(details, fontDisplayName+" (Skipped - Protected system font)") + details = append(details, display+" (Skipped - Protected system font)") + continue + } + if _, statErr := os.Stat(fontPath); os.IsNotExist(statErr) { + removed++ + details = append(details, display+" (already gone)") + output.GetDebug().State("Font file already absent, counting as removed: %s", matchingFont) continue } - fontDisplayName := extractFontDisplayNameFromPath(fontPath) + output.GetDebug().State("Unregistering font: %s (%s)", matchingFont, params.Scope) + if err := params.FontManager.RemoveFont(matchingFont, params.Scope, unregisterOpts); err != nil { + if strings.Contains(err.Error(), "font not found") { + removed++ + details = append(details, display+" (already gone)") + continue + } + failed++ + errors = append(errors, "Failed to unregister font") + details = append(details, display+" (Failed)") + output.GetDebug().Error("unregister RemoveFont failed for %s: %v", matchingFont, err) + continue + } + toDelete = append(toDelete, pending{base: matchingFont, display: display}) + } - // Remove font - output.GetDebug().State("Calling fontManager.RemoveFont(%s, %s)", matchingFont, params.Scope) - err := params.FontManager.RemoveFont(matchingFont, params.Scope, batchOpts) + if len(toDelete) > 0 && ctx.Err() == nil { + if flushErr := params.FontManager.FlushFontCache(params.Scope); flushErr != nil { + output.GetDebug().Warning("Font cache flush before delete: %v", flushErr) + } + } - if err != nil { - // Actual removal failure (cache flush is handled once after the batch) + // Pass 2: delete files (RemoveFont does delete + optional notify; we already flushed). + for j, p := range toDelete { + if err := ctx.Err(); err != nil { + errors = append(errors, err.Error()) + break + } + if params.OnProgress != nil && total > 0 { + params.OnProgress(ProgressUpdate{ + Phase: removeStepRemove, + Kind: ProgressCount, + Done: float64(total)/2 + float64(j+1)*0.5, + Total: float64(total), + }) + } + output.GetDebug().State("Deleting font file: %s (%s)", p.base, params.Scope) + if err := params.FontManager.RemoveFont(p.base, params.Scope, deleteOpts); err != nil { + if strings.Contains(err.Error(), "font not found") || strings.Contains(err.Error(), "cannot find the file") { + removed++ + details = append(details, p.display+" (already gone)") + continue + } failed++ - var errorMsg string errStr := err.Error() - if containsAny(errStr, []string{"in use", "access denied", "permission"}) { - errorMsg = "Font is in use or access denied" + if containsAny(errStr, []string{"in use", "access denied", "permission", "used by another"}) { + errors = append(errors, "Font is in use or access denied") } else { - errorMsg = "Failed to remove existing font" + errors = append(errors, "Failed to remove existing font") } - errors = append(errors, errorMsg) - details = append(details, fontDisplayName+" (Failed)") - output.GetDebug().Error("fontManager.RemoveFont() failed for %s: %v", matchingFont, err) + details = append(details, p.display+" (Failed)") + output.GetDebug().Error("delete RemoveFont failed for %s: %v", p.base, err) continue } - - output.GetDebug().State("Successfully removed font: %s", fontDisplayName) removed++ - details = append(details, fontDisplayName) + details = append(details, p.display) + output.GetDebug().State("Successfully removed font: %s", p.display) } if params.OnProgress != nil { @@ -482,7 +526,6 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int }) } - // Single cache refresh / font-change notification after all removals (avoids pkill fontd / fc-cache / WM_FONTCHANGE per file) if removed > 0 && ctx.Err() == nil { if params.OnProgress != nil { params.OnProgress(ProgressUpdate{Phase: removeStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) @@ -594,6 +637,7 @@ func removeFont( if err := ctx.Err(); err != nil { return res, err } + // Clear install record when every listed file is gone (removed or already absent) and none failed. if usedRegistry && failed == 0 && removed == len(matchingFonts) && removed > 0 { if rmErr := installations.RemoveInstallation(fontName); rmErr != nil { output.GetDebug().Error("remove installation registry entry: %v", rmErr) diff --git a/cmd/remove_stale_test.go b/cmd/remove_stale_test.go new file mode 100644 index 0000000..e5715a5 --- /dev/null +++ b/cmd/remove_stale_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "testing" + + "fontget/internal/platform" +) + +type removeTrackingFM struct { + dir string + calls []string +} + +func (m *removeTrackingFM) InstallFont(string, platform.InstallationScope, bool, *platform.InstallFontOptions) error { + return nil +} +func (m *removeTrackingFM) RemoveFont(name string, _ platform.InstallationScope, _ *platform.RemoveFontOptions) error { + m.calls = append(m.calls, name) + return nil +} +func (m *removeTrackingFM) GetFontDir(platform.InstallationScope) string { return m.dir } +func (m *removeTrackingFM) RequiresElevation(platform.InstallationScope) bool { + return false +} +func (m *removeTrackingFM) IsElevated() (bool, error) { return true, nil } +func (m *removeTrackingFM) FlushFontCache(platform.InstallationScope) error { return nil } +func (m *removeTrackingFM) GetElevationCommand() (string, []string, error) { + return "", nil, nil +} + +func TestRemoveFontFiles_missingFilesCountAsRemoved(t *testing.T) { + dir := t.TempDir() + fm := &removeTrackingFM{dir: dir} + removed, skipped, failed, _, _ := removeFontFiles(RemoveFontFilesParams{ + Ctx: context.Background(), + MatchingFonts: []string{"Lekton.ttf", "Lekton-Bold.ttf"}, + FontManager: fm, + Scope: platform.UserScope, + FontDir: dir, + }) + if removed != 2 || skipped != 0 || failed != 0 { + t.Fatalf("removed=%d skipped=%d failed=%d", removed, skipped, failed) + } + if len(fm.calls) != 0 { + t.Fatalf("RemoveFont should not run for absent files, got %v", fm.calls) + } +} + +func TestRemoveFontFiles_presentStillCallsRemove(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Lekton.ttf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + fm := &removeTrackingFM{dir: dir} + removed, _, failed, _, _ := removeFontFiles(RemoveFontFilesParams{ + Ctx: context.Background(), + MatchingFonts: []string{"Lekton.ttf", "Gone.ttf"}, + FontManager: fm, + Scope: platform.UserScope, + FontDir: dir, + }) + if removed != 2 || failed != 0 { + t.Fatalf("removed=%d failed=%d", removed, failed) + } + // unregister + delete for present file only + if len(fm.calls) != 2 || fm.calls[0] != "Lekton.ttf" || fm.calls[1] != "Lekton.ttf" { + t.Fatalf("calls=%v", fm.calls) + } +} diff --git a/internal/platform/darwin.go b/internal/platform/darwin.go index f6b2025..d11680d 100644 --- a/internal/platform/darwin.go +++ b/internal/platform/darwin.go @@ -119,6 +119,10 @@ func (m *darwinFontManager) RemoveFont(fontName string, scope InstallationScope, fontPath := filepath.Join(targetDir, fontName) + if opts != nil && opts.UnregisterOnly { + return nil + } + // Delete the font file if err := os.Remove(fontPath); err != nil { return fmt.Errorf("failed to remove font file: %w", err) diff --git a/internal/platform/linux.go b/internal/platform/linux.go index 091e139..da0caef 100644 --- a/internal/platform/linux.go +++ b/internal/platform/linux.go @@ -117,6 +117,10 @@ func (m *linuxFontManager) RemoveFont(fontName string, scope InstallationScope, fontPath := filepath.Join(targetDir, fontName) + if opts != nil && opts.UnregisterOnly { + return nil + } + // Delete the font file if err := os.Remove(fontPath); err != nil { return fmt.Errorf("failed to remove font file: %w", err) diff --git a/internal/platform/mutation.go b/internal/platform/mutation.go index d23a5b9..8994948 100644 --- a/internal/platform/mutation.go +++ b/internal/platform/mutation.go @@ -48,8 +48,8 @@ func removeTracked(mut *FileMutation, path string) { mut.ArtifactPaths = out } -// placeFontFile copies src to dest, using unique same-volume staging and backups when replacing. -// New files are staged then renamed so a failed mid-copy cannot leave an untracked partial dest. +// placeFontFile copies src to dest. New installs stage then rename. Force replace +// overwrites in place (no backup) — fonts are re-downloadable. func placeFontFile(src, dest string, force bool, opts *InstallFontOptions) (FileMutation, error) { var mut FileMutation mut.DestPath = dest @@ -111,13 +111,10 @@ func createNewFontFile(src, dest string, mut *FileMutation, opts *InstallFontOpt } func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { - backup := uniqueArtifact(dest, "bak") - if err := copyFile(dest, backup); err != nil { - return fmt.Errorf("backup existing font: %w", err) - } - mut.BackupPath = backup mut.Replaced = true - trackArtifact(mut, backup) + if opts != nil && opts.FailPoint == InstallFailReplace { + return failPointError(InstallFailReplace) + } staged := uniqueArtifact(dest, "new") trackArtifact(mut, staged) @@ -129,30 +126,21 @@ func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallF return failPointError(InstallFailCopyAfterWrite) } - if opts != nil && opts.FailPoint == InstallFailReplace { + if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { removeTracked(mut, staged) - return failPointError(InstallFailReplace) - } - - oldMoved := uniqueArtifact(dest, "old") - trackArtifact(mut, oldMoved) - if err := os.Rename(dest, oldMoved); err != nil { - removeTracked(mut, staged) - removeTracked(mut, oldMoved) - return fmt.Errorf("cannot replace locked or busy font: %w", err) + return fmt.Errorf("remove existing font: %w", err) } if err := os.Rename(staged, dest); err != nil { - _ = os.Rename(oldMoved, dest) - removeTracked(mut, staged) - removeTracked(mut, oldMoved) - return fmt.Errorf("install replacement: %w", err) + if copyErr := copyFile(staged, dest); copyErr != nil { + removeTracked(mut, staged) + return fmt.Errorf("install replacement: %w", copyErr) + } } removeTracked(mut, staged) - removeTracked(mut, oldMoved) return nil } -// CommitMutation deletes disposable backups/artifacts after a successful package commit. +// CommitMutation deletes disposable staging artifacts after a successful package commit. func CommitMutation(mut FileMutation) error { var first error paths := append([]string{}, mut.ArtifactPaths...) @@ -175,8 +163,8 @@ func CommitMutation(mut FileMutation) error { return first } -// RollbackMutation undoes file and platform registration changes for one mutation. -// Pre-existing fonts are restored from backup; files created by this operation are removed. +// RollbackMutation undoes registration and removes files this operation created. +// Force-replaced fonts are not restored from backup (fonts are re-downloadable). func RollbackMutation(mut FileMutation) error { if mut.UndoRegistration != nil { if err := mut.UndoRegistration(); err != nil { @@ -188,6 +176,7 @@ func RollbackMutation(mut FileMutation) error { var fileErr error if mut.Replaced && mut.BackupPath != "" { + // Legacy mutations that still carry a backup (e.g. recovery records). if err := copyFile(mut.BackupPath, mut.DestPath); err != nil { fileErr = fmt.Errorf("restore backup %s: %w", mut.BackupPath, err) } @@ -205,7 +194,6 @@ func RollbackMutation(mut FileMutation) error { fileErr = restoreErr } - // Do not delete BackupPath here: incomplete recovery must retain it. CommitMutation cleans success. for _, p := range mut.ArtifactPaths { if p == "" || p == mut.BackupPath { continue diff --git a/internal/platform/mutation_test.go b/internal/platform/mutation_test.go index 600249c..3980d99 100644 --- a/internal/platform/mutation_test.go +++ b/internal/platform/mutation_test.go @@ -3,7 +3,6 @@ package platform import ( "os" "path/filepath" - "strings" "testing" ) @@ -27,7 +26,7 @@ func TestPlaceFontFileCreates(t *testing.T) { } } -func TestPlaceFontFileForceRestoresOnReplaceFail(t *testing.T) { +func TestPlaceFontFileForceReplaceLeavesOldOnInjectedFail(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src.ttf") dst := filepath.Join(dir, "dst.ttf") @@ -46,15 +45,35 @@ func TestPlaceFontFileForceRestoresOnReplaceFail(t *testing.T) { if string(got) != "old-bytes-here" { t.Fatalf("original bytes lost: %q", got) } - if mut.BackupPath == "" { - t.Fatal("backup must exist so recovery remains possible") + if mut.BackupPath != "" { + t.Fatal("force replace must not create backups") } - bak, _ := os.ReadFile(mut.BackupPath) - if string(bak) != "old-bytes-here" { - t.Fatalf("backup = %q", bak) +} + +func TestPlaceFontFileForceOverwrites(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.ttf") + dst := filepath.Join(dir, "dst.ttf") + _ = os.WriteFile(src, []byte("new-bytes-here"), 0644) + _ = os.WriteFile(dst, []byte("old-bytes-here"), 0644) + mut, err := placeFontFile(src, dst, true, nil) + if err != nil { + t.Fatal(err) + } + if !mut.Replaced || mut.BackupPath != "" { + t.Fatalf("mutation = %+v", mut) } - if !strings.Contains(filepath.Base(mut.BackupPath), ".fontget-bak-") { - t.Fatalf("backup should use unique suffix, got %s", mut.BackupPath) + got, _ := os.ReadFile(dst) + if string(got) != "new-bytes-here" { + t.Fatalf("dest = %q", got) + } + if err := RollbackMutation(mut); err != nil { + t.Fatal(err) + } + // No backup: contents stay; registration undo is a no-op without hooks. + got, _ = os.ReadFile(dst) + if string(got) != "new-bytes-here" { + t.Fatalf("force replace leaves new bytes: %q", got) } } @@ -81,38 +100,6 @@ func TestPlaceFontFileCopyAfterWriteTracksPartial(t *testing.T) { } } -func TestUniqueBackupSurvivesSecondAttempt(t *testing.T) { - dir := t.TempDir() - src := filepath.Join(dir, "src.ttf") - dst := filepath.Join(dir, "dst.ttf") - _ = os.WriteFile(src, []byte("new-bytes-here"), 0644) - _ = os.WriteFile(dst, []byte("old-bytes-here"), 0644) - - var mut1 FileMutation - _, err := placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut1, FailPoint: InstallFailReplace}) - if err == nil { - t.Fatal("expected first failure") - } - firstBak := mut1.BackupPath - firstBytes, _ := os.ReadFile(firstBak) - - var mut2 FileMutation - _, err = placeFontFile(src, dst, true, &InstallFontOptions{Mutation: &mut2, FailPoint: InstallFailReplace}) - if err == nil { - t.Fatal("expected second failure") - } - if mut2.BackupPath == firstBak { - t.Fatal("second attempt must not reuse first backup path") - } - got, err := os.ReadFile(firstBak) - if err != nil { - t.Fatalf("first recovery backup destroyed: %v", err) - } - if string(got) != string(firstBytes) { - t.Fatal("first backup contents changed") - } -} - func TestCheckDestinationCollisions(t *testing.T) { if err := CheckDestinationCollisions([]string{`C:\a\Foo.ttf`, `C:\b\Foo.ttf`}); err == nil { t.Fatal("expected collision") @@ -122,48 +109,6 @@ func TestCheckDestinationCollisions(t *testing.T) { } } -func TestRollbackAndCommitMutation(t *testing.T) { - dir := t.TempDir() - src := filepath.Join(dir, "src.ttf") - dst := filepath.Join(dir, "dst.ttf") - if err := os.WriteFile(src, []byte("new-bytes-here"), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dst, []byte("old-bytes-here"), 0644); err != nil { - t.Fatal(err) - } - mut, err := placeFontFile(src, dst, true, nil) - if err != nil { - t.Fatal(err) - } - got, _ := os.ReadFile(dst) - if string(got) != "new-bytes-here" { - t.Fatalf("replaced = %q", got) - } - if err := RollbackMutation(mut); err != nil { - t.Fatal(err) - } - got, _ = os.ReadFile(dst) - if string(got) != "old-bytes-here" { - t.Fatalf("restored = %q", got) - } - if err := os.WriteFile(dst, []byte("new-bytes-here"), 0644); err != nil { - t.Fatal(err) - } - mut2, err := placeFontFile(src, dst, true, nil) - if err != nil { - t.Fatal(err) - } - if err := CommitMutation(mut2); err != nil { - t.Fatal(err) - } - if mut2.BackupPath != "" { - if _, err := os.Stat(mut2.BackupPath); !os.IsNotExist(err) { - t.Fatalf("backup should be removed after commit: %v", err) - } - } -} - func TestPlaceFontFileSkipWithoutForce(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src.ttf") diff --git a/internal/platform/platform.go b/internal/platform/platform.go index fdd18e7..b2360ac 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -54,7 +54,7 @@ type FileMutation struct { // RegistryAdded is true after a successful machine-scope registry write for FontName. RegistryAdded bool - // ArtifactPaths are unique disposable files (backups/stages) owned by this mutation. + // ArtifactPaths are unique disposable staging files owned by this mutation. ArtifactPaths []string // UndoRegistration, when set, undoes registrations for this mutation (tests and platforms). @@ -80,6 +80,9 @@ type RemoveFontOptions struct { // SkipPostRemoveCacheRefresh skips the per-remove OS font cache update / Windows WM_FONTCHANGE notification. // Use with FlushFontCache(scope) once after removing multiple files in one batch. SkipPostRemoveCacheRefresh bool + // UnregisterOnly removes registration (e.g. RemoveFontResource) but does not delete the file. + // Batch remove: unregister all → FlushFontCache → delete, so Windows releases locks first. + UnregisterOnly bool } // FontManager defines the interface for platform-specific font operations diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 1d9332e..3d1fe77 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "syscall" + "time" "unsafe" "fontget/internal/logging" @@ -269,23 +270,34 @@ func (m *windowsFontManager) RemoveFont(fontName string, scope InstallationScope } } - // Delete the font file + if opts != nil && opts.UnregisterOnly { + return nil + } + + // Delete the font file. Prefer a font-change broadcast first so the session releases locks. + _ = NotifyFontChange() logger.Debug("Removing font file...") - if err := os.Remove(fontPath); err != nil { - logger.Error("Failed to remove font file at path %s: %v", fontPath, err) - // Try to restore the font resource if file deletion fails - if restoreErr := AddFontResource(fontPath); restoreErr != nil { - logger.Error("Failed to restore font resource after file deletion failure: %v", restoreErr) + var removeErr error + for attempt := 0; attempt < 4; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt*40) * time.Millisecond) + _ = NotifyFontChange() } - return fmt.Errorf("failed to remove font file: %w", err) + removeErr = os.Remove(fontPath) + if removeErr == nil || os.IsNotExist(removeErr) { + removeErr = nil + break + } + } + if removeErr != nil { + logger.Error("Failed to remove font file at path %s: %v", fontPath, removeErr) + // ponytail: do not AddFontResource after a failed delete — that re-locks the file. + return fmt.Errorf("failed to remove font file: %w", removeErr) } logger.Debug("Font file removed successfully") skipNotify := opts != nil && opts.SkipPostRemoveCacheRefresh if !skipNotify { - // Notify other applications about the font removal - // Only send WM_FONTCHANGE to the desktop window to avoid hangs from full window enumeration. - // Enumerating all windows can hang or be extremely slow on some systems. logger.Debug("Notifying system about font change...") if err := NotifyFontChange(); err != nil { logger.Error("Failed to notify system about font change: %v", err) From d1670f3c616313f2f9ef018f77c9dfd8ac2059cc Mon Sep 17 00:00:00 2001 From: graphixa Date: Mon, 14 Sep 2026 23:07:33 +1000 Subject: [PATCH 09/23] Update installation documentation to reference the new build guide and correct link paths --- docs/README.md | 2 +- docs/development/{BUILD.md => build-guide.md} | 0 docs/installation.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/development/{BUILD.md => build-guide.md} (100%) diff --git a/docs/README.md b/docs/README.md index a3925b4..7edeb4b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ | Doc | Description | |-----|----------------| | [Contributing](contributing.md) | How to contribute, build from source, run tests | -| [Build](development/BUILD.md) | Build and release steps | +| [Build](development/build-guide.md) | Build and release steps | | [Codebase](development/codebase.md) | High-level layout and where things live | | [Style guide](development/style-guide.md) | Theming, UI styles, theme config | | [Theming](development/theming.md) | Theme files, structure, and configuration | diff --git a/docs/development/BUILD.md b/docs/development/build-guide.md similarity index 100% rename from docs/development/BUILD.md rename to docs/development/build-guide.md diff --git a/docs/installation.md b/docs/installation.md index 22076c1..d3916aa 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -123,7 +123,7 @@ The AUR package builds FontGet from source. You need an [AUR helper](https://wik ## Build and Install from Source -For instructions on building FontGet from source, see the [Build Guide](development/BUILD.md). +For instructions on building FontGet from source, see the [Build Guide](development/build-guide.md). ### Prerequisites From 1c50fb2e6cc00e700cb7cf2628c62907c36d5fa9 Mon Sep 17 00:00:00 2001 From: graphixa Date: Mon, 14 Sep 2026 23:10:44 +1000 Subject: [PATCH 10/23] Refactor font installation and removal processes to improve cancellation handling and track incomplete operations --- cmd/add.go | 425 ++++++++++------------ cmd/add_reliability_test.go | 77 +--- cmd/browse_model.go | 15 +- cmd/cancel_contract_test.go | 290 +++++++++++++++ cmd/cancel_messages.go | 151 ++++++++ cmd/import.go | 43 ++- cmd/install_tracking.go | 234 ++++++++++++ cmd/registry_failure_test.go | 27 -- cmd/remove.go | 199 +++++----- cmd/remove_stale_test.go | 8 +- docs/development/build-guide.md | 2 +- internal/installations/incomplete_test.go | 50 +++ internal/installations/registry.go | 77 +++- internal/platform/darwin.go | 3 +- internal/platform/linux.go | 3 +- internal/platform/windows.go | 6 +- 16 files changed, 1179 insertions(+), 431 deletions(-) create mode 100644 cmd/cancel_contract_test.go create mode 100644 cmd/cancel_messages.go create mode 100644 cmd/install_tracking.go delete mode 100644 cmd/registry_failure_test.go create mode 100644 internal/installations/incomplete_test.go diff --git a/cmd/add.go b/cmd/add.go index f1c1ef4..833527e 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -17,7 +17,6 @@ import ( "fontget/internal/repo" "fontget/internal/shared" "fontget/internal/ui" - "fontget/internal/version" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -660,6 +659,7 @@ Use --scope to set installation location: packagesFailed := 0 cancelled := false + var incompleteCancelIDs []string operationItems := setupInstallationProgressBar(fontsToInstall) @@ -693,6 +693,9 @@ Use --scope to set installation location: for itemIndex, fontGroup := range fontsToInstall { if err := ctx.Err(); err != nil { cancelled = true + for j := itemIndex; j < len(fontsToInstall); j++ { + incompleteCancelIDs = append(incompleteCancelIDs, fontsToInstall[j].FontID) + } return shared.ErrOperationCancelled } @@ -737,6 +740,25 @@ Use --scope to set installation location: ) if err != nil { + isCancel := IsCancelErr(err) + if isCancel { + cancelled = true + incompleteCancelIDs = append(incompleteCancelIDs, fontGroup.FontID) + for j := itemIndex + 1; j < len(fontsToInstall); j++ { + incompleteCancelIDs = append(incompleteCancelIDs, fontsToInstall[j].FontID) + } + if result != nil { + status.Installed += result.Success + status.Skipped += result.Skipped + status.Failed += result.Failed + } + send(components.ItemUpdateMsg{ + Index: itemIndex, + Status: InstallStatusFailed, + Message: "Cancelled", + }) + return shared.ErrOperationCancelled + } packagesFailed++ if result != nil { if result.Status != InstallStatusFailed { @@ -746,6 +768,8 @@ Use --scope to set installation location: status.Failed++ } else { status.Failed += result.Failed + status.Installed += result.Success + status.Skipped += result.Skipped } status.Errors = append(status.Errors, result.Errors...) } else { @@ -759,10 +783,6 @@ Use --scope to set installation location: Message: "Operation failed", ErrorMessage: errorMsg, }) - if errors.Is(err, shared.ErrOperationCancelled) || errors.Is(err, context.Canceled) { - cancelled = true - return shared.ErrOperationCancelled - } continue } @@ -819,12 +839,14 @@ Use --scope to set installation location: if progressErr != nil { if errors.Is(progressErr, shared.ErrOperationCancelled) || cancelled { - fmt.Printf("%s\n", ui.WarningText.Render("Installation cancelled.")) - fmt.Println() - return shared.AlreadyPrinted(shared.ErrOperationCancelled) + if err := FinishInstallationCancel(incompleteCancelIDs, string(installScope), force); err != nil { + return err + } + // Cancellation after all requested work finished — report completion below. + } else { + GetLogger().Error("Failed to install fonts: %v", progressErr) + return progressErr } - GetLogger().Error("Failed to install fonts: %v", progressErr) - return progressErr } handleNotFoundFonts(notFoundFonts, IsDebug()) @@ -1017,12 +1039,19 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin return allFontPaths, nil } -// installDownloadedFonts installs downloaded font files to system -func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress ProgressFunc, tc *installTestControl) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { +// installDownloadedFonts installs downloaded font files to system. +// Cancellation stops before the next file after finishing the current file's place/register/track steps. +// Successfully completed files are kept; package-wide rollback is not performed. +func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager platform.FontManager, installScope platform.InstallationScope, fontDir string, force bool, onProgress ProgressFunc, tc *installTestControl, track *installTracker) (installed, skipped, failed int, details []string, errs []string, downloadSize int64, mutations []platform.FileMutation, err error) { start := time.Now() var installedFiles []string var skippedFiles []string var failedFiles []string + var present []string // retained + newly installed basenames + + if ctx == nil { + ctx = context.Background() + } destPaths := make([]string, 0, len(fontPaths)) for _, fontPath := range fontPaths { @@ -1036,11 +1065,9 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager total := len(fontPaths) for i, fontPath := range fontPaths { - if ctx != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - err = ctxErr - break - } + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + break } fontDisplayName := filepath.Base(fontPath) if onProgress != nil && total > 0 { @@ -1064,6 +1091,13 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager skipped++ _ = os.Remove(fontPath) skippedFiles = append(skippedFiles, fontDisplayName) + present = append(present, fontDisplayName) + if track != nil { + if trackErr := track.persistInstallState(present, nil); trackErr != nil { + err = fmt.Errorf("installation tracking failed: %w", trackErr) + break + } + } continue } } @@ -1079,25 +1113,20 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager output.GetDebug().State("Installing font file: %s to %s (scope: %s)", fontDisplayName, fontDir, installScope) installErr := fontManager.InstallFont(fontPath, installScope, force, batchOpts) - if mut.DestPath != "" { - mutations = append(mutations, mut) - } - if installErr != nil { _ = os.Remove(fontPath) + if mut.DestPath != "" { + _ = platform.RollbackMutation(mut) + } failed++ errorMsg := makeUserFriendlyError(fontDisplayName, installErr) errs = append(errs, errorMsg) failedFiles = append(failedFiles, fontDisplayName) output.GetDebug().Error("fontManager.InstallFont() failed for %s: %v", fontDisplayName, installErr) err = installErr - break - } - - if tc != nil && tc.failAfterMutations > 0 && len(mutations) >= tc.failAfterMutations { - failed++ - err = fmt.Errorf("injected failure after mutation count %d", len(mutations)) - failedFiles = append(failedFiles, fontDisplayName) + if track != nil && len(present) > 0 { + _ = track.persistInstallState(present, errs) + } break } @@ -1105,12 +1134,16 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager if _, statErr := os.Stat(installedPath); statErr == nil { if _, metaErr := platform.ExtractFontMetadata(installedPath); metaErr != nil { _ = os.Remove(fontPath) + _ = platform.RollbackMutation(mut) failed++ errorMsg := makeUserFriendlyError(fontDisplayName, fmt.Errorf("installed file is not a valid font: %w", metaErr)) errs = append(errs, errorMsg) failedFiles = append(failedFiles, fontDisplayName) output.GetDebug().Warning("Installed file failed validation: %s (%v)", fontDisplayName, metaErr) err = metaErr + if track != nil && len(present) > 0 { + _ = track.persistInstallState(present, errs) + } break } } @@ -1119,6 +1152,58 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager _ = os.Remove(fontPath) installed++ installedFiles = append(installedFiles, fontDisplayName) + present = append(present, fontDisplayName) + mutations = append(mutations, mut) + + if track != nil { + if tc != nil && tc.failProvenance { + err = fmt.Errorf("injected provenance failure") + _ = platform.RollbackMutation(mut) + installed-- + installedFiles = installedFiles[:len(installedFiles)-1] + present = present[:len(present)-1] + mutations = mutations[:len(mutations)-1] + failed++ + failedFiles = append(failedFiles, fontDisplayName) + if len(present) > 0 { + _ = track.persistInstallState(present, []string{err.Error()}) + } + break + } + if trackErr := track.persistInstallState(present, nil); trackErr != nil { + _ = platform.RollbackMutation(mut) + installed-- + installedFiles = installedFiles[:len(installedFiles)-1] + present = present[:len(present)-1] + mutations = mutations[:len(mutations)-1] + failed++ + failedFiles = append(failedFiles, fontDisplayName) + err = fmt.Errorf("installation tracking failed: %w", trackErr) + if len(present) > 0 { + _ = track.persistInstallState(present, []string{err.Error()}) + } + break + } + } else if tc != nil && tc.failProvenance { + err = fmt.Errorf("injected provenance failure") + _ = platform.RollbackMutation(mut) + installed-- + installedFiles = installedFiles[:len(installedFiles)-1] + present = present[:len(present)-1] + mutations = mutations[:len(mutations)-1] + failed++ + failedFiles = append(failedFiles, fontDisplayName) + break + } + _ = platform.CommitMutation(mut) + + if tc != nil && tc.failAfterMutations > 0 && installed >= tc.failAfterMutations { + err = fmt.Errorf("injected failure after mutation count %d", installed) + if track != nil { + _ = track.persistInstallState(present, []string{err.Error()}) + } + break + } } if onProgress != nil { @@ -1130,14 +1215,11 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager }) } - if err == nil && installed > 0 { - if ctx != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - err = ctxErr - } - } + if err == nil && ctx.Err() != nil { + err = ctx.Err() } - if err == nil && installed > 0 { + + if installed > 0 || (err != nil && len(present) > 0) { if onProgress != nil { onProgress(ProgressUpdate{Phase: installStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) } @@ -1187,20 +1269,9 @@ func buildInstallResult(status string, message string, installed, skipped, faile // installFont handles the core installation logic for a single font. // // It checks if the font is already installed (unless force is true), downloads all font variants, -// installs them to the system, and returns an InstallResult with the operation outcome. -// The function handles cleanup of temporary files automatically via defer. -// -// Parameters: -// - fontFiles: List of font file variants to install -// - fontID: Font identifier for checking if already installed -// - fontManager: Platform-specific font manager for installation -// - installScope: Installation scope (user or machine) -// - force: If true, skip already-installed check and force reinstallation -// - fontDir: Target directory for font installation -// -// Returns: -// - InstallResult: Contains success/skipped/failed counts and details -// - error: Installation error if the operation fails +// optionally removes existing package files when force is set, installs them to the system, +// and returns an InstallResult with the operation outcome. +// Cancellation keeps successfully completed files and records incomplete package state. func installFont( ctx context.Context, fontFiles []repo.FontFile, @@ -1268,6 +1339,9 @@ func installFont( if downloadErr != nil { return buildInstallResult(InstallStatusFailed, "Download failed", 0, 0, len(fontFiles), nil, nil, 0), downloadErr } + if err := ctx.Err(); err != nil { + return buildInstallResult(InstallStatusFailed, msgDownloadCancelledShort, 0, 0, len(fontFiles), nil, nil, 0), err + } unlockDest, lockErr := installations.LockDestination(ctx, fontDir) if lockErr != nil { @@ -1275,18 +1349,60 @@ func installFont( } defer unlockDest() - installed, skipped, failed, details, instErrs, downloadSize, mutations, installErr := installDownloadedFonts( - ctx, allFontPaths, fontManager, installScope, fontDir, force, onProgress, tc) + expected := make([]string, 0, len(allFontPaths)) + for _, p := range allFontPaths { + expected = append(expected, filepath.Base(p)) + } + track := newInstallTracker(fontID, fontFiles, installScope, fontDir, expected) - if installErr != nil || failed > 0 { - rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, installErr) - res := buildInstallResult(InstallStatusFailed, "Installation failed", 0, skipped, failed, details, instErrs, downloadSize) - if rbErr != nil { - return res, rbErr + if force { + existing := packageBasenamesFromRegistry(fontID, fontDir) + if len(existing) > 0 { + if onProgress != nil { + onProgress(ProgressUpdate{Phase: removeStepRemove, Detail: "existing package", Kind: ProgressCount, Done: 0, Total: float64(len(existing))}) + } + removed, _, remFailed, _, remErrs, remErr := removeFontFiles(RemoveFontFilesParams{ + Ctx: ctx, + MatchingFonts: existing, + FontManager: fontManager, + Scope: installScope, + FontDir: fontDir, + FontID: fontID, + IsCriticalSystemFont: shared.IsCriticalSystemFont, + OnProgress: onProgress, + }) + if remErr != nil || remFailed > 0 { + msg := "Force removal failed" + if IsCancelErr(remErr) { + msg = msgForceRemovalCancelledShort + } + res := buildInstallResult(InstallStatusFailed, msg, 0, 0, remFailed, existing, remErrs, 0) + if remErr != nil { + return res, remErr + } + return res, fmt.Errorf("force install: removal incomplete") + } + _ = removed } - if installErr != nil { - return res, installErr + if err := ctx.Err(); err != nil { + return buildInstallResult(InstallStatusFailed, msgInstallationCancelledShort, 0, 0, 0, nil, nil, 0), err } + } + + installed, skipped, failed, details, instErrs, downloadSize, _, installErr := installDownloadedFonts( + ctx, allFontPaths, fontManager, installScope, fontDir, force, onProgress, tc, track) + + if installErr != nil { + status := InstallStatusFailed + message := "Installation failed" + if IsCancelErr(installErr) { + message = msgInstallationCancelledShort + } + res := buildInstallResult(status, message, installed, skipped, failed, details, instErrs, downloadSize) + return res, installErr + } + if failed > 0 { + res := buildInstallResult(InstallStatusFailed, "Installation failed", installed, skipped, failed, details, instErrs, downloadSize) return res, fmt.Errorf("package install incomplete") } @@ -1298,190 +1414,19 @@ func installFont( } res := buildInstallResult(status, message, installed, skipped, failed, details, instErrs, downloadSize) - if status == InstallStatusCompleted && installed > 0 { - if err := ctx.Err(); err != nil { - rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, err) - res.Status = InstallStatusFailed - res.Success = 0 - if rbErr != nil { - return res, rbErr - } - return res, err - } - if tc != nil && tc.failProvenance { - rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, fmt.Errorf("injected provenance failure")) - res.Status = InstallStatusFailed - res.Success = 0 - if rbErr != nil { - return res, rbErr - } - return res, fmt.Errorf("injected provenance failure") - } - if recErr := tryRecordInstallationRegistry(fontID, fontFiles, installScope, fontDir, res); recErr != nil { - rbErr := rollbackPackageMutations(ctx, fontManager, installScope, fontID, string(installScope), mutations, recErr) - res.Status = InstallStatusFailed - res.Success = 0 - if rbErr != nil { - return res, rbErr - } - return res, recErr - } - for _, mut := range mutations { - _ = platform.CommitMutation(mut) - } - } return res, nil } -func rollbackPackageMutations(ctx context.Context, fontManager platform.FontManager, installScope platform.InstallationScope, fontID, scope string, mutations []platform.FileMutation, cause error) error { - recCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) - defer cancel() - - var outstanding []string - var completed []string - var recErrs []string - if cause != nil { - recErrs = append(recErrs, cause.Error()) - } - var backupPaths []string - var affected []string - for i := len(mutations) - 1; i >= 0; i-- { - if err := recCtx.Err(); err != nil { - outstanding = append(outstanding, "recovery timeout before finishing remaining mutations") - recErrs = append(recErrs, err.Error()) - break - } - mut := mutations[i] - affected = append(affected, mut.DestPath) - if mut.BackupPath != "" { - backupPaths = append(backupPaths, mut.BackupPath) - } - if err := platform.RollbackMutation(mut); err != nil { - outstanding = append(outstanding, "restore "+mut.DestPath) - recErrs = append(recErrs, err.Error()) +func removeBasename(list []string, base string) []string { + base = strings.ToLower(filepath.Base(strings.TrimSpace(base))) + var out []string + for _, s := range list { + if strings.ToLower(filepath.Base(s)) == base { continue } - completed = append(completed, "restore "+mut.DestPath) - if mut.BackupPath != "" { - _ = os.Remove(mut.BackupPath) - } + out = append(out, s) } - - if fontManager != nil && len(mutations) > 0 { - if err := recCtx.Err(); err == nil { - if flushErr := fontManager.FlushFontCache(installScope); flushErr != nil { - output.GetDebug().Warning("Font cache refresh after rollback: %v", flushErr) - } - } - } - - if len(outstanding) == 0 { - return nil - } - // Retain backups for outstanding recoveries. - path, saveErr := installations.SaveRecoveryRecord(installations.RecoveryRecord{ - PackageID: fontID, - Scope: scope, - AffectedPaths: affected, - BackupPaths: backupPaths, - CompletedSteps: completed, - OutstandingSteps: outstanding, - Errors: recErrs, - }) - msg := fmt.Sprintf("recovery required for %s; backups retained", fontID) - if path != "" { - msg = fmt.Sprintf("%s; recovery record: %s", msg, path) - if len(backupPaths) > 0 { - msg = fmt.Sprintf("%s; backups: %s", msg, strings.Join(backupPaths, ", ")) - } - fmt.Printf("%s\n", ui.ErrorText.Render(msg)) - } - if saveErr != nil { - return fmt.Errorf("%w: %v: %v", shared.ErrRecoveryRequired, cause, saveErr) - } - return fmt.Errorf("%w: %s", shared.ErrRecoveryRequired, msg) -} - -func tryRecordInstallationRegistry(fontID string, fontFiles []repo.FontFile, installScope platform.InstallationScope, fontDir string, result *InstallResult) error { - if fontID == "" || result == nil { - return nil - } - if result.Status != InstallStatusCompleted || result.Success <= 0 || result.Failed != 0 { - return nil - } - if result.Success > len(result.Details) { - return fmt.Errorf("installation registry: details shorter than success count") - } - catalogName := "" - variantByBasename := make(map[string]string) - for _, ff := range fontFiles { - b := filepath.Base(strings.TrimSpace(ff.Path)) - if b == "" { - continue - } - variantByBasename[b] = strings.TrimSpace(ff.Variant) - } - if len(fontFiles) > 0 { - catalogName = strings.TrimSpace(fontFiles[0].Name) - } - installedBasenames := result.Details[:result.Success] - nonEmptyBasenames := 0 - for _, base := range installedBasenames { - if strings.TrimSpace(base) != "" { - nonEmptyBasenames++ - } - } - var files []installations.InstalledFontFile - for _, base := range installedBasenames { - base = strings.TrimSpace(base) - if base == "" { - continue - } - full := filepath.Join(fontDir, base) - md, err := platform.ExtractFontMetadata(full) - if err != nil { - output.GetDebug().Warning("installation registry: skipping %s (metadata: %v)", full, err) - continue - } - fam := strings.TrimSpace(md.TypographicFamily) - if fam == "" { - fam = strings.TrimSpace(md.FamilyName) - } - style := strings.TrimSpace(md.TypographicStyle) - if style == "" { - style = strings.TrimSpace(md.StyleName) - } - fullName := strings.TrimSpace(md.FullName) - files = append(files, installations.InstalledFontFile{ - Path: full, - CatalogVariant: variantByBasename[base], - SFNT: installations.SFNTSnapshot{ - Family: fam, - Style: style, - FullName: fullName, - }, - }) - } - if len(files) != nonEmptyBasenames { - return fmt.Errorf("installation registry: incomplete metadata (%d/%d faces)", len(files), nonEmptyBasenames) - } - if len(files) == 0 { - return fmt.Errorf("installation registry: no files to record") - } - installSrc := "" - if meta, metaErr := repo.MatchRepositoryFontByID(fontID); metaErr == nil && meta != nil { - installSrc = strings.TrimSpace(meta.Source) - } else if metaErr != nil { - output.GetDebug().Warning("installation registry: catalog lookup for installation_source failed: %v", metaErr) - } - return installations.RecordInstallation(installations.RecordParams{ - FontID: fontID, - CatalogName: catalogName, - InstallationSource: installSrc, - Scope: string(installScope), - FontGetVersion: version.GetVersion(), - Files: files, - }) + return out } func makeUserFriendlyError(fontName string, err error) string { @@ -1587,7 +1532,13 @@ func checkInstalledViaRegistry(fontID string, scope platform.InstallationScope) return false, false } inst := reg.FindByFontID(fontID) - if inst == nil || !inst.HasFaces() { + if inst == nil { + return false, false + } + if inst.IsIncomplete() { + return false, true // known incomplete — do not treat as already installed + } + if !inst.IsComplete() { return false, false } if !strings.EqualFold(strings.TrimSpace(inst.Scope), strings.TrimSpace(string(scope))) { diff --git a/cmd/add_reliability_test.go b/cmd/add_reliability_test.go index b69265f..972ecc1 100644 --- a/cmd/add_reliability_test.go +++ b/cmd/add_reliability_test.go @@ -192,7 +192,7 @@ func (m *copyFontManager) GetElevationCommand() (string, []string, error) { return "", nil, nil } -func TestInstallRollbackAfterFirstMutation(t *testing.T) { +func TestInstallKeepsCompletedFileAfterInjectedFailure(t *testing.T) { home := t.TempDir() testutil.SetHome(t, home) fontDir := t.TempDir() @@ -214,22 +214,18 @@ func TestInstallRollbackAfterFirstMutation(t *testing.T) { t.Fatal(err) } - _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failAfterMutations: 1}) + installed, _, _, _, _, _, _, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failAfterMutations: 1}, nil) if err == nil { t.Fatal("expected injected failure") } - if rb := rollbackPackageMutations(context.Background(), fm, platform.UserScope, "test.pkg", "user", mutations, err); rb != nil { - t.Fatalf("rollback: %v", rb) + if installed != 1 { + t.Fatalf("expected 1 completed file kept, got %d", installed) } - entries, _ := os.ReadDir(fontDir) - for _, e := range entries { - if strings.HasPrefix(e.Name(), ".") { - continue - } - t.Fatalf("rolled-back dest must not keep created fonts, found %s", e.Name()) + if _, statErr := os.Stat(filepath.Join(fontDir, "Alpha-Regular.ttf")); statErr != nil { + t.Fatalf("completed file must remain: %v", statErr) } - if len(fm.registered) != 0 { - t.Fatalf("registrations left after rollback: %#v", fm.registered) + if _, statErr := os.Stat(filepath.Join(fontDir, "Beta-Regular.ttf")); !os.IsNotExist(statErr) { + t.Fatal("second file must not have been installed") } } @@ -272,7 +268,7 @@ func TestPackageFailureDoesNotCountRolledBackAsInstalled(t *testing.T) { } } -func TestInstallRollbackAfterLaterMutation(t *testing.T) { +func TestInstallKeepsCompletedFilesAfterLaterInjectedFailure(t *testing.T) { fontDir := t.TempDir() fm := ©FontManager{dir: fontDir} staging, err := platform.NewOperationStaging() @@ -288,25 +284,17 @@ func TestInstallRollbackAfterLaterMutation(t *testing.T) { if err := os.WriteFile(pathB, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { t.Fatal(err) } - _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failAfterMutations: 2}) + installed, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failAfterMutations: 2}, nil) if err == nil { t.Fatal("expected injected failure") } - if len(mutations) < 2 { - t.Fatalf("need two mutations before later fail, got %d", len(mutations)) - } - if rb := rollbackPackageMutations(context.Background(), fm, platform.UserScope, "test.pkg", "user", mutations, err); rb != nil { - t.Fatalf("rollback: %v", rb) + if installed != 2 || len(mutations) < 2 { + t.Fatalf("both files should complete before stop: installed=%d mutations=%d", installed, len(mutations)) } - entries, _ := os.ReadDir(fontDir) - for _, e := range entries { - if strings.HasPrefix(e.Name(), ".") { - continue + for _, name := range []string{"Alpha-Regular.ttf", "Beta-Regular.ttf"} { + if _, statErr := os.Stat(filepath.Join(fontDir, name)); statErr != nil { + t.Fatalf("completed file missing %s: %v", name, statErr) } - t.Fatalf("rolled-back dest must not keep created fonts, found %s", e.Name()) - } - if len(fm.registered) != 0 { - t.Fatalf("registrations left after rollback: %#v", fm.registered) } } @@ -322,12 +310,12 @@ func TestInstallRegisterFailRollsBack(t *testing.T) { if err := os.WriteFile(src, testutil.MinimalTTF("Face", "Regular"), 0644); err != nil { t.Fatal(err) } - _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{src}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failRegister: true}) + _, _, _, _, _, _, mutations, err := installDownloadedFonts(context.Background(), []string{src}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failRegister: true}, nil) if err == nil { t.Fatal("expected register failure") } - if rb := rollbackPackageMutations(context.Background(), fm, platform.UserScope, "test.pkg", "user", mutations, err); rb != nil { - t.Fatalf("rollback: %v", rb) + if len(mutations) != 0 { + t.Fatalf("failed file should not remain in mutations: %d", len(mutations)) } entries, _ := os.ReadDir(fontDir) for _, e := range entries { @@ -338,7 +326,7 @@ func TestInstallRegisterFailRollsBack(t *testing.T) { } } -func TestInstallProvenanceFailRollsBack(t *testing.T) { +func TestInstallProvenanceFailRollsBackCurrentFile(t *testing.T) { home := t.TempDir() testutil.SetHome(t, home) payload := testutil.MinimalTTF("ProvFam", "Regular") @@ -376,30 +364,3 @@ func TestInstallProvenanceFailRollsBack(t *testing.T) { t.Fatalf("provenance failure left installed file %s", e.Name()) } } - -func TestRollbackFailureWritesRecoveryRecord(t *testing.T) { - home := t.TempDir() - testutil.SetHome(t, home) - dir := t.TempDir() - dest := filepath.Join(dir, "Face.ttf") - if err := os.Mkdir(dest, 0755); err != nil { - t.Fatal(err) - } - backup := dest + ".fontget-bak" - if err := os.WriteFile(backup, []byte("old-bytes"), 0644); err != nil { - t.Fatal(err) - } - mut := platform.FileMutation{DestPath: dest, BackupPath: backup, Replaced: true} - err := rollbackPackageMutations(context.Background(), nil, platform.UserScope, "test.pkg", "user", []platform.FileMutation{mut}, errors.New("install failed")) - if err == nil || !errors.Is(err, shared.ErrRecoveryRequired) { - t.Fatalf("want ErrRecoveryRequired, got %v", err) - } - if _, statErr := os.Stat(backup); statErr != nil { - t.Fatalf("backup must be retained: %v", statErr) - } - recDir := filepath.Join(home, ".fontget", "recovery") - entries, _ := os.ReadDir(recDir) - if len(entries) == 0 { - t.Fatal("expected recovery record file") - } -} diff --git a/cmd/browse_model.go b/cmd/browse_model.go index 2a71b2f..757b707 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "errors" "fmt" "strings" "sync" @@ -418,15 +417,16 @@ func browseNormalizeSourceLabel(s string) string { return s } -func browseResultFromInstall(fontName, source string, msg installFinishedMsg) (title string, errorTitle bool, body string) { +func browseResultFromInstall(fontName, source string, msg installFinishedMsg, scope platform.InstallationScope, force bool) (title string, errorTitle bool, body string) { fontName = strings.TrimSpace(fontName) if fontName == "" { fontName = shared.PlaceholderNA } source = browseNormalizeSourceLabel(source) if msg.err != nil { - if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, shared.ErrOperationCancelled) { - return "Cancelled", false, ui.InfoText.Render("Installation cancelled.") + if IsCancelErr(msg.err) { + text := FormatInstallationCancelledText([]string{msg.fontID}, string(scope), force) + return "Cancelled", false, ui.WarningText.Render(text) } return "Error", true, ui.RenderError(msg.err.Error()) } @@ -456,8 +456,9 @@ func browseResultFromUninstall(fontName string, installScope platform.Installati fontName = shared.PlaceholderNA } if msg.err != nil { - if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, shared.ErrOperationCancelled) { - return "Cancelled", false, ui.InfoText.Render("Uninstall cancelled.") + if IsCancelErr(msg.err) { + text := FormatRemovalCancelledText([]string{msg.fontID}, string(installScope)) + return "Cancelled", false, ui.WarningText.Render(text) } return "Error", true, ui.RenderError(msg.err.Error()) } @@ -1040,7 +1041,7 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusPhase = "" m.opMsgCh = nil m.opCancel = nil - title, errTitle, body := browseResultFromInstall(fontName, source, msg) + title, errTitle, body := browseResultFromInstall(fontName, source, msg, m.installScope, m.force) m.openResultModal(title, errTitle, body) cmd := m.syncTableDimensions() return m, cmd diff --git a/cmd/cancel_contract_test.go b/cmd/cancel_contract_test.go new file mode 100644 index 0000000..8ea8b04 --- /dev/null +++ b/cmd/cancel_contract_test.go @@ -0,0 +1,290 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "fontget/internal/installations" + "fontget/internal/platform" + "fontget/internal/shared" + "fontget/internal/testutil" +) + +func TestIncompleteInstallDoesNotSatisfyAlreadyInstalled(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + face := filepath.Join(fontDir, "Alpha-Regular.ttf") + if err := os.WriteFile(face, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.UpsertInstallation(installations.UpsertParams{ + FontID: "test.alpha", + Scope: "user", + Files: []installations.InstalledFontFile{{Path: face, SFNT: installations.SFNTSnapshot{Family: "Alpha", Style: "Regular"}}}, + Status: installations.StatusIncompleteInstall, + Remaining: []string{"Beta-Regular.ttf"}, + }); err != nil { + t.Fatal(err) + } + installed, handled := checkInstalledViaRegistry("test.alpha", platform.UserScope) + if !handled || installed { + t.Fatalf("incomplete must not count as installed: installed=%v handled=%v", installed, handled) + } +} + +func TestInstallCancelKeepsCompletedFileAndRecordsIncomplete(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + fm := &cancelAfterInstallFM{copyFontManager: ©FontManager{dir: fontDir}, cancel: cancel} + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + pathA := filepath.Join(staging.Root, "Alpha-Regular.ttf") + pathB := filepath.Join(staging.Root, "Beta-Regular.ttf") + if err := os.WriteFile(pathA, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathB, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { + t.Fatal(err) + } + + track := newInstallTracker("test.cancel", nil, platform.UserScope, fontDir, []string{"Alpha-Regular.ttf", "Beta-Regular.ttf"}) + installed, _, _, _, _, _, _, err := installDownloadedFonts(ctx, []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, nil, track) + if err == nil { + t.Fatal("expected cancel error") + } + if installed != 1 { + t.Fatalf("expected exactly one completed install, got %d", installed) + } + if _, statErr := os.Stat(filepath.Join(fontDir, "Alpha-Regular.ttf")); statErr != nil { + t.Fatalf("first file must remain: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(fontDir, "Beta-Regular.ttf")); !os.IsNotExist(statErr) { + t.Fatal("second file must not start after cancel") + } + reg, loadErr := installations.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + inst := reg.FindByFontID("test.cancel") + if inst == nil || !inst.IsIncomplete() { + t.Fatalf("expected incomplete registry record: %+v", inst) + } + if len(inst.Remaining) == 0 { + t.Fatal("expected remaining basenames") + } +} + +type cancelAfterInstallFM struct { + *copyFontManager + cancel context.CancelFunc + n int +} + +func (m *cancelAfterInstallFM) InstallFont(fontPath string, scope platform.InstallationScope, force bool, opts *platform.InstallFontOptions) error { + err := m.copyFontManager.InstallFont(fontPath, scope, force, opts) + m.n++ + if m.n >= 1 && m.cancel != nil { + m.cancel() + } + return err +} + +func TestRemoveCancelKeepsRemainingFiles(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + a := filepath.Join(fontDir, "Alpha-Regular.ttf") + b := filepath.Join(fontDir, "Beta-Regular.ttf") + if err := os.WriteFile(a, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(b, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "test.rm", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: a, SFNT: installations.SFNTSnapshot{Family: "Alpha", Style: "Regular"}}, + {Path: b, SFNT: installations.SFNTSnapshot{Family: "Beta", Style: "Regular"}}, + }, + }); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + fm := &removeCancelFM{ + removeTrackingFM: &removeTrackingFM{dir: fontDir}, + after: func() { + cancel() + }, + } + removed, _, _, _, _, err := removeFontFiles(RemoveFontFilesParams{ + Ctx: ctx, + MatchingFonts: []string{"Alpha-Regular.ttf", "Beta-Regular.ttf"}, + FontManager: fm, + Scope: platform.UserScope, + FontDir: fontDir, + FontID: "test.rm", + }) + if err == nil { + t.Fatal("expected cancel") + } + if removed != 1 { + t.Fatalf("removed=%d calls=%v", removed, fm.calls) + } + // Cancellation stops before the second file; registry must record incomplete removal. + reg, loadErr := installations.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + inst := reg.FindByFontID("test.rm") + if inst == nil || inst.Status != installations.StatusIncompleteRemove { + t.Fatalf("expected incomplete_remove: %+v", inst) + } + if len(inst.Remaining) != 1 || !strings.EqualFold(inst.Remaining[0], "Beta-Regular.ttf") { + t.Fatalf("expected Beta remaining, got %#v", inst.Remaining) + } + if _, statErr := os.Stat(b); statErr != nil { + t.Fatalf("second file must remain: %v", statErr) + } + _ = a // first file may still appear present on some hosts if delete is deferred; tracking is authoritative +} + +type removeCancelFM struct { + *removeTrackingFM + after func() +} + +func (m *removeCancelFM) RemoveFont(name string, scope platform.InstallationScope, opts *platform.RemoveFontOptions) error { + err := m.removeTrackingFM.RemoveFont(name, scope, opts) + if m.after != nil { + m.after() + } + return err +} + +func TestFormatRetryCommands(t *testing.T) { + got := FormatRetryAddCommand([]string{"nerd.iosevka"}, "user", false) + if got != "fontget add nerd.iosevka" { + t.Fatalf("got %q", got) + } + got = FormatRetryAddCommand([]string{"nerd.iosevka"}, "machine", true) + if got != "fontget add nerd.iosevka --scope machine --force" { + t.Fatalf("got %q", got) + } + got = FormatRetryRemoveCommand([]string{"nerd.iosevka"}, "user") + if got != "fontget remove nerd.iosevka" { + t.Fatalf("got %q", got) + } +} + +func TestFinishInstallationCancel_exitStatus(t *testing.T) { + if err := FinishInstallationCancel(nil, "user", false); err != nil { + t.Fatalf("complete cancel should exit 0 path: %v", err) + } + err := FinishInstallationCancel([]string{"nerd.iosevka"}, "user", false) + if err == nil { + t.Fatal("incomplete cancel must be non-nil") + } + if !errors.Is(err, shared.ErrOperationCancelled) { + t.Fatalf("want ErrOperationCancelled, got %v", err) + } +} + +func TestFinishRemovalCancel_exitStatus(t *testing.T) { + if err := FinishRemovalCancel(nil, "user"); err != nil { + t.Fatalf("complete cancel should exit 0 path: %v", err) + } + err := FinishRemovalCancel([]string{"nerd.iosevka"}, "machine") + if err == nil { + t.Fatal("incomplete cancel must be non-nil") + } + if !errors.Is(err, shared.ErrOperationCancelled) { + t.Fatalf("want ErrOperationCancelled, got %v", err) + } +} + +func TestIsCancelErr(t *testing.T) { + if !IsCancelErr(context.Canceled) || !IsCancelErr(shared.ErrOperationCancelled) { + t.Fatal("expected cancel detection") + } + if IsCancelErr(errors.New("other")) || IsCancelErr(nil) { + t.Fatal("false positive") + } +} + +func TestForceRemovePhaseStopsBeforeInstallOnCancel(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + a := filepath.Join(fontDir, "Old-Regular.ttf") + if err := os.WriteFile(a, testutil.MinimalTTF("Old", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "test.force", + Scope: "user", + Files: []installations.InstalledFontFile{{Path: a, SFNT: installations.SFNTSnapshot{Family: "Old", Style: "Regular"}}}, + }); err != nil { + t.Fatal(err) + } + fm := &removeTrackingFM{dir: fontDir} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled before remove phase + _, _, _, _, _, err := removeFontFiles(RemoveFontFilesParams{ + Ctx: ctx, + MatchingFonts: []string{"Old-Regular.ttf"}, + FontManager: fm, + Scope: platform.UserScope, + FontDir: fontDir, + FontID: "test.force", + }) + if err == nil { + t.Fatal("expected cancel before removal") + } + if _, statErr := os.Stat(a); statErr != nil { + t.Fatalf("cancelled force remove must not delete: %v", statErr) + } +} + +func TestTrackingFailureStopsBeforeNextFile(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + fm := ©FontManager{dir: fontDir} + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + pathA := filepath.Join(staging.Root, "Alpha-Regular.ttf") + pathB := filepath.Join(staging.Root, "Beta-Regular.ttf") + if err := os.WriteFile(pathA, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathB, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { + t.Fatal(err) + } + track := newInstallTracker("test.trackfail", nil, platform.UserScope, fontDir, []string{"Alpha-Regular.ttf", "Beta-Regular.ttf"}) + installed, _, _, _, _, _, _, err := installDownloadedFonts(context.Background(), []string{pathA, pathB}, fm, platform.UserScope, fontDir, false, nil, &installTestControl{failProvenance: true}, track) + if err == nil { + t.Fatal("expected tracking failure") + } + if installed != 0 { + t.Fatalf("failed file must not count as installed: %d", installed) + } + if _, statErr := os.Stat(filepath.Join(fontDir, "Beta-Regular.ttf")); !os.IsNotExist(statErr) { + t.Fatal("must not install next file after tracking failure") + } +} diff --git a/cmd/cancel_messages.go b/cmd/cancel_messages.go new file mode 100644 index 0000000..6d0e71c --- /dev/null +++ b/cmd/cancel_messages.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode" + + "fontget/internal/shared" + "fontget/internal/ui" +) + +// Cancellation contract user-facing copy (single source — do not duplicate in platform files). +const ( + msgInstallationCancelledIncomplete = "Installation cancelled... Some fonts were not installed." + msgRemovalCancelledIncomplete = "Removal cancelled... Some fonts were not removed." + msgInstallationCancelledShort = "Installation cancelled" + msgRemovalCancelledShort = "Removal cancelled" + msgDownloadCancelledShort = "Download cancelled" + msgForceRemovalCancelledShort = "Force removal cancelled" +) + +// IsCancelErr reports user cancellation (context or FontGet sentinel). +func IsCancelErr(err error) bool { + return err != nil && (errors.Is(err, shared.ErrOperationCancelled) || errors.Is(err, context.Canceled)) +} + +func shellQuoteArg(arg string) string { + if arg == "" { + return `""` + } + needs := false + for _, r := range arg { + if unicode.IsSpace(r) || strings.ContainsRune(`"'&|<>()^%!`, r) { + needs = true + break + } + } + if !needs { + return arg + } + return `"` + strings.ReplaceAll(arg, `"`, `\"`) + `"` +} + +func dedupePackageIDs(ids []string) []string { + seen := make(map[string]struct{}) + var out []string + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + key := strings.ToLower(id) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, id) + } + return out +} + +func formatRetryCommand(verb string, packageIDs []string, scope string, force bool) string { + parts := []string{"fontget", verb} + for _, id := range dedupePackageIDs(packageIDs) { + parts = append(parts, shellQuoteArg(id)) + } + scope = strings.TrimSpace(scope) + if scope != "" && !strings.EqualFold(scope, "user") { + parts = append(parts, "--scope", shellQuoteArg(scope)) + } + if force { + parts = append(parts, "--force") + } + return strings.Join(parts, " ") +} + +// FormatRetryAddCommand builds the suggested fontget add command for incomplete installs. +func FormatRetryAddCommand(packageIDs []string, scope string, force bool) string { + return formatRetryCommand("add", packageIDs, scope, force) +} + +// FormatRetryRemoveCommand builds the suggested fontget remove command for incomplete removals. +func FormatRetryRemoveCommand(packageIDs []string, scope string) string { + return formatRetryCommand("remove", packageIDs, scope, false) +} + +// FormatInstallationCancelledText returns the full cancellation + retry text for installs / force installs. +func FormatInstallationCancelledText(packageIDs []string, scope string, force bool) string { + ids := dedupePackageIDs(packageIDs) + if len(ids) == 0 { + return msgInstallationCancelledShort + } + return fmt.Sprintf("%s\nRun `%s` again to complete the installation.", + msgInstallationCancelledIncomplete, FormatRetryAddCommand(ids, scope, force)) +} + +// FormatRemovalCancelledText returns the full cancellation + retry text for removals. +func FormatRemovalCancelledText(packageIDs []string, scope string) string { + ids := dedupePackageIDs(packageIDs) + if len(ids) == 0 { + return msgRemovalCancelledShort + } + return fmt.Sprintf("%s\nRun `%s` again to complete the removal.", + msgRemovalCancelledIncomplete, FormatRetryRemoveCommand(ids, scope)) +} + +func printCancelledText(text string) { + lines := strings.Split(text, "\n") + if len(lines) == 0 { + return + } + fmt.Printf("%s\n", ui.WarningText.Render(lines[0])) + for _, line := range lines[1:] { + fmt.Println(line) + } + fmt.Println() +} + +// PrintInstallationCancelledMessage prints the contract cancellation wording for add / add --force / import. +func PrintInstallationCancelledMessage(packageIDs []string, scope string, force bool) { + printCancelledText(FormatInstallationCancelledText(packageIDs, scope, force)) +} + +// PrintRemovalCancelledMessage prints the contract cancellation wording for remove. +func PrintRemovalCancelledMessage(packageIDs []string, scope string) { + printCancelledText(FormatRemovalCancelledText(packageIDs, scope)) +} + +// FinishInstallationCancel handles exit status after an install-family cancel. +// Incomplete work → print contract message and return non-zero (AlreadyPrinted). +// No remaining work → return nil so the command reports completion. +func FinishInstallationCancel(incompletePackageIDs []string, scope string, force bool) error { + ids := dedupePackageIDs(incompletePackageIDs) + if len(ids) == 0 { + return nil + } + PrintInstallationCancelledMessage(ids, scope, force) + return shared.AlreadyPrinted(shared.ErrOperationCancelled) +} + +// FinishRemovalCancel handles exit status after a remove cancel. +func FinishRemovalCancel(incompletePackageIDs []string, scope string) error { + ids := dedupePackageIDs(incompletePackageIDs) + if len(ids) == 0 { + return nil + } + PrintRemovalCancelledMessage(ids, scope) + return shared.AlreadyPrinted(shared.ErrOperationCancelled) +} diff --git a/cmd/import.go b/cmd/import.go index 0eb5185..d7ed0ed 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -592,6 +592,8 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni // Run unified progress for download and install verbose, _ := cmd.Flags().GetBool("verbose") debug, _ := cmd.Flags().GetBool("debug") + var incompleteCancelIDs []string + cancelled := false progressErr := components.RunProgressBar( title, operationItems, @@ -615,6 +617,10 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni // Process each font group for itemIndex, fontGroup := range fontsToInstall { if err := ctx.Err(); err != nil { + cancelled = true + for j := itemIndex; j < len(fontsToInstall); j++ { + incompleteCancelIDs = append(incompleteCancelIDs, fontsToInstall[j].FontID) + } return shared.ErrOperationCancelled } send(components.ItemUpdateMsg{ @@ -658,8 +664,28 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni ) if err != nil { + if IsCancelErr(err) { + cancelled = true + incompleteCancelIDs = append(incompleteCancelIDs, fontGroup.FontID) + for j := itemIndex + 1; j < len(fontsToInstall); j++ { + incompleteCancelIDs = append(incompleteCancelIDs, fontsToInstall[j].FontID) + } + if result != nil { + status.Installed += result.Success + status.Skipped += result.Skipped + status.Failed += result.Failed + } + send(components.ItemUpdateMsg{ + Index: itemIndex, + Status: "failed", + Message: "Cancelled", + }) + return shared.ErrOperationCancelled + } if result != nil { status.Failed += result.Failed + status.Installed += result.Success + status.Skipped += result.Skipped } else { status.Failed++ } @@ -671,9 +697,6 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni Message: "Operation failed", ErrorMessage: errorMsg, }) - if errors.Is(err, shared.ErrOperationCancelled) || errors.Is(err, context.Canceled) { - return shared.ErrOperationCancelled - } continue } @@ -707,15 +730,15 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni ) if progressErr != nil { - // Check if it was a cancellation - if errors.Is(progressErr, shared.ErrOperationCancelled) { - cmdutils.PrintWarning("Import cancelled.") + if errors.Is(progressErr, shared.ErrOperationCancelled) || cancelled { + if err := FinishInstallationCancel(incompleteCancelIDs, string(installScope), force); err != nil { + return err + } + } else { + cmdutils.PrintErrorf("%v", progressErr) fmt.Println() - return nil // Don't return error for cancellation + return nil } - cmdutils.PrintErrorf("%v", progressErr) - fmt.Println() - return nil } // Show source availability warnings at the bottom (after progress bar, before status report) diff --git a/cmd/install_tracking.go b/cmd/install_tracking.go new file mode 100644 index 0000000..31c2824 --- /dev/null +++ b/cmd/install_tracking.go @@ -0,0 +1,234 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "fontget/internal/installations" + "fontget/internal/output" + "fontget/internal/platform" + "fontget/internal/repo" + "fontget/internal/version" +) + +// installTracker persists per-file install progress for one package. +type installTracker struct { + fontID string + scope platform.InstallationScope + fontDir string + catalogName string + variantByBasename map[string]string + installSrc string + expected []string // all package basenames for this attempt +} + +func newInstallTracker(fontID string, fontFiles []repo.FontFile, scope platform.InstallationScope, fontDir string, expected []string) *installTracker { + t := &installTracker{ + fontID: fontID, + scope: scope, + fontDir: fontDir, + variantByBasename: make(map[string]string), + expected: dedupeBasenameList(expected), + } + for _, ff := range fontFiles { + b := filepath.Base(strings.TrimSpace(ff.Path)) + if b == "" { + b = filepath.Base(strings.TrimSpace(ff.Variant)) + } + if b != "" { + t.variantByBasename[b] = strings.TrimSpace(ff.Variant) + } + if t.catalogName == "" { + t.catalogName = strings.TrimSpace(ff.Name) + } + } + if fontID != "" { + if meta, metaErr := repo.MatchRepositoryFontByID(fontID); metaErr == nil && meta != nil { + t.installSrc = strings.TrimSpace(meta.Source) + } + } + return t +} + +func dedupeBasenameList(in []string) []string { + seen := make(map[string]struct{}) + var out []string + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" { + continue + } + s = filepath.Base(s) + key := strings.ToLower(s) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, s) + } + return out +} + +func remainingBasenames(expected, present []string) []string { + have := make(map[string]struct{}) + for _, p := range present { + have[strings.ToLower(filepath.Base(strings.TrimSpace(p)))] = struct{}{} + } + var rem []string + for _, e := range expected { + if _, ok := have[strings.ToLower(e)]; !ok { + rem = append(rem, e) + } + } + return rem +} + +func installedFaceFromPath(full, catalogVariant string) (installations.InstalledFontFile, error) { + md, err := platform.ExtractFontMetadata(full) + if err != nil { + return installations.InstalledFontFile{}, err + } + fam := strings.TrimSpace(md.TypographicFamily) + if fam == "" { + fam = strings.TrimSpace(md.FamilyName) + } + style := strings.TrimSpace(md.TypographicStyle) + if style == "" { + style = strings.TrimSpace(md.StyleName) + } + return installations.InstalledFontFile{ + Path: full, + CatalogVariant: catalogVariant, + SFNT: installations.SFNTSnapshot{ + Family: fam, + Style: style, + FullName: strings.TrimSpace(md.FullName), + }, + }, nil +} + +func (t *installTracker) facesForBasenames(basenames []string) ([]installations.InstalledFontFile, error) { + if t == nil { + return nil, nil + } + var files []installations.InstalledFontFile + for _, base := range basenames { + base = strings.TrimSpace(base) + if base == "" { + continue + } + full := filepath.Join(t.fontDir, base) + if _, err := os.Stat(full); err != nil { + return nil, fmt.Errorf("tracked file missing: %s", base) + } + face, err := installedFaceFromPath(full, t.variantByBasename[base]) + if err != nil { + return nil, fmt.Errorf("%s: %w", base, err) + } + files = append(files, face) + } + return files, nil +} + +// persistInstallState writes complete or incomplete_install tracking for present basenames. +func (t *installTracker) persistInstallState(present []string, lastErrors []string) error { + if t == nil || strings.TrimSpace(t.fontID) == "" { + return nil + } + present = dedupeBasenameList(present) + remaining := remainingBasenames(t.expected, present) + files, err := t.facesForBasenames(present) + if err != nil { + return err + } + status := "" + if len(remaining) > 0 { + status = installations.StatusIncompleteInstall + } else if len(files) == 0 { + return fmt.Errorf("installation registry: no files to record") + } + return installations.UpsertInstallation(installations.UpsertParams{ + FontID: t.fontID, + CatalogName: t.catalogName, + InstallationSource: t.installSrc, + Scope: string(t.scope), + FontGetVersion: version.GetVersion(), + Files: files, + Status: status, + Remaining: remaining, + LastErrors: lastErrors, + }) +} + +// persistRemoveState writes incomplete_remove or deletes the record when nothing remains. +func persistRemoveState(fontID, scope, fontDir string, stillPresent []string, remaining []string, lastErrors []string) error { + fontID = strings.TrimSpace(fontID) + if fontID == "" { + return nil + } + stillPresent = dedupeBasenameList(stillPresent) + remaining = dedupeBasenameList(remaining) + if len(stillPresent) == 0 && len(remaining) == 0 { + return installations.RemoveInstallation(fontID) + } + var files []installations.InstalledFontFile + for _, base := range stillPresent { + full := filepath.Join(fontDir, base) + face, err := installedFaceFromPath(full, "") + if err != nil { + output.GetDebug().Warning("remove tracking metadata for %s: %v", base, err) + files = append(files, installations.InstalledFontFile{Path: full}) + continue + } + files = append(files, face) + } + catalogName := "" + installSrc := "" + fontGetVer := version.GetVersion() + if reg, err := installations.Load(); err == nil { + if inst := reg.FindByFontID(fontID); inst != nil { + catalogName = inst.CatalogName + installSrc = inst.InstallationSource + if inst.FontGetVersion != "" { + fontGetVer = inst.FontGetVersion + } + } + } + return installations.UpsertInstallation(installations.UpsertParams{ + FontID: fontID, + CatalogName: catalogName, + InstallationSource: installSrc, + Scope: scope, + FontGetVersion: fontGetVer, + Files: files, + Status: installations.StatusIncompleteRemove, + Remaining: remaining, + LastErrors: lastErrors, + }) +} + +// packageBasenamesFromRegistry returns tracked basenames for fontID under fontDir. +func packageBasenamesFromRegistry(fontID, fontDir string) []string { + reg, err := installations.Load() + if err != nil { + return nil + } + inst := reg.FindByFontID(fontID) + if inst == nil { + return nil + } + out := inst.BasenamesForDir(fontDir) + for _, r := range inst.Remaining { + r = filepath.Base(strings.TrimSpace(r)) + if r == "" { + continue + } + full := filepath.Join(fontDir, r) + if _, err := os.Stat(full); err == nil { + out = append(out, r) + } + } + return dedupeBasenameList(out) +} diff --git a/cmd/registry_failure_test.go b/cmd/registry_failure_test.go deleted file mode 100644 index f885ca3..0000000 --- a/cmd/registry_failure_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "testing" - - "fontget/internal/installations" - "fontget/internal/platform" - "fontget/internal/repo" - "fontget/internal/testutil" -) - -func TestTryRecordInstallationRegistry_skipsFailedPackage(t *testing.T) { - home := t.TempDir() - testutil.SetHome(t, home) - - // Failed extract/install must never write provenance (issue #6 cleanup invariant). - tryRecordInstallationRegistry("nerd.cascadia-code", []repo.FontFile{{Name: "Cascadia Code"}}, platform.UserScope, t.TempDir(), - buildInstallResult(InstallStatusFailed, "Download failed", 0, 0, 1, nil, nil, 0)) - - regPath := installations.RegistryPath() - if _, err := os.Stat(regPath); !os.IsNotExist(err) { - data, _ := os.ReadFile(regPath) - t.Fatalf("failed package must not create registry file %q: %s", filepath.Base(regPath), data) - } -} diff --git a/cmd/remove.go b/cmd/remove.go index 24218e8..a8f0bf4 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -402,44 +402,42 @@ type RemoveFontFilesParams struct { FontManager platform.FontManager Scope platform.InstallationScope FontDir string + FontID string // catalog id when known (for incomplete tracking) IsCriticalSystemFont func(string) bool OnProgress ProgressFunc } -// removeFontFiles removes font files from system -func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int, details []string, errors []string) { +// removeFontFiles removes font files one at a time (unregister + delete + track). +// Cancellation finishes the current file, then stops before starting another. +func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int, details []string, errors []string, err error) { ctx := params.Ctx if ctx == nil { ctx = context.Background() } unlock, lockErr := installations.LockDestination(ctx, params.FontDir) if lockErr != nil { - return 0, 0, len(params.MatchingFonts), nil, []string{lockErr.Error()} + return 0, 0, len(params.MatchingFonts), nil, []string{lockErr.Error()}, lockErr } defer unlock() - unregisterOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true, UnregisterOnly: true} - deleteOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} - + opts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} total := len(params.MatchingFonts) - type pending struct { - base string - display string - } - var toDelete []pending + left := append([]string(nil), params.MatchingFonts...) + track := strings.TrimSpace(params.FontID) != "" - // Pass 1: unregister (Windows RemoveFontResource). Defer deletes until after one FlushFontCache - // so the session releases file locks — deleting immediately after UnregisterOnly=false used to fail "in use". for i, matchingFont := range params.MatchingFonts { - if err := ctx.Err(); err != nil { - errors = append(errors, err.Error()) + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + if track { + _ = persistRemoveState(params.FontID, string(params.Scope), params.FontDir, left, left, errors) + } break } if params.OnProgress != nil && total > 0 { params.OnProgress(ProgressUpdate{ Phase: removeStepRemove, Kind: ProgressCount, - Done: float64(i) * 0.5, + Done: float64(i), Total: float64(total), }) } @@ -449,72 +447,68 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int if params.IsCriticalSystemFont != nil && params.IsCriticalSystemFont(matchingFont) { skipped++ details = append(details, display+" (Skipped - Protected system font)") + left = removeBasename(left, matchingFont) continue } if _, statErr := os.Stat(fontPath); os.IsNotExist(statErr) { removed++ details = append(details, display+" (already gone)") output.GetDebug().State("Font file already absent, counting as removed: %s", matchingFont) - continue - } - - output.GetDebug().State("Unregistering font: %s (%s)", matchingFont, params.Scope) - if err := params.FontManager.RemoveFont(matchingFont, params.Scope, unregisterOpts); err != nil { - if strings.Contains(err.Error(), "font not found") { - removed++ - details = append(details, display+" (already gone)") - continue + left = removeBasename(left, matchingFont) + if track { + if trackErr := persistRemoveState(params.FontID, string(params.Scope), params.FontDir, left, left, nil); trackErr != nil { + err = fmt.Errorf("removal tracking failed: %w", trackErr) + errors = append(errors, err.Error()) + break + } } - failed++ - errors = append(errors, "Failed to unregister font") - details = append(details, display+" (Failed)") - output.GetDebug().Error("unregister RemoveFont failed for %s: %v", matchingFont, err) continue } - toDelete = append(toDelete, pending{base: matchingFont, display: display}) - } - - if len(toDelete) > 0 && ctx.Err() == nil { - if flushErr := params.FontManager.FlushFontCache(params.Scope); flushErr != nil { - output.GetDebug().Warning("Font cache flush before delete: %v", flushErr) - } - } - // Pass 2: delete files (RemoveFont does delete + optional notify; we already flushed). - for j, p := range toDelete { - if err := ctx.Err(); err != nil { - errors = append(errors, err.Error()) - break - } - if params.OnProgress != nil && total > 0 { - params.OnProgress(ProgressUpdate{ - Phase: removeStepRemove, - Kind: ProgressCount, - Done: float64(total)/2 + float64(j+1)*0.5, - Total: float64(total), - }) - } - output.GetDebug().State("Deleting font file: %s (%s)", p.base, params.Scope) - if err := params.FontManager.RemoveFont(p.base, params.Scope, deleteOpts); err != nil { - if strings.Contains(err.Error(), "font not found") || strings.Contains(err.Error(), "cannot find the file") { + output.GetDebug().State("Removing font: %s (%s)", matchingFont, params.Scope) + if remErr := params.FontManager.RemoveFont(matchingFont, params.Scope, opts); remErr != nil { + if strings.Contains(remErr.Error(), "font not found") || strings.Contains(remErr.Error(), "cannot find the file") { removed++ - details = append(details, p.display+" (already gone)") + details = append(details, display+" (already gone)") + left = removeBasename(left, matchingFont) + if track { + if trackErr := persistRemoveState(params.FontID, string(params.Scope), params.FontDir, left, left, nil); trackErr != nil { + err = fmt.Errorf("removal tracking failed: %w", trackErr) + errors = append(errors, err.Error()) + break + } + } continue } failed++ - errStr := err.Error() + errStr := remErr.Error() if containsAny(errStr, []string{"in use", "access denied", "permission", "used by another"}) { errors = append(errors, "Font is in use or access denied") + } else if strings.Contains(strings.ToLower(errStr), "restore registration") { + errors = append(errors, "Failed to delete font and restore registration") } else { errors = append(errors, "Failed to remove existing font") } - details = append(details, p.display+" (Failed)") - output.GetDebug().Error("delete RemoveFont failed for %s: %v", p.base, err) - continue + details = append(details, display+" (Failed)") + output.GetDebug().Error("RemoveFont failed for %s: %v", matchingFont, remErr) + err = remErr + if track { + _ = persistRemoveState(params.FontID, string(params.Scope), params.FontDir, left, left, errors) + } + break } + removed++ - details = append(details, p.display) - output.GetDebug().State("Successfully removed font: %s", p.display) + details = append(details, display) + left = removeBasename(left, matchingFont) + output.GetDebug().State("Successfully removed font: %s", display) + if track { + if trackErr := persistRemoveState(params.FontID, string(params.Scope), params.FontDir, left, left, nil); trackErr != nil { + err = fmt.Errorf("removal tracking failed: %w", trackErr) + errors = append(errors, err.Error()) + break + } + } } if params.OnProgress != nil { @@ -526,7 +520,7 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int }) } - if removed > 0 && ctx.Err() == nil { + if removed > 0 { if params.OnProgress != nil { params.OnProgress(ProgressUpdate{Phase: removeStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) } @@ -545,7 +539,13 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int } } - return removed, skipped, failed, details, errors + if track && err == nil && failed == 0 && len(left) == 0 { + if rmErr := installations.RemoveInstallation(params.FontID); rmErr != nil { + output.GetDebug().Error("remove installation registry entry: %v", rmErr) + } + } + + return removed, skipped, failed, details, errors, err } // buildRemoveResult builds RemoveResult from removal outcomes @@ -618,31 +618,37 @@ func removeFont( result.Message = "Font not found" return result, err } + fontID := "" + if usedRegistry { + fontID = fontName + } else if installReg != nil { + if inst := installReg.FindByFontID(fontName); inst != nil { + fontID = fontName + } + } if onProgress != nil { onProgress(ProgressUpdate{Phase: removeStepScan, Kind: ProgressFlag, Done: 1, Total: 1}) } // Remove font files - removed, skipped, failed, details, errors := removeFontFiles(RemoveFontFilesParams{ + removed, skipped, failed, details, errors, remErr := removeFontFiles(RemoveFontFilesParams{ Ctx: ctx, MatchingFonts: matchingFonts, FontManager: fontManager, Scope: scope, FontDir: fontDir, + FontID: fontID, IsCriticalSystemFont: shared.IsCriticalSystemFont, OnProgress: onProgress, }) res := buildRemoveResult(removed, skipped, failed, details, errors) + if remErr != nil { + return res, remErr + } if err := ctx.Err(); err != nil { return res, err } - // Clear install record when every listed file is gone (removed or already absent) and none failed. - if usedRegistry && failed == 0 && removed == len(matchingFonts) && removed > 0 { - if rmErr := installations.RemoveInstallation(fontName); rmErr != nil { - output.GetDebug().Error("remove installation registry entry: %v", rmErr) - } - } return res, nil } @@ -1767,7 +1773,7 @@ Use --scope to set removal location: output.GetDebug().State("Calling removeFont(%s, %s, %s)", fontInfo.SearchName, scopeLabelName, fontDir) result, err := removeFont( - context.Background(), + cmd.Context(), fontInfo.SearchName, fontManager, scopeType, @@ -1779,6 +1785,9 @@ Use --scope to set removal location: ) if err != nil { + if IsCancelErr(err) { + return FinishRemovalCancel([]string{fontInfo.SearchName}, scopeFlag) + } output.GetDebug().State("Error removing font %s in %s: %v", fontInfo.SearchName, scopeLabelName, err) if result != nil { updateRemovalStatus(status, result) @@ -1829,6 +1838,13 @@ Use --scope to set removal location: title = OpRemovingFontsBothScopes } + opCtx := cmd.Context() + if opCtx == nil { + opCtx = context.Background() + } + var incompleteCancelIDs []string + cancelledRemove := false + // Run unified progress for font removal (TUI mode) fontsInOppositeScope := []string{} // Track fonts that still exist in opposite scope after removal progressErr := components.RunProgressBar( @@ -1837,7 +1853,7 @@ Use --scope to set removal location: verbose, // Verbose mode: show operational details and file/variant listings debug, // Debug mode: show technical details func(send func(msg tea.Msg), cancelChan <-chan struct{}) error { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(opCtx) defer cancel() go func() { select { @@ -1852,6 +1868,8 @@ Use --scope to set removal location: // "all" scope - process each font+scope combination individually for _, item := range fontScopeItems { if err := ctx.Err(); err != nil { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, item.FontName) return shared.ErrOperationCancelled } // Send initial "in_progress" message @@ -1890,7 +1908,12 @@ Use --scope to set removal location: manifestProbe, onProgress, ) - if errors.Is(err, context.Canceled) || errors.Is(err, shared.ErrOperationCancelled) { + if IsCancelErr(err) { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, item.FontName) + if result != nil { + updateRemovalStatus(status, result) + } return shared.ErrOperationCancelled } @@ -1957,6 +1980,8 @@ Use --scope to set removal location: // Single scope - process each found font for i, fontInfo := range foundFonts { if err := ctx.Err(); err != nil { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, fontInfo.SearchName) return shared.ErrOperationCancelled } // Use proper font name from pre-extracted map @@ -2116,7 +2141,12 @@ Use --scope to set removal location: ) if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, shared.ErrOperationCancelled) { + if IsCancelErr(err) { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, fontName) + if result != nil { + updateRemovalStatus(status, result) + } return shared.ErrOperationCancelled } if strings.Contains(err.Error(), "not found") { @@ -2237,7 +2267,12 @@ Use --scope to set removal location: ) if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, shared.ErrOperationCancelled) { + if IsCancelErr(err) { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, fontName) + if result != nil { + updateRemovalStatus(status, result) + } return shared.ErrOperationCancelled } if strings.Contains(err.Error(), "not found") { @@ -2347,13 +2382,15 @@ Use --scope to set removal location: if progressErr != nil { // Check if it was a cancellation - if errors.Is(progressErr, shared.ErrOperationCancelled) { - fmt.Printf("%s\n", ui.WarningText.Render("Removal cancelled.")) - fmt.Println() - return nil // Don't return error for cancellation + if errors.Is(progressErr, shared.ErrOperationCancelled) || cancelledRemove { + if err := FinishRemovalCancel(incompleteCancelIDs, scopeFlag); err != nil { + return err + } + // Cancellation after all requested work finished — fall through to completion reporting. + } else { + GetLogger().Error("Failed to process font removal: %v", progressErr) + return progressErr } - GetLogger().Error("Failed to process font removal: %v", progressErr) - return progressErr } GetLogger().Info("Removal complete - Removed: %d, Skipped: %d, Failed: %d", diff --git a/cmd/remove_stale_test.go b/cmd/remove_stale_test.go index e5715a5..14bc88d 100644 --- a/cmd/remove_stale_test.go +++ b/cmd/remove_stale_test.go @@ -34,7 +34,7 @@ func (m *removeTrackingFM) GetElevationCommand() (string, []string, error) { func TestRemoveFontFiles_missingFilesCountAsRemoved(t *testing.T) { dir := t.TempDir() fm := &removeTrackingFM{dir: dir} - removed, skipped, failed, _, _ := removeFontFiles(RemoveFontFilesParams{ + removed, skipped, failed, _, _, _ := removeFontFiles(RemoveFontFilesParams{ Ctx: context.Background(), MatchingFonts: []string{"Lekton.ttf", "Lekton-Bold.ttf"}, FontManager: fm, @@ -55,7 +55,7 @@ func TestRemoveFontFiles_presentStillCallsRemove(t *testing.T) { t.Fatal(err) } fm := &removeTrackingFM{dir: dir} - removed, _, failed, _, _ := removeFontFiles(RemoveFontFilesParams{ + removed, _, failed, _, _, _ := removeFontFiles(RemoveFontFilesParams{ Ctx: context.Background(), MatchingFonts: []string{"Lekton.ttf", "Gone.ttf"}, FontManager: fm, @@ -65,8 +65,8 @@ func TestRemoveFontFiles_presentStillCallsRemove(t *testing.T) { if removed != 2 || failed != 0 { t.Fatalf("removed=%d failed=%d", removed, failed) } - // unregister + delete for present file only - if len(fm.calls) != 2 || fm.calls[0] != "Lekton.ttf" || fm.calls[1] != "Lekton.ttf" { + // one RemoveFont call for the present file (absent file skipped) + if len(fm.calls) != 1 || fm.calls[0] != "Lekton.ttf" { t.Fatalf("calls=%v", fm.calls) } } diff --git a/docs/development/build-guide.md b/docs/development/build-guide.md index c8a76d7..c78f96d 100644 --- a/docs/development/build-guide.md +++ b/docs/development/build-guide.md @@ -61,5 +61,5 @@ For version/commit/date in the binary, use the same ldflags as in `scripts/build - **Build fails:** Ensure Go 1.26+ (`go version`), you’re in the repo root (where `go.mod` is), and run `go mod tidy` if needed. - **Permission denied on script:** Run with `sh scripts/build.sh` (or `bash scripts/build.sh`), not `./scripts/build.sh`. -- **Binary won’t run (e.g. on pCloud):** Default output is already `/tmp/fontget-dev`; run `/tmp/fontget-dev`. If you used `FONTGET_OUTPUT=./fontget`, the filesystem may not allow execute — use the default or build to another local path. +- **Binary won’t run (e.g. on pCloud, onedrive etc):** Default output is already `/tmp/fontget-dev`; run `/tmp/fontget-dev`. If you used `FONTGET_OUTPUT=./fontget`, the filesystem may not allow execute — use the default or build to another local path. - **Windows:** Use the PowerShell script; Make is optional (e.g. via WSL or Chocolatey). diff --git a/internal/installations/incomplete_test.go b/internal/installations/incomplete_test.go new file mode 100644 index 0000000..bfce12e --- /dev/null +++ b/internal/installations/incomplete_test.go @@ -0,0 +1,50 @@ +package installations + +import ( + "path/filepath" + "testing" + + "fontget/internal/testutil" +) + +func TestInstallationIncompleteStatus(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + face := filepath.Join(fontDir, "A.ttf") + if err := UpsertInstallation(UpsertParams{ + FontID: "pkg.a", + Scope: "user", + Files: []InstalledFontFile{{Path: face, SFNT: SFNTSnapshot{Family: "A"}}}, + Status: StatusIncompleteInstall, + Remaining: []string{"B.ttf"}, + }); err != nil { + t.Fatal(err) + } + reg, err := Load() + if err != nil { + t.Fatal(err) + } + inst := reg.FindByFontID("pkg.a") + if inst == nil || inst.IsComplete() || !inst.IsIncomplete() { + t.Fatalf("incomplete install: %+v", inst) + } + if err := UpsertInstallation(UpsertParams{ + FontID: "pkg.a", + Scope: "user", + Files: []InstalledFontFile{ + {Path: face, SFNT: SFNTSnapshot{Family: "A"}}, + {Path: filepath.Join(fontDir, "B.ttf"), SFNT: SFNTSnapshot{Family: "B"}}, + }, + }); err != nil { + t.Fatal(err) + } + reg, err = Load() + if err != nil { + t.Fatal(err) + } + inst = reg.FindByFontID("pkg.a") + if inst == nil || !inst.IsComplete() { + t.Fatalf("complete install: %+v", inst) + } +} diff --git a/internal/installations/registry.go b/internal/installations/registry.go index f2f3707..cf1d576 100644 --- a/internal/installations/registry.go +++ b/internal/installations/registry.go @@ -57,6 +57,13 @@ type InstalledFace struct { CatalogVariant string `json:"catalog_variant,omitempty"` } +// Installation status values for interrupted or failed multi-file operations. +// Empty Status means a completed installation (Remaining must also be empty). +const ( + StatusIncompleteInstall = "incomplete_install" + StatusIncompleteRemove = "incomplete_remove" +) + // Installation is one catalog install record (map key: lowercase Font ID). type Installation struct { FontID string `json:"font_id"` @@ -66,6 +73,12 @@ type Installation struct { InstalledAt time.Time `json:"installed_at"` FontGetVersion string `json:"fontget_version,omitempty"` Families []FamilyGroup `json:"families"` + // Status is empty when complete; otherwise incomplete_install or incomplete_remove. + Status string `json:"status,omitempty"` + // Remaining are basenames still to install (incomplete_install) or remove (incomplete_remove). + Remaining []string `json:"remaining,omitempty"` + // LastErrors records unresolved file/registration/tracking issues from the last attempt. + LastErrors []string `json:"last_errors,omitempty"` } // Bump when the persisted JSON contract changes incompatibly. @@ -251,14 +264,47 @@ type RecordParams struct { Files []InstalledFontFile // one row per installed file (grouped on save) } -// RecordInstallation upserts one installation keyed by lowercase Font ID. +// UpsertParams describes a full or partial install/remove record to persist. +type UpsertParams struct { + FontID string + CatalogName string + InstallationSource string + Scope string + FontGetVersion string + Files []InstalledFontFile // currently present tracked files + Status string // empty = complete; incomplete_install | incomplete_remove + Remaining []string // basenames still to install or remove + LastErrors []string +} + +// RecordInstallation upserts one completed installation keyed by lowercase Font ID. func RecordInstallation(p RecordParams) error { + return UpsertInstallation(UpsertParams{ + FontID: p.FontID, + CatalogName: p.CatalogName, + InstallationSource: p.InstallationSource, + Scope: p.Scope, + FontGetVersion: p.FontGetVersion, + Files: p.Files, + }) +} + +// UpsertInstallation writes a complete or incomplete installation record. +// An incomplete install with no present files is allowed (Remaining only). +// A complete record requires at least one file. +func UpsertInstallation(p UpsertParams) error { if strings.TrimSpace(p.FontID) == "" { return fmt.Errorf("empty font_id") } - if len(p.Files) == 0 { + status := strings.TrimSpace(p.Status) + flat := normalizeInstalledFiles(p.Files) + remaining := dedupeStrings(p.Remaining) + if status == "" && len(remaining) == 0 && len(flat) == 0 { return fmt.Errorf("empty files") } + if status != "" && status != StatusIncompleteInstall && status != StatusIncompleteRemove { + return fmt.Errorf("invalid installation status %q", status) + } key := strings.ToLower(strings.TrimSpace(p.FontID)) return withRegistryFileLock(context.Background(), func() error { @@ -270,7 +316,6 @@ func RecordInstallation(p RecordParams) error { return err } - flat := normalizeInstalledFiles(p.Files) inst := &Installation{ FontID: p.FontID, CatalogName: strings.TrimSpace(p.CatalogName), @@ -279,6 +324,14 @@ func RecordInstallation(p RecordParams) error { InstalledAt: time.Now().UTC(), FontGetVersion: strings.TrimSpace(p.FontGetVersion), Families: GroupInstalledFiles(flat), + Status: status, + Remaining: remaining, + LastErrors: dedupeStrings(p.LastErrors), + } + if status == "" { + inst.Status = "" + inst.Remaining = nil + inst.LastErrors = nil } reg.Installations[key] = inst return saveUnlocked(reg) @@ -396,6 +449,24 @@ func (inst *Installation) HasFaces() bool { return false } +// IsComplete reports a finished install with no remaining work recorded. +// Incomplete installs must never satisfy the "already installed" shortcut. +func (inst *Installation) IsComplete() bool { + if inst == nil || !inst.HasFaces() { + return false + } + return strings.TrimSpace(inst.Status) == "" && len(inst.Remaining) == 0 +} + +// IsIncomplete reports interrupted install or remove work. +func (inst *Installation) IsIncomplete() bool { + if inst == nil { + return false + } + s := strings.TrimSpace(inst.Status) + return s == StatusIncompleteInstall || s == StatusIncompleteRemove +} + // RemoveInstallation deletes the record for fontID (case-insensitive). func RemoveInstallation(fontID string) error { key := strings.ToLower(strings.TrimSpace(fontID)) diff --git a/internal/platform/darwin.go b/internal/platform/darwin.go index d11680d..0a57302 100644 --- a/internal/platform/darwin.go +++ b/internal/platform/darwin.go @@ -123,7 +123,8 @@ func (m *darwinFontManager) RemoveFont(fontName string, scope InstallationScope, return nil } - // Delete the font file + // Delete the font file. No separate registration API on macOS (unlike Windows GDI). + // Cancellation boundaries and user-facing messages are handled in cmd/. if err := os.Remove(fontPath); err != nil { return fmt.Errorf("failed to remove font file: %w", err) } diff --git a/internal/platform/linux.go b/internal/platform/linux.go index da0caef..ef592dd 100644 --- a/internal/platform/linux.go +++ b/internal/platform/linux.go @@ -121,7 +121,8 @@ func (m *linuxFontManager) RemoveFont(fontName string, scope InstallationScope, return nil } - // Delete the font file + // Delete the font file. No separate registration API on Linux (unlike Windows GDI). + // Cancellation boundaries and user-facing messages are handled in cmd/. if err := os.Remove(fontPath); err != nil { return fmt.Errorf("failed to remove font file: %w", err) } diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 3d1fe77..4ac9cca 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -291,7 +291,11 @@ func (m *windowsFontManager) RemoveFont(fontName string, scope InstallationScope } if removeErr != nil { logger.Error("Failed to remove font file at path %s: %v", fontPath, removeErr) - // ponytail: do not AddFontResource after a failed delete — that re-locks the file. + // Restore GDI registration only (Windows). User-facing cancel/retry copy lives in cmd/. + if addErr := AddFontResource(fontPath); addErr != nil { + logger.Error("Failed to restore font registration after delete failure for %s: %v", fontPath, addErr) + return fmt.Errorf("failed to remove font file: %w (also failed to restore registration: %v)", removeErr, addErr) + } return fmt.Errorf("failed to remove font file: %w", removeErr) } logger.Debug("Font file removed successfully") From 7954ff8b6c00abf717605a0db053ad0ec9a78836 Mon Sep 17 00:00:00 2001 From: graphixa Date: Mon, 14 Sep 2026 23:24:23 +1000 Subject: [PATCH 11/23] fix: clear staticcheck SA4006 and U1000 --- cmd/progress_steps.go | 1 - cmd/remove.go | 16 ---------------- internal/platform/windows.go | 12 ++++++++---- 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/cmd/progress_steps.go b/cmd/progress_steps.go index 69ce932..ec97475 100644 --- a/cmd/progress_steps.go +++ b/cmd/progress_steps.go @@ -144,7 +144,6 @@ func OverallWorkPercent(itemIndex, itemCount int, u ProgressUpdate) float64 { itemIndex = 0 } if itemIndex >= itemCount { - itemIndex = itemCount - 1 return 100 } frac := itemFrac(u) diff --git a/cmd/remove.go b/cmd/remove.go index a8f0bf4..07b3150 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -246,22 +246,6 @@ func resolveFontNameOrID(input string, repository *repo.Repository) string { return input } -// extractFontDisplayNameFromPath extracts the proper display name from a font file path -// Uses font metadata (SFNT name table) for accurate font names, falls back to filename parsing -func extractFontDisplayNameFromPath(fontPath string) string { - // Try to extract metadata from the font file first (most accurate) - if metadata, err := platform.ExtractFontMetadata(fontPath); err == nil { - if metadata.FamilyName != "" { - // Use FormatFontNameWithVariant to properly format the name with style - return shared.FormatFontNameWithVariant(metadata.FamilyName, metadata.StyleName) - } - } - - // Fallback to filename parsing if metadata extraction fails - filename := filepath.Base(fontPath) - return shared.GetDisplayNameFromFilename(filename) -} - // extractFontFamilyNameFromPath extracts just the font family name (without variant) from a font file path // Uses font metadata (SFNT name table) for accurate font names, falls back to filename parsing func extractFontFamilyNameFromPath(fontPath string) string { diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 4ac9cca..1a46e72 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -434,9 +434,13 @@ func (m *windowsFontManager) removeFontFromRegistry(fontName string) error { func (m *windowsFontManager) openFontRegistryKey() (syscall.Handle, error) { logger := logging.GetLogger() var key syscall.Handle - ret, _, err := regCreateKeyEx.Call( + fontsKey, err := syscall.UTF16PtrFromString(`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`) + if err != nil { + return 0, fmt.Errorf("font registry path: %w", err) + } + ret, _, callErr := regCreateKeyEx.Call( uintptr(HKEY_LOCAL_MACHINE), - uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts"))), + uintptr(unsafe.Pointer(fontsKey)), 0, 0, 0, @@ -446,8 +450,8 @@ func (m *windowsFontManager) openFontRegistryKey() (syscall.Handle, error) { 0, ) if ret != 0 { - logger.Error("Failed to open registry key: %v", err) - return 0, fmt.Errorf("failed to open registry key: %w", err) + logger.Error("Failed to open registry key: %v", callErr) + return 0, fmt.Errorf("failed to open registry key: %w", callErr) } logger.Debug("Registry key opened successfully") return key, nil From fa974263598a7a3b182d828b79b4f02ff4879f77 Mon Sep 17 00:00:00 2001 From: graphixa Date: Mon, 14 Sep 2026 23:39:21 +1000 Subject: [PATCH 12/23] fix(test): portable paths for destination collision check --- internal/platform/mutation_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/platform/mutation_test.go b/internal/platform/mutation_test.go index 3980d99..f43f52b 100644 --- a/internal/platform/mutation_test.go +++ b/internal/platform/mutation_test.go @@ -101,10 +101,13 @@ func TestPlaceFontFileCopyAfterWriteTracksPartial(t *testing.T) { } func TestCheckDestinationCollisions(t *testing.T) { - if err := CheckDestinationCollisions([]string{`C:\a\Foo.ttf`, `C:\b\Foo.ttf`}); err == nil { + a := filepath.Join("dir-a", "Foo.ttf") + b := filepath.Join("dir-b", "Foo.ttf") + c := filepath.Join("dir-a", "Bar.ttf") + if err := CheckDestinationCollisions([]string{a, b}); err == nil { t.Fatal("expected collision") } - if err := CheckDestinationCollisions([]string{`C:\a\Foo.ttf`, `C:\a\Bar.ttf`}); err != nil { + if err := CheckDestinationCollisions([]string{a, c}); err != nil { t.Fatal(err) } } From 4a0d8b9333cdc544be4827c485d6650ecba930ba Mon Sep 17 00:00:00 2001 From: graphixa Date: Wed, 16 Sep 2026 09:01:15 +1000 Subject: [PATCH 13/23] Add manifest and registry migration logic for Nerd Fonts source from V1 to V2 on fontget-sources --- internal/config/manifest.go | 17 ++- internal/config/manifest_test.go | 18 ++- internal/installations/registry.go | 2 +- internal/installations/registry_migrate.go | 68 ++++++++-- .../installations/registry_migrate_test.go | 125 ++++++++++++++++++ internal/sources/urls.go | 7 +- internal/sources/urls_test.go | 19 +++ 7 files changed, 234 insertions(+), 22 deletions(-) create mode 100644 internal/installations/registry_migrate_test.go create mode 100644 internal/sources/urls_test.go diff --git a/internal/config/manifest.go b/internal/config/manifest.go index 2854157..e3be311 100644 --- a/internal/config/manifest.go +++ b/internal/config/manifest.go @@ -342,7 +342,8 @@ var BuiltInSourceNames = []string{ } // mergeBuiltInSourcesFromDefaults inserts any missing built-in source rows from the current defaults -// (enabled, URL, prefix, priority). Returns true if the manifest was modified. +// and refreshes URL/filename/prefix/priority for existing built-ins (Enabled is preserved). +// Returns true if the manifest was modified. func mergeBuiltInSourcesFromDefaults(m *Manifest) (bool, error) { def, err := createDefaultManifest() if err != nil { @@ -353,10 +354,22 @@ func mergeBuiltInSourcesFromDefaults(m *Manifest) (bool, error) { } changed := false for name, cfg := range def.Sources { - if _, exists := m.Sources[name]; !exists { + existing, exists := m.Sources[name] + if !exists { m.Sources[name] = cfg changed = true + continue } + // CLI forbids editing built-in URL/prefix/priority; keep Enabled, sync the rest from this binary. + if existing.URL == cfg.URL && existing.Filename == cfg.Filename && existing.Prefix == cfg.Prefix && existing.Priority == cfg.Priority { + continue + } + existing.URL = cfg.URL + existing.Filename = cfg.Filename + existing.Prefix = cfg.Prefix + existing.Priority = cfg.Priority + m.Sources[name] = existing + changed = true } return changed, nil } diff --git a/internal/config/manifest_test.go b/internal/config/manifest_test.go index 8dd55f3..085c80b 100644 --- a/internal/config/manifest_test.go +++ b/internal/config/manifest_test.go @@ -72,7 +72,7 @@ func TestMergeBuiltInSourcesFromDefaults(t *testing.T) { t.Fatalf("mergeBuiltInSourcesFromDefaults: %v", err) } if !changed { - t.Fatal("expected merge to report changes when built-ins are missing") + t.Fatal("expected merge to report changes when built-ins are missing or stale") } if len(m.Sources) != 6 { t.Fatalf("len(Sources) = %d, want 6", len(m.Sources)) @@ -88,13 +88,23 @@ func TestMergeBuiltInSourcesFromDefaults(t *testing.T) { } } - // Existing entries must be untouched - if m.Sources["Google Fonts"].URL != "https://example.com/google.json" { - t.Errorf("Google Fonts URL was overwritten") + def, err := createDefaultManifest() + if err != nil { + t.Fatal(err) + } + // Built-in location fields refresh from defaults; Enabled is preserved. + if m.Sources["Google Fonts"].URL != def.Sources["Google Fonts"].URL { + t.Errorf("Google Fonts URL = %q want default %q", m.Sources["Google Fonts"].URL, def.Sources["Google Fonts"].URL) } if m.Sources["Nerd Fonts"].Enabled { t.Errorf("Nerd Fonts Enabled should remain false") } + if m.Sources["Nerd Fonts"].Filename != "nerd-fonts-v2.json" { + t.Errorf("Nerd Fonts Filename = %q want nerd-fonts-v2.json", m.Sources["Nerd Fonts"].Filename) + } + if m.Sources["Nerd Fonts"].URL != def.Sources["Nerd Fonts"].URL { + t.Errorf("Nerd Fonts URL = %q want default %q", m.Sources["Nerd Fonts"].URL, def.Sources["Nerd Fonts"].URL) + } changedAgain, err := mergeBuiltInSourcesFromDefaults(m) if err != nil { diff --git a/internal/installations/registry.go b/internal/installations/registry.go index cf1d576..fd17197 100644 --- a/internal/installations/registry.go +++ b/internal/installations/registry.go @@ -82,7 +82,7 @@ type Installation struct { } // Bump when the persisted JSON contract changes incompatibly. -const schemaVersion = "1.0" +const schemaVersion = "1.1" func normalizeFamilyGroups(in []FamilyGroup) []FamilyGroup { if len(in) == 0 { diff --git a/internal/installations/registry_migrate.go b/internal/installations/registry_migrate.go index 07be76a..cc09b2c 100644 --- a/internal/installations/registry_migrate.go +++ b/internal/installations/registry_migrate.go @@ -1,10 +1,15 @@ package installations import ( + _ "embed" + "encoding/json" "fmt" "strings" ) +//go:embed migrations/001-nerd-fonts-v1-to-v2.json +var nerdFontsV1ToV2JSON []byte + // registryMigrationStep advances schema_version by one hop. Steps are applied in order until // reg.SchemaVersion matches schemaVersion (see schemaVersion in registry.go). type registryMigrationStep struct { @@ -21,21 +26,13 @@ func init() { // buildRegistryMigrations defines every allowed schema_version transition for this binary. // When you bump schemaVersion, add a new switch case and chain older versions → newer (one hop per `from`). -// -// Example for bumping from "1.0" to "2.0": -// -// case "2.0": -// return []registryMigrationStep{ -// {from: "", to: "1.0"}, -// {from: "1", to: "1.0"}, -// {from: "1.0", to: "2.0", fn: migrateV1_0ToV2_0}, -// } func buildRegistryMigrations() []registryMigrationStep { switch schemaVersion { - case "1.0": + case "1.1": return []registryMigrationStep{ - {from: "", to: schemaVersion}, - {from: "1", to: schemaVersion}, + {from: "", to: "1.0"}, + {from: "1", to: "1.0"}, + {from: "1.0", to: "1.1", fn: migrateV1_0ToV1_1}, } default: panic(fmt.Sprintf("installations: schemaVersion %q has no migration definition — edit buildRegistryMigrations in registry_migrate.go", schemaVersion)) @@ -80,3 +77,50 @@ func applyRegistryMigrations(reg *Registry) (changed bool, err error) { func CurrentRegistrySchemaVersion() string { return schemaVersion } + +type fontIDRenameDoc struct { + Renames []fontIDRename `json:"renames"` +} + +type fontIDRename struct { + From string `json:"from"` + To string `json:"to"` + CatalogName string `json:"catalog_name"` +} + +func migrateV1_0ToV1_1(reg *Registry) error { + return applyFontIDRenames(reg, nerdFontsV1ToV2JSON) +} + +func applyFontIDRenames(reg *Registry, raw []byte) error { + var doc fontIDRenameDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("parse font id renames: %w", err) + } + if reg.Installations == nil { + reg.Installations = make(map[string]*Installation) + } + for _, r := range doc.Renames { + from := strings.ToLower(strings.TrimSpace(r.From)) + to := strings.ToLower(strings.TrimSpace(r.To)) + if from == "" || to == "" || from == to { + continue + } + inst, ok := reg.Installations[from] + if !ok { + continue + } + if _, exists := reg.Installations[to]; exists { + // Destination already tracked — drop the legacy key. + delete(reg.Installations, from) + continue + } + delete(reg.Installations, from) + inst.FontID = to + if name := strings.TrimSpace(r.CatalogName); name != "" { + inst.CatalogName = name + } + reg.Installations[to] = inst + } + return nil +} diff --git a/internal/installations/registry_migrate_test.go b/internal/installations/registry_migrate_test.go new file mode 100644 index 0000000..3d8a838 --- /dev/null +++ b/internal/installations/registry_migrate_test.go @@ -0,0 +1,125 @@ +package installations + +import ( + "os" + "path/filepath" + "testing" + "time" + + "fontget/internal/testutil" +) + +func TestMigrateV1_0ToV1_1_renamesNerdFontIDs(t *testing.T) { + reg := &Registry{ + SchemaVersion: "1.0", + Installations: map[string]*Installation{ + "nerd.cascadia-code": { + FontID: "nerd.cascadia-code", + CatalogName: "Cascadia Code", + Scope: "user", + InstalledAt: time.Now().UTC(), + }, + "nerd.jetbrains-mono": { + FontID: "nerd.jetbrains-mono", + CatalogName: "JetBrainsMono Nerd Font", + Scope: "user", + InstalledAt: time.Now().UTC(), + }, + }, + } + if err := migrateV1_0ToV1_1(reg); err != nil { + t.Fatal(err) + } + if _, ok := reg.Installations["nerd.cascadia-code"]; ok { + t.Fatal("legacy cascadia-code key should be gone") + } + inst := reg.Installations["nerd.caskaydia-cove"] + if inst == nil { + t.Fatal("missing nerd.caskaydia-cove") + } + if inst.FontID != "nerd.caskaydia-cove" { + t.Fatalf("FontID = %q", inst.FontID) + } + if inst.CatalogName != "CaskaydiaCove Nerd Font" { + t.Fatalf("CatalogName = %q", inst.CatalogName) + } + if reg.Installations["nerd.jetbrains-mono"] == nil { + t.Fatal("unchanged id should remain") + } +} + +func TestMigrateV1_0ToV1_1_destinationExistsDropsFrom(t *testing.T) { + reg := &Registry{ + SchemaVersion: "1.0", + Installations: map[string]*Installation{ + "nerd.cascadia-code": { + FontID: "nerd.cascadia-code", + CatalogName: "old", + Scope: "user", + }, + "nerd.caskaydia-cove": { + FontID: "nerd.caskaydia-cove", + CatalogName: "keep", + Scope: "user", + }, + }, + } + if err := migrateV1_0ToV1_1(reg); err != nil { + t.Fatal(err) + } + if _, ok := reg.Installations["nerd.cascadia-code"]; ok { + t.Fatal("from key should be dropped when to exists") + } + if got := reg.Installations["nerd.caskaydia-cove"].CatalogName; got != "keep" { + t.Fatalf("CatalogName = %q want keep", got) + } +} + +func TestLoad_appliesNerdIDRenamesAndPersists(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + dir := filepath.Join(home, ".fontget") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, FileName) + raw := []byte(`{ + "schema_version": "1.0", + "created": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "installations": { + "nerd.cascadia-code": { + "font_id": "nerd.cascadia-code", + "catalog_name": "Cascadia Code", + "scope": "user", + "installed_at": "2024-01-01T00:00:00Z", + "families": [] + } + } + }`) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + reg, err := Load() + if err != nil { + t.Fatal(err) + } + if reg.SchemaVersion != "1.1" { + t.Fatalf("schema = %q", reg.SchemaVersion) + } + if reg.FindByFontID("nerd.caskaydia-cove") == nil { + t.Fatal("expected renamed install") + } + if reg.FindByFontID("nerd.cascadia-code") != nil { + t.Fatal("legacy id should be gone") + } + + // Second load is idempotent. + reg2, err := Load() + if err != nil { + t.Fatal(err) + } + if reg2.FindByFontID("nerd.caskaydia-cove") == nil { + t.Fatal("rename should stick") + } +} diff --git a/internal/sources/urls.go b/internal/sources/urls.go index d5fc41c..a390088 100644 --- a/internal/sources/urls.go +++ b/internal/sources/urls.go @@ -11,8 +11,9 @@ const ( // GoogleFontsURL is the URL for Google Fonts source data GoogleFontsURL = BaseURL + "/google-fonts.json" - // NerdFontsURL is the URL for Nerd Fonts source data - NerdFontsURL = BaseURL + "/nerd-fonts.json" + // NerdFontsURL is the URL for Nerd Fonts source data (v2 catalog: patched names/ids). + // Legacy v1 remains at .../nerd-fonts.json for older FontGet binaries. + NerdFontsURL = BaseURL + "/nerd-fonts-v2.json" // LeagueOfMoveableTypeURL is the URL for The League of Moveable Type source data LeagueOfMoveableTypeURL = BaseURL + "/league-of-moveable-type.json" @@ -44,7 +45,7 @@ func DefaultSources() map[string]SourceInfo { URL: NerdFontsURL, Prefix: "nerd", Enabled: true, - Filename: "nerd-fonts.json", + Filename: "nerd-fonts-v2.json", Priority: 2, }, "The League of Moveable Type": { diff --git a/internal/sources/urls_test.go b/internal/sources/urls_test.go new file mode 100644 index 0000000..91c74dd --- /dev/null +++ b/internal/sources/urls_test.go @@ -0,0 +1,19 @@ +package sources + +import ( + "strings" + "testing" +) + +func TestDefaultSources_nerdFontsV2(t *testing.T) { + if !strings.HasSuffix(NerdFontsURL, "/nerd-fonts-v2.json") { + t.Fatalf("NerdFontsURL = %q want …/nerd-fonts-v2.json", NerdFontsURL) + } + nerd := DefaultSources()["Nerd Fonts"] + if nerd.Filename != "nerd-fonts-v2.json" { + t.Fatalf("Filename = %q", nerd.Filename) + } + if nerd.URL != NerdFontsURL { + t.Fatalf("URL = %q want %q", nerd.URL, NerdFontsURL) + } +} From 5769958789913e49bcb7b591ec9c4bd4d227c804 Mon Sep 17 00:00:00 2001 From: graphixa Date: Wed, 16 Sep 2026 09:35:53 +1000 Subject: [PATCH 14/23] Improve font removal logic to better classify Nerd Fonts via Font ID check and family classification. --- cmd/remove.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/cmd/remove.go b/cmd/remove.go index 07b3150..d556a3d 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -342,6 +342,8 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, } } + isFontID := strings.Contains(strings.TrimSpace(fontName), ".") || (manifestProbe != nil && manifestProbe(fontName)) + // Resolve Font ID to font name if needed (supports both Font IDs and font names) searchName := resolveFontNameOrID(fontName, repository) if searchName != fontName { @@ -372,6 +374,14 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, } } + // Font IDs must not delete another source's faces after base-name stripping + // (e.g. nerd.iosevka must not remove plain Fontsource Iosevka). + if isFontID && len(matchingFonts) > 0 { + fontDir := fontManager.GetFontDir(scope) + matchingFonts = filterBasenamesForFontID(matchingFonts, fontDir, fontName) + output.GetDebug().State("After Font ID SFNT filter: %d file(s) for %q", len(matchingFonts), fontName) + } + if len(matchingFonts) == 0 { return nil, false, fmt.Errorf("font not found: %s", fontName) } @@ -379,6 +389,52 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, return matchingFonts, false, nil } +func isNerdFontID(fontID string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(fontID)), "nerd.") +} + +func sfntLooksLikeNerdFont(family string) bool { + return strings.Contains(strings.ToLower(family), "nerd") +} + +// sfntFamilyAllowedForFontID rejects obvious cross-source collisions before catalog matching. +// Nerd IDs require a Nerd-looking SFNT family; non-Nerd IDs must not take Nerd faces. +func sfntFamilyAllowedForFontID(family, targetFontID string) bool { + nerdID := isNerdFontID(targetFontID) + nerdFace := sfntLooksLikeNerdFont(family) + if nerdID && !nerdFace { + return false + } + if !nerdID && nerdFace { + return false + } + return true +} + +// filterBasenamesForFontID keeps only faces whose SFNT family classifies as targetFontID. +func filterBasenamesForFontID(basenames []string, fontDir, targetFontID string) []string { + index, indexErr := repo.BuildFontIndexForMatching() + if indexErr != nil { + output.GetDebug().State("Font ID filter: index unavailable (%v); using Nerd/non-Nerd SFNT gate only", indexErr) + index = nil + } + out := make([]string, 0, len(basenames)) + for _, base := range basenames { + path := filepath.Join(fontDir, base) + family := extractFontFamilyNameFromPath(path) + if !sfntFamilyAllowedForFontID(family, targetFontID) { + output.GetDebug().State("Font ID filter: skip %q family %q (source class mismatch for %q)", base, family, targetFontID) + continue + } + if index != nil && !checkFontMatchesFontID(family, targetFontID, index) { + output.GetDebug().State("Font ID filter: skip %q family %q (catalog match ≠ %q)", base, family, targetFontID) + continue + } + out = append(out, base) + } + return out +} + // RemoveFontFilesParams contains parameters for removeFontFiles function type RemoveFontFilesParams struct { Ctx context.Context @@ -605,10 +661,8 @@ func removeFont( fontID := "" if usedRegistry { fontID = fontName - } else if installReg != nil { - if inst := installReg.FindByFontID(fontName); inst != nil { - fontID = fontName - } + } else if installations.ShouldConsultRegistryForRemoval(fontName, installReg, manifestProbe) { + fontID = fontName } if onProgress != nil { onProgress(ProgressUpdate{Phase: removeStepScan, Kind: ProgressFlag, Done: 1, Total: 1}) @@ -646,6 +700,9 @@ type FontInfo struct { // This is more accurate than checking metadata strings and works for all Font ID variants. // Returns true if the font's Font ID matches the target Font ID. func checkFontMatchesFontID(fontFamilyName string, targetFontID string, fontIndex repo.FontIndex) bool { + if !sfntFamilyAllowedForFontID(fontFamilyName, targetFontID) { + return false + } return repo.MatchFontFamilyToFontID(fontFamilyName, targetFontID, fontIndex) } From 58a7cffed7b35a1396fb97fd15b9e43a13b9bee4 Mon Sep 17 00:00:00 2001 From: graphixa Date: Wed, 16 Sep 2026 11:01:03 +1000 Subject: [PATCH 15/23] Fix font locking bug during operations --- cmd/remove.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/remove.go b/cmd/remove.go index d556a3d..3e74ef2 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -449,16 +449,12 @@ type RemoveFontFilesParams struct { // removeFontFiles removes font files one at a time (unregister + delete + track). // Cancellation finishes the current file, then stops before starting another. +// Caller must hold installations.LockDestination for params.FontDir when mutating a real font directory. func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int, details []string, errors []string, err error) { ctx := params.Ctx if ctx == nil { ctx = context.Background() } - unlock, lockErr := installations.LockDestination(ctx, params.FontDir) - if lockErr != nil { - return 0, 0, len(params.MatchingFonts), nil, []string{lockErr.Error()}, lockErr - } - defer unlock() opts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} total := len(params.MatchingFonts) @@ -668,6 +664,15 @@ func removeFont( onProgress(ProgressUpdate{Phase: removeStepScan, Kind: ProgressFlag, Done: 1, Total: 1}) } + unlock, lockErr := installations.LockDestination(ctx, fontDir) + if lockErr != nil { + result := buildRemoveResult(0, 0, 0, nil, []string{lockErr.Error()}) + result.Status = StatusFailed + result.Message = "Failed to lock destination" + return result, lockErr + } + defer unlock() + // Remove font files removed, skipped, failed, details, errors, remErr := removeFontFiles(RemoveFontFilesParams{ Ctx: ctx, From 4dc6bc171dfae7ebcb03fd1326241027cacb430c Mon Sep 17 00:00:00 2001 From: graphixa Date: Thu, 17 Sep 2026 00:04:21 +1000 Subject: [PATCH 16/23] Clean up registry migration code and remove command logic --- .gitignore | 1 + cmd/remove.go | 32 ++--- cmd/remove_fontid_filter_test.go | 31 +++++ .../migrations/001-nerd-fonts-v1-to-v2.json | 128 ++++++++++++++++++ internal/installations/registry_migrate.go | 27 ++-- .../installations/registry_migrate_test.go | 4 +- 6 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 cmd/remove_fontid_filter_test.go create mode 100644 internal/installations/migrations/001-nerd-fonts-v1-to-v2.json diff --git a/.gitignore b/.gitignore index 514199f..83d60ff 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ fontget.exe *.json !.gosec.json !build/winres.json +!internal/installations/migrations/*.json *exe.old *exe~ diff --git a/cmd/remove.go b/cmd/remove.go index 3e74ef2..00a2786 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -342,15 +342,12 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, } } - isFontID := strings.Contains(strings.TrimSpace(fontName), ".") || (manifestProbe != nil && manifestProbe(fontName)) - // Resolve Font ID to font name if needed (supports both Font IDs and font names) searchName := resolveFontNameOrID(fontName, repository) if searchName != fontName { output.GetDebug().State("Resolved font name: %s -> %s", fontName, searchName) } - // Find font files in the specified scope output.GetDebug().State("Calling findFontFamilyFiles(%s, %s)", searchName, scope) matchingFonts, walkErr := findFontFamilyFiles(searchName, fontManager, scope) if walkErr != nil { @@ -358,7 +355,6 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, } output.GetDebug().State("Found %d matching font file(s)", len(matchingFonts)) - // If no direct matches, try repository search if len(matchingFonts) == 0 && repository != nil { output.GetDebug().State("No direct matches found, trying repository search for: %s", searchName) results, err := repository.SearchFonts(searchName, "false") @@ -376,7 +372,7 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, // Font IDs must not delete another source's faces after base-name stripping // (e.g. nerd.iosevka must not remove plain Fontsource Iosevka). - if isFontID && len(matchingFonts) > 0 { + if installations.ShouldConsultRegistryForRemoval(fontName, installReg, manifestProbe) && len(matchingFonts) > 0 { fontDir := fontManager.GetFontDir(scope) matchingFonts = filterBasenamesForFontID(matchingFonts, fontDir, fontName) output.GetDebug().State("After Font ID SFNT filter: %d file(s) for %q", len(matchingFonts), fontName) @@ -398,17 +394,8 @@ func sfntLooksLikeNerdFont(family string) bool { } // sfntFamilyAllowedForFontID rejects obvious cross-source collisions before catalog matching. -// Nerd IDs require a Nerd-looking SFNT family; non-Nerd IDs must not take Nerd faces. func sfntFamilyAllowedForFontID(family, targetFontID string) bool { - nerdID := isNerdFontID(targetFontID) - nerdFace := sfntLooksLikeNerdFont(family) - if nerdID && !nerdFace { - return false - } - if !nerdID && nerdFace { - return false - } - return true + return isNerdFontID(targetFontID) == sfntLooksLikeNerdFont(family) } // filterBasenamesForFontID keeps only faces whose SFNT family classifies as targetFontID. @@ -420,14 +407,15 @@ func filterBasenamesForFontID(basenames []string, fontDir, targetFontID string) } out := make([]string, 0, len(basenames)) for _, base := range basenames { - path := filepath.Join(fontDir, base) - family := extractFontFamilyNameFromPath(path) - if !sfntFamilyAllowedForFontID(family, targetFontID) { - output.GetDebug().State("Font ID filter: skip %q family %q (source class mismatch for %q)", base, family, targetFontID) - continue + family := extractFontFamilyNameFromPath(filepath.Join(fontDir, base)) + ok := false + if index == nil { + ok = sfntFamilyAllowedForFontID(family, targetFontID) + } else { + ok = checkFontMatchesFontID(family, targetFontID, index) } - if index != nil && !checkFontMatchesFontID(family, targetFontID, index) { - output.GetDebug().State("Font ID filter: skip %q family %q (catalog match ≠ %q)", base, family, targetFontID) + if !ok { + output.GetDebug().State("Font ID filter: skip %q family %q for %q", base, family, targetFontID) continue } out = append(out, base) diff --git a/cmd/remove_fontid_filter_test.go b/cmd/remove_fontid_filter_test.go new file mode 100644 index 0000000..19992c9 --- /dev/null +++ b/cmd/remove_fontid_filter_test.go @@ -0,0 +1,31 @@ +package cmd + +import "testing" + +func TestSfntFamilyAllowedForFontID(t *testing.T) { + cases := []struct { + family string + id string + want bool + }{ + {"Iosevka", "nerd.iosevka", false}, + {"Iosevka Nerd Font", "nerd.iosevka", true}, + {"Iosevka", "fontsource.iosevka", true}, + {"Iosevka Nerd Font", "fontsource.iosevka", false}, + {"Roboto", "google.roboto", true}, + } + for _, tc := range cases { + if got := sfntFamilyAllowedForFontID(tc.family, tc.id); got != tc.want { + t.Fatalf("sfntFamilyAllowedForFontID(%q, %q)=%v want %v", tc.family, tc.id, got, tc.want) + } + } +} + +func TestCheckFontMatchesFontID_nerdGate(t *testing.T) { + if checkFontMatchesFontID("Iosevka", "nerd.iosevka", nil) { + t.Fatal("plain Iosevka must not match nerd.iosevka") + } + if checkFontMatchesFontID("Iosevka Nerd Font", "fontsource.iosevka", nil) { + t.Fatal("nerd face must not match fontsource id") + } +} diff --git a/internal/installations/migrations/001-nerd-fonts-v1-to-v2.json b/internal/installations/migrations/001-nerd-fonts-v1-to-v2.json new file mode 100644 index 0000000..9cfa187 --- /dev/null +++ b/internal/installations/migrations/001-nerd-fonts-v1-to-v2.json @@ -0,0 +1,128 @@ +{ + "schema_version": "1", + "source": "Nerd Fonts", + "nerd_fonts_release": "v3.5.1", + "generated_at": "2026-09-15T15:18:39.602Z", + "renames": [ + { + "from": "nerd.annotation-mono", + "to": "nerd.annotationmono", + "catalog_name": "AnnotationM NF" + }, + { + "from": "nerd.anonymous-pro", + "to": "nerd.anonymice", + "catalog_name": "AnonymicePro Nerd Font" + }, + { + "from": "nerd.atkinson-hyperlegible-mono", + "to": "nerd.atkynson-mono", + "catalog_name": "AtkynsonMono NF" + }, + { + "from": "nerd.big-blue-terminal", + "to": "nerd.bigblue-terminal", + "catalog_name": "BigBlueTermPlus Nerd Font" + }, + { + "from": "nerd.cascadia-code", + "to": "nerd.caskaydia-cove", + "catalog_name": "CaskaydiaCove Nerd Font" + }, + { + "from": "nerd.cascadia-mono", + "to": "nerd.caskaydia-mono", + "catalog_name": "CaskaydiaMono NF" + }, + { + "from": "nerd.daddytime-mono", + "to": "nerd.daddy-time-mono", + "catalog_name": "DaddyTimeMono Nerd Font" + }, + { + "from": "nerd.gohu", + "to": "nerd.gohufont", + "catalog_name": "GohuFont 14 Nerd Font" + }, + { + "from": "nerd.google-sans-code", + "to": "nerd.googlesanscode", + "catalog_name": "GoogleSansCode NF" + }, + { + "from": "nerd.hasklig", + "to": "nerd.hasklug", + "catalog_name": "Hasklug Nerd Font" + }, + { + "from": "nerd.heavydata", + "to": "nerd.heavy-data", + "catalog_name": "HeavyData Nerd Font" + }, + { + "from": "nerd.hermit", + "to": "nerd.hurmit", + "catalog_name": "Hurmit Nerd Font" + }, + { + "from": "nerd.ia-writer", + "to": "nerd.im-writing", + "catalog_name": "iMWritingMono Nerd Font" + }, + { + "from": "nerd.ibm-plex-mono", + "to": "nerd.blex-mono", + "catalog_name": "BlexMono Nerd Font" + }, + { + "from": "nerd.intel-one-mono", + "to": "nerd.intone-mono", + "catalog_name": "IntoneMono Nerd Font" + }, + { + "from": "nerd.liberation-mono", + "to": "nerd.liberation", + "catalog_name": "LiterationMono Nerd Font" + }, + { + "from": "nerd.m-plus", + "to": "nerd.m", + "catalog_name": "M+1Code Nerd Font" + }, + { + "from": "nerd.meslo", + "to": "nerd.meslo-lg", + "catalog_name": "MesloLGM Nerd Font" + }, + { + "from": "nerd.monaspace", + "to": "nerd.monaspice", + "catalog_name": "MonaspiceNe NF" + }, + { + "from": "nerd.proggyclean", + "to": "nerd.proggy-clean-tt", + "catalog_name": "ProggyClean Nerd Font" + }, + { + "from": "nerd.recursive", + "to": "nerd.recursive-mono", + "catalog_name": "RecMonoSmCasual Nerd Font" + }, + { + "from": "nerd.share-tech-mono", + "to": "nerd.shure-tech-mono", + "catalog_name": "ShureTechMono Nerd Font" + }, + { + "from": "nerd.source-code-pro", + "to": "nerd.sauce-code-pro", + "catalog_name": "SauceCodePro Nerd Font" + }, + { + "from": "nerd.terminus", + "to": "nerd.terminess-ttf", + "catalog_name": "Terminess Nerd Font" + } + ] +} \ No newline at end of file diff --git a/internal/installations/registry_migrate.go b/internal/installations/registry_migrate.go index cc09b2c..d88526a 100644 --- a/internal/installations/registry_migrate.go +++ b/internal/installations/registry_migrate.go @@ -32,7 +32,9 @@ func buildRegistryMigrations() []registryMigrationStep { return []registryMigrationStep{ {from: "", to: "1.0"}, {from: "1", to: "1.0"}, - {from: "1.0", to: "1.1", fn: migrateV1_0ToV1_1}, + {from: "1.0", to: "1.1", fn: func(reg *Registry) error { + return applyFontIDRenames(reg, nerdFontsV1ToV2JSON) + }}, } default: panic(fmt.Sprintf("installations: schemaVersion %q has no migration definition — edit buildRegistryMigrations in registry_migrate.go", schemaVersion)) @@ -78,22 +80,14 @@ func CurrentRegistrySchemaVersion() string { return schemaVersion } -type fontIDRenameDoc struct { - Renames []fontIDRename `json:"renames"` -} - -type fontIDRename struct { - From string `json:"from"` - To string `json:"to"` - CatalogName string `json:"catalog_name"` -} - -func migrateV1_0ToV1_1(reg *Registry) error { - return applyFontIDRenames(reg, nerdFontsV1ToV2JSON) -} - func applyFontIDRenames(reg *Registry, raw []byte) error { - var doc fontIDRenameDoc + var doc struct { + Renames []struct { + From string `json:"from"` + To string `json:"to"` + CatalogName string `json:"catalog_name"` + } `json:"renames"` + } if err := json.Unmarshal(raw, &doc); err != nil { return fmt.Errorf("parse font id renames: %w", err) } @@ -111,7 +105,6 @@ func applyFontIDRenames(reg *Registry, raw []byte) error { continue } if _, exists := reg.Installations[to]; exists { - // Destination already tracked — drop the legacy key. delete(reg.Installations, from) continue } diff --git a/internal/installations/registry_migrate_test.go b/internal/installations/registry_migrate_test.go index 3d8a838..6ae7f5a 100644 --- a/internal/installations/registry_migrate_test.go +++ b/internal/installations/registry_migrate_test.go @@ -27,7 +27,7 @@ func TestMigrateV1_0ToV1_1_renamesNerdFontIDs(t *testing.T) { }, }, } - if err := migrateV1_0ToV1_1(reg); err != nil { + if err := applyFontIDRenames(reg, nerdFontsV1ToV2JSON); err != nil { t.Fatal(err) } if _, ok := reg.Installations["nerd.cascadia-code"]; ok { @@ -64,7 +64,7 @@ func TestMigrateV1_0ToV1_1_destinationExistsDropsFrom(t *testing.T) { }, }, } - if err := migrateV1_0ToV1_1(reg); err != nil { + if err := applyFontIDRenames(reg, nerdFontsV1ToV2JSON); err != nil { t.Fatal(err) } if _, ok := reg.Installations["nerd.cascadia-code"]; ok { From 976e6adcad8ec97d8d9959409c600c2b8f7b41ad Mon Sep 17 00:00:00 2001 From: graphixa Date: Thu, 17 Sep 2026 00:49:52 +1000 Subject: [PATCH 17/23] Improved font installation and removal processes by adding reconciliation for tracked files and better cancellation handling. Also Fixed error reporting during removal operations. --- cmd/add.go | 14 +- cmd/cancel_contract_test.go | 225 ++++++++++++++++++++++++++++ cmd/install_tracking.go | 29 ++++ cmd/remove.go | 36 +++-- internal/platform/machine_remove.go | 127 ++++++++++++++++ internal/platform/windows.go | 171 +++++++++++++-------- internal/platform/windows_utils.go | 3 + 7 files changed, 523 insertions(+), 82 deletions(-) create mode 100644 internal/platform/machine_remove.go diff --git a/cmd/add.go b/cmd/add.go index 833527e..b11d9cb 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1061,6 +1061,14 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager return 0, 0, len(fontPaths), nil, []string{collErr.Error()}, 0, nil, collErr } + if track != nil { + retained, recErr := reconcileTrackedPresent(track.fontID, fontDir) + if recErr != nil { + return 0, 0, 0, nil, []string{recErr.Error()}, 0, nil, recErr + } + present = retained + } + batchOpts := &platform.InstallFontOptions{SkipPostInstallCacheRefresh: true} total := len(fontPaths) @@ -1215,10 +1223,6 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager }) } - if err == nil && ctx.Err() != nil { - err = ctx.Err() - } - if installed > 0 || (err != nil && len(present) > 0) { if onProgress != nil { onProgress(ProgressUpdate{Phase: installStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) @@ -1355,7 +1359,7 @@ func installFont( } track := newInstallTracker(fontID, fontFiles, installScope, fontDir, expected) - if force { + if force { existing := packageBasenamesFromRegistry(fontID, fontDir) if len(existing) > 0 { if onProgress != nil { diff --git a/cmd/cancel_contract_test.go b/cmd/cancel_contract_test.go index 8ea8b04..6725b77 100644 --- a/cmd/cancel_contract_test.go +++ b/cmd/cancel_contract_test.go @@ -288,3 +288,228 @@ func TestTrackingFailureStopsBeforeNextFile(t *testing.T) { t.Fatal("must not install next file after tracking failure") } } + +func TestInstallRetryPreservesRetainedInventory(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + + write := func(name, fam, style string) string { + p := filepath.Join(staging.Root, name) + if err := os.WriteFile(p, testutil.MinimalTTF(fam, style), 0644); err != nil { + t.Fatal(err) + } + return p + } + pathA := write("Alpha-Regular.ttf", "Alpha", "Regular") + pathB := write("Beta-Regular.ttf", "Beta", "Regular") + pathC := write("Gamma-Regular.ttf", "Gamma", "Regular") + expected := []string{"Alpha-Regular.ttf", "Beta-Regular.ttf", "Gamma-Regular.ttf"} + + ctx1, cancel1 := context.WithCancel(context.Background()) + fm1 := &cancelAfterInstallFM{copyFontManager: ©FontManager{dir: fontDir}, cancel: cancel1} + track1 := newInstallTracker("test.retry", nil, platform.UserScope, fontDir, expected) + _, _, _, _, _, _, _, err = installDownloadedFonts(ctx1, []string{pathA, pathB, pathC}, fm1, platform.UserScope, fontDir, false, nil, nil, track1) + if err == nil { + t.Fatal("expected cancel after A") + } + + // Restage B/C (A already installed; staging copies are consumed). + pathB = write("Beta-Regular.ttf", "Beta", "Regular") + pathC = write("Gamma-Regular.ttf", "Gamma", "Regular") + pathA2 := write("Alpha-Regular.ttf", "Alpha", "Regular") + + ctx2, cancel2 := context.WithCancel(context.Background()) + fm2 := &cancelAfterInstallFM{copyFontManager: ©FontManager{dir: fontDir}, cancel: cancel2} + track2 := newInstallTracker("test.retry", nil, platform.UserScope, fontDir, expected) + _, skipped, _, _, _, _, _, err := installDownloadedFonts(ctx2, []string{pathA2, pathB, pathC}, fm2, platform.UserScope, fontDir, false, nil, nil, track2) + if err == nil { + t.Fatal("expected cancel after B") + } + if skipped != 1 { + t.Fatalf("expected skip A, got skipped=%d", skipped) + } + + reg, loadErr := installations.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + inst := reg.FindByFontID("test.retry") + if inst == nil || !inst.IsIncomplete() { + t.Fatalf("expected incomplete record: %+v", inst) + } + bases := inst.BasenamesForDir(fontDir) + have := map[string]bool{} + for _, b := range bases { + have[strings.ToLower(b)] = true + } + if !have["alpha-regular.ttf"] || !have["beta-regular.ttf"] { + t.Fatalf("A and B must remain tracked, got %#v", bases) + } + if have["gamma-regular.ttf"] { + t.Fatal("C must not be tracked yet") + } + if len(inst.Remaining) != 1 || !strings.EqualFold(inst.Remaining[0], "Gamma-Regular.ttf") { + t.Fatalf("C must remain outstanding: %#v", inst.Remaining) + } + + removed, _, _, _, _, remErr := removeFontFiles(RemoveFontFilesParams{ + Ctx: context.Background(), + MatchingFonts: bases, + FontManager: &removeTrackingFM{dir: fontDir}, + Scope: platform.UserScope, + FontDir: fontDir, + FontID: "test.retry", + }) + if remErr != nil { + t.Fatal(remErr) + } + if removed != 2 { + t.Fatalf("removal must remove A and B, got %d", removed) + } + reg, _ = installations.Load() + if reg.FindByFontID("test.retry") != nil { + t.Fatal("package record must be cleared after full removal") + } +} + +func TestInstallRetryReconcilesExternallyDeletedTrackedFile(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + a := filepath.Join(fontDir, "Alpha-Regular.ttf") + b := filepath.Join(fontDir, "Beta-Regular.ttf") + if err := os.WriteFile(a, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(b, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.UpsertInstallation(installations.UpsertParams{ + FontID: "test.ext", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: a, SFNT: installations.SFNTSnapshot{Family: "Alpha", Style: "Regular"}}, + {Path: b, SFNT: installations.SFNTSnapshot{Family: "Beta", Style: "Regular"}}, + }, + Status: installations.StatusIncompleteInstall, + Remaining: []string{"Gamma-Regular.ttf"}, + }); err != nil { + t.Fatal(err) + } + if err := os.Remove(a); err != nil { + t.Fatal(err) + } + + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + pathA := filepath.Join(staging.Root, "Alpha-Regular.ttf") + pathB := filepath.Join(staging.Root, "Beta-Regular.ttf") + pathC := filepath.Join(staging.Root, "Gamma-Regular.ttf") + for _, tc := range []struct { + path, fam, style string + }{ + {pathA, "Alpha", "Regular"}, + {pathB, "Beta", "Regular"}, + {pathC, "Gamma", "Regular"}, + } { + if err := os.WriteFile(tc.path, testutil.MinimalTTF(tc.fam, tc.style), 0644); err != nil { + t.Fatal(err) + } + } + + fm := ©FontManager{dir: fontDir} + track := newInstallTracker("test.ext", nil, platform.UserScope, fontDir, []string{"Alpha-Regular.ttf", "Beta-Regular.ttf", "Gamma-Regular.ttf"}) + installed, skipped, failed, _, _, _, _, err := installDownloadedFonts(context.Background(), []string{pathA, pathB, pathC}, fm, platform.UserScope, fontDir, false, nil, nil, track) + if err != nil { + t.Fatal(err) + } + if failed != 0 || installed != 2 || skipped != 1 { + t.Fatalf("want reinstall A + skip B + install C; installed=%d skipped=%d failed=%d", installed, skipped, failed) + } + reg, loadErr := installations.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + inst := reg.FindByFontID("test.ext") + if inst == nil || inst.IsIncomplete() { + t.Fatalf("expected complete install after retry: %+v", inst) + } + if len(inst.BasenamesForDir(fontDir)) != 3 { + t.Fatalf("expected A,B,C tracked: %#v", inst.BasenamesForDir(fontDir)) + } +} + +func TestInstallCancelOnFinalFileStillSucceeds(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + fm := &cancelAfterInstallFM{copyFontManager: ©FontManager{dir: fontDir}, cancel: cancel} + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + pathA := filepath.Join(staging.Root, "Only-Regular.ttf") + if err := os.WriteFile(pathA, testutil.MinimalTTF("Only", "Regular"), 0644); err != nil { + t.Fatal(err) + } + track := newInstallTracker("test.last", nil, platform.UserScope, fontDir, []string{"Only-Regular.ttf"}) + installed, _, _, _, _, _, _, err := installDownloadedFonts(ctx, []string{pathA}, fm, platform.UserScope, fontDir, false, nil, nil, track) + if err != nil { + t.Fatalf("late cancel after final file must not fail: %v", err) + } + if installed != 1 { + t.Fatalf("installed=%d", installed) + } + reg, _ := installations.Load() + inst := reg.FindByFontID("test.last") + if inst == nil || inst.IsIncomplete() { + t.Fatalf("expected complete record: %+v", inst) + } +} + +func TestRemoveCancelOnFinalFileStillSucceeds(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + a := filepath.Join(fontDir, "Only-Regular.ttf") + if err := os.WriteFile(a, testutil.MinimalTTF("Only", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "test.rmlast", + Scope: "user", + Files: []installations.InstalledFontFile{{Path: a, SFNT: installations.SFNTSnapshot{Family: "Only", Style: "Regular"}}}, + }); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + fm := &removeCancelFM{ + removeTrackingFM: &removeTrackingFM{dir: fontDir}, + after: cancel, + } + removed, _, _, _, _, err := removeFontFiles(RemoveFontFilesParams{ + Ctx: ctx, + MatchingFonts: []string{"Only-Regular.ttf"}, + FontManager: fm, + Scope: platform.UserScope, + FontDir: fontDir, + FontID: "test.rmlast", + }) + if err != nil { + t.Fatalf("late cancel after final file must not fail: %v", err) + } + if removed != 1 { + t.Fatalf("removed=%d", removed) + } +} diff --git a/cmd/install_tracking.go b/cmd/install_tracking.go index 31c2824..ff8699f 100644 --- a/cmd/install_tracking.go +++ b/cmd/install_tracking.go @@ -232,3 +232,32 @@ func packageBasenamesFromRegistry(fontID, fontDir string) []string { } return dedupeBasenameList(out) } + +// reconcileTrackedPresent returns tracked Files under fontDir that still exist. +// Confirmed missing files are dropped. Unexpected stat/read errors are returned. +func reconcileTrackedPresent(fontID, fontDir string) ([]string, error) { + fontID = strings.TrimSpace(fontID) + if fontID == "" { + return nil, nil + } + reg, err := installations.Load() + if err != nil { + return nil, err + } + inst := reg.FindByFontID(fontID) + if inst == nil { + return nil, nil + } + var present []string + for _, base := range inst.BasenamesForDir(fontDir) { + full := filepath.Join(fontDir, base) + if _, err := os.Stat(full); err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("reconcile tracked inventory: %s: %w", base, err) + } + present = append(present, base) + } + return dedupeBasenameList(present), nil +} diff --git a/cmd/remove.go b/cmd/remove.go index 00a2786..ee66142 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -328,6 +328,19 @@ func updateRemovalStatus(status *RemovalStatus, result *RemoveResult) { status.Failed += result.Failed } +// removalExitAfterSummary returns a non-nil AlreadyPrinted error when any requested +// removal failed or fonts were not found. Summary output is assumed already shown. +func removalExitAfterSummary(failedCount, totalCount, notFoundCount int) error { + failedCount += notFoundCount + if failedCount <= 0 { + return nil + } + return shared.AlreadyPrinted(&shared.FontRemovalError{ + FailedCount: failedCount, + TotalCount: totalCount, + }) +} + // findFontFilesForRemoval finds all font files matching the font name. // The bool is true when paths came from the installation registry (exact FontGet provenance). func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, scope platform.InstallationScope, repository *repo.Repository, installReg *installations.Registry, manifestProbe installations.ManifestFontIDProbe) ([]string, bool, error) { @@ -677,9 +690,6 @@ func removeFont( if remErr != nil { return res, remErr } - if err := ctx.Err(); err != nil { - return res, err - } return res, nil } @@ -1230,7 +1240,7 @@ Use --scope to set removal location: if len(scopes) == 1 && scopes[0] == platform.MachineScope { if err := cmdutils.CheckElevation(cmd, fontManager, platform.MachineScope); err != nil { if errors.Is(err, cmdutils.ErrElevationRequired) { - return nil // Already printed user-friendly message + return shared.AlreadyPrinted(err) } output.GetVerbose().Error("%v", err) output.GetDebug().Error("checkElevation() failed: %v", err) @@ -1288,7 +1298,7 @@ Use --scope to set removal location: } } fmt.Println() - return nil // Already printed user-friendly message + return shared.AlreadyPrinted(fmt.Errorf("cannot remove protected system fonts")) } foundFonts = removableFonts @@ -1762,7 +1772,7 @@ Use --scope to set removal location: }, true) } } - return nil + return shared.AlreadyPrinted(&shared.FontNotFoundError{FontName: strings.Join(notFoundFonts, ", ")}) } // For --all scope, require elevation upfront @@ -1770,7 +1780,7 @@ Use --scope to set removal location: // Check elevation first if err := cmdutils.CheckElevation(cmd, fontManager, platform.MachineScope); err != nil { if errors.Is(err, cmdutils.ErrElevationRequired) { - return nil // Already printed user-friendly message + return shared.AlreadyPrinted(err) } output.GetVerbose().Error("%v", err) output.GetDebug().Error("checkElevation() failed for --scope all: %v", err) @@ -1825,6 +1835,10 @@ Use --scope to set removal location: output.GetDebug().State("Error removing font %s in %s: %v", fontInfo.SearchName, scopeLabelName, err) if result != nil { updateRemovalStatus(status, result) + // Tracking/lock failures may leave Failed==0 after a successful delete. + if result.Failed == 0 { + status.Failed++ + } // Show failed variants if available _, _, failedFiles := processRemoveResult(result) if len(failedFiles) > 0 { @@ -1833,6 +1847,8 @@ Use --scope to set removal location: output.GetDebug().State(" - %s", file) } } + } else { + status.Failed++ } continue } @@ -1860,7 +1876,7 @@ Use --scope to set removal location: GetLogger().Info("Removal complete - Removed: %d, Skipped: %d, Failed: %d", status.Removed, status.Skipped, status.Failed) - return nil + return removalExitAfterSummary(status.Failed, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) } // Determine title based on scope @@ -2844,9 +2860,7 @@ Use --scope to set removal location: FailedLabel: "Failed", }, output.IsVerboseOutputEnabled()) - // Don't return error for removal failures since we already show detailed status report - // This prevents duplicate error messages while maintaining proper exit codes - return nil + return removalExitAfterSummary(status.Failed, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) }, } diff --git a/internal/platform/machine_remove.go b/internal/platform/machine_remove.go new file mode 100644 index 0000000..6215ff7 --- /dev/null +++ b/internal/platform/machine_remove.go @@ -0,0 +1,127 @@ +package platform + +import ( + "errors" + "fmt" + "os" + "strings" + "time" +) + +// ErrRegistryValueAbsent means the Fonts registry value was already gone (idempotent OK). +var ErrRegistryValueAbsent = errors.New("registry font value absent") + +// registryFontValue is a captured machine-scope Fonts registry entry. +type registryFontValue struct { + Name string + Data string + Type uint32 + Found bool +} + +// machineRemoveOps are narrow seams for machine-scope removal (tests inject fakes). +type machineRemoveOps struct { + RemoveGDI func(path string) error + AddGDI func(path string) error + CaptureReg func(fontName string) (registryFontValue, error) + DeleteReg func(fontName string) error + RestoreReg func(v registryFontValue) error + RemoveFile func(path string) error + NotifyChange func() error + UnregisterOnly bool + SkipNotify bool +} + +func isBenignGDIRemoveErr(err error) bool { + if err == nil { + return true + } + s := err.Error() + return strings.Contains(s, "error code: 0") || strings.Contains(s, "The operation completed successfully") +} + +func joinRemoveRepairErr(primary error, repair error) error { + if primary == nil { + return repair + } + if repair == nil { + return primary + } + return fmt.Errorf("%w (also failed to restore registration: %v)", primary, repair) +} + +// removeMachineScopedFont removes GDI + registry then the file, restoring prior +// registration when deletion fails. Registry removal failure stops before delete. +func removeMachineScopedFont(fontName, fontPath string, ops machineRemoveOps) error { + gdiRemoved := false + if err := ops.RemoveGDI(fontPath); err != nil { + if !isBenignGDIRemoveErr(err) { + return fmt.Errorf("failed to remove font resource: %w", err) + } + } else { + gdiRemoved = true + } + + prior, capErr := ops.CaptureReg(fontName) + if capErr != nil { + var repair error + if gdiRemoved { + repair = ops.AddGDI(fontPath) + } + return joinRemoveRepairErr(fmt.Errorf("failed to capture font registry state: %w", capErr), repair) + } + + if prior.Found { + if err := ops.DeleteReg(fontName); err != nil && !errors.Is(err, ErrRegistryValueAbsent) { + var repair error + if gdiRemoved { + repair = ops.AddGDI(fontPath) + } + return joinRemoveRepairErr(fmt.Errorf("failed to remove font from registry: %w", err), repair) + } + } + + if ops.UnregisterOnly { + return nil + } + + if ops.NotifyChange != nil { + _ = ops.NotifyChange() + } + + var removeErr error + for attempt := 0; attempt < 4; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt*40) * time.Millisecond) + if ops.NotifyChange != nil { + _ = ops.NotifyChange() + } + } + removeErr = ops.RemoveFile(fontPath) + if removeErr == nil || os.IsNotExist(removeErr) { + removeErr = nil + break + } + } + if removeErr != nil { + var repair error + if prior.Found { + if rerr := ops.RestoreReg(prior); rerr != nil { + repair = rerr + } + } + if gdiRemoved { + if aerr := ops.AddGDI(fontPath); aerr != nil { + repair = joinRemoveRepairErr(repair, aerr) + } + } + return joinRemoveRepairErr(fmt.Errorf("failed to remove font file: %w", removeErr), repair) + } + + if !ops.SkipNotify && ops.NotifyChange != nil { + if err := ops.NotifyChange(); err != nil { + return fmt.Errorf("failed to notify font change: %w", err) + } + } + return nil +} diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 1a46e72..58958df 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -4,12 +4,12 @@ package platform import ( + "errors" "fmt" "os" "path/filepath" "strings" "syscall" - "time" "unsafe" "fontget/internal/logging" @@ -238,78 +238,35 @@ func (m *windowsFontManager) RemoveFont(fontName string, scope InstallationScope fontPath := filepath.Join(targetDir, fontName) logger.Debug("Target path: %s", fontPath) - // Check if font exists if _, err := os.Stat(fontPath); os.IsNotExist(err) { logger.Error("Font not found at path: %s", fontPath) return fmt.Errorf("font not found: %s", fontName) } - // Remove the font resource - logger.Debug("Removing font resource...") - if err := RemoveFontResource(fontPath); err != nil { - // Check if the error is because the font isn't loaded as a resource - // This is normal and shouldn't prevent font removal - if strings.Contains(err.Error(), "error code: 0") || strings.Contains(err.Error(), "The operation completed successfully") { - logger.Debug("Font resource not loaded, continuing with file removal") - } else { - logger.Error("Failed to remove font resource from path %s: %v", fontPath, err) - return fmt.Errorf("failed to remove font resource: %w", err) - } - } else { - logger.Debug("Font resource removed successfully") + unregisterOnly := opts != nil && opts.UnregisterOnly + skipNotify := opts != nil && opts.SkipPostRemoveCacheRefresh + ops := machineRemoveOps{ + RemoveGDI: RemoveFontResource, + AddGDI: AddFontResource, + RemoveFile: os.Remove, + NotifyChange: NotifyFontChange, + UnregisterOnly: unregisterOnly, + SkipNotify: skipNotify, } - - // Remove from registry if machine scope if scope == MachineScope { - logger.Debug("Removing font from registry...") - if err := m.removeFontFromRegistry(fontName); err != nil { - logger.Error("Failed to remove font from registry: %v", err) - // Continue with file removal even if registry removal fails - } else { - logger.Debug("Font removed from registry successfully") - } - } - - if opts != nil && opts.UnregisterOnly { - return nil - } - - // Delete the font file. Prefer a font-change broadcast first so the session releases locks. - _ = NotifyFontChange() - logger.Debug("Removing font file...") - var removeErr error - for attempt := 0; attempt < 4; attempt++ { - if attempt > 0 { - time.Sleep(time.Duration(attempt*40) * time.Millisecond) - _ = NotifyFontChange() - } - removeErr = os.Remove(fontPath) - if removeErr == nil || os.IsNotExist(removeErr) { - removeErr = nil - break - } - } - if removeErr != nil { - logger.Error("Failed to remove font file at path %s: %v", fontPath, removeErr) - // Restore GDI registration only (Windows). User-facing cancel/retry copy lives in cmd/. - if addErr := AddFontResource(fontPath); addErr != nil { - logger.Error("Failed to restore font registration after delete failure for %s: %v", fontPath, addErr) - return fmt.Errorf("failed to remove font file: %w (also failed to restore registration: %v)", removeErr, addErr) - } - return fmt.Errorf("failed to remove font file: %w", removeErr) + ops.CaptureReg = m.captureFontRegistryValue + ops.DeleteReg = m.removeFontFromRegistry + ops.RestoreReg = m.restoreFontRegistryValue + } else { + ops.CaptureReg = func(string) (registryFontValue, error) { return registryFontValue{}, nil } + ops.DeleteReg = func(string) error { return nil } + ops.RestoreReg = func(registryFontValue) error { return nil } } - logger.Debug("Font file removed successfully") - skipNotify := opts != nil && opts.SkipPostRemoveCacheRefresh - if !skipNotify { - logger.Debug("Notifying system about font change...") - if err := NotifyFontChange(); err != nil { - logger.Error("Failed to notify system about font change: %v", err) - return fmt.Errorf("failed to notify font change: %w", err) - } - logger.Debug("Font change notification sent successfully") + if err := removeMachineScopedFont(fontName, fontPath, ops); err != nil { + logger.Error("Font removal failed for %s: %v", fontName, err) + return err } - logger.Info("Font removal completed successfully") return nil } @@ -430,6 +387,37 @@ func (m *windowsFontManager) removeFontFromRegistry(fontName string) error { return nil } +// captureFontRegistryValue reads the existing Fonts registry entry for fontName (exact prior state). +func (m *windowsFontManager) captureFontRegistryValue(fontName string) (registryFontValue, error) { + valueName := fontName + " (TrueType)" + key, err := m.openFontRegistryKey() + if err != nil { + return registryFontValue{}, err + } + defer regCloseKey.Call(uintptr(key)) + + data, typ, err := m.queryRegistryValue(key, valueName) + if err != nil { + if errors.Is(err, ErrRegistryValueAbsent) { + return registryFontValue{Name: valueName, Found: false}, nil + } + return registryFontValue{}, err + } + return registryFontValue{Name: valueName, Data: data, Type: typ, Found: true}, nil +} + +func (m *windowsFontManager) restoreFontRegistryValue(v registryFontValue) error { + if !v.Found || v.Name == "" { + return nil + } + key, err := m.openFontRegistryKey() + if err != nil { + return err + } + defer regCloseKey.Call(uintptr(key)) + return m.setRegistryValueTyped(key, v.Name, v.Data, v.Type) +} + // openFontRegistryKey opens the Windows font registry key for writing func (m *windowsFontManager) openFontRegistryKey() (syscall.Handle, error) { logger := logging.GetLogger() @@ -444,7 +432,7 @@ func (m *windowsFontManager) openFontRegistryKey() (syscall.Handle, error) { 0, 0, 0, - uintptr(KEY_WRITE), + uintptr(KEY_WRITE|KEY_QUERY_VALUE), 0, uintptr(unsafe.Pointer(&key)), 0, @@ -459,6 +447,10 @@ func (m *windowsFontManager) openFontRegistryKey() (syscall.Handle, error) { // setRegistryValue sets a registry value with proper error handling func (m *windowsFontManager) setRegistryValue(key syscall.Handle, valueName, value string) error { + return m.setRegistryValueTyped(key, valueName, value, REG_SZ) +} + +func (m *windowsFontManager) setRegistryValueTyped(key syscall.Handle, valueName, value string, typ uint32) error { logger := logging.GetLogger() valueNamePtr, err := syscall.UTF16PtrFromString(valueName) @@ -473,11 +465,14 @@ func (m *windowsFontManager) setRegistryValue(key syscall.Handle, valueName, val return fmt.Errorf("failed to convert value to UTF16: %w", err) } + if typ == 0 { + typ = REG_SZ + } ret, _, err := regSetValueEx.Call( uintptr(key), uintptr(unsafe.Pointer(valueNamePtr)), 0, - uintptr(REG_SZ), + uintptr(typ), uintptr(unsafe.Pointer(valuePtr)), uintptr((len(value)+1)*2), ) @@ -488,6 +483,48 @@ func (m *windowsFontManager) setRegistryValue(key syscall.Handle, valueName, val return nil } +func (m *windowsFontManager) queryRegistryValue(key syscall.Handle, valueName string) (string, uint32, error) { + valueNamePtr, err := syscall.UTF16PtrFromString(valueName) + if err != nil { + return "", 0, fmt.Errorf("failed to convert value name to UTF16: %w", err) + } + var typ uint32 + var dataLen uint32 + ret, _, callErr := regQueryValueEx.Call( + uintptr(key), + uintptr(unsafe.Pointer(valueNamePtr)), + 0, + uintptr(unsafe.Pointer(&typ)), + 0, + uintptr(unsafe.Pointer(&dataLen)), + ) + if ret != 0 { + if ret == 2 { // ERROR_FILE_NOT_FOUND + return "", 0, ErrRegistryValueAbsent + } + return "", 0, fmt.Errorf("failed to query registry value: %w", callErr) + } + if dataLen == 0 { + return "", typ, nil + } + buf := make([]uint16, (dataLen+1)/2) + ret, _, callErr = regQueryValueEx.Call( + uintptr(key), + uintptr(unsafe.Pointer(valueNamePtr)), + 0, + uintptr(unsafe.Pointer(&typ)), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&dataLen)), + ) + if ret != 0 { + if ret == 2 { + return "", 0, ErrRegistryValueAbsent + } + return "", 0, fmt.Errorf("failed to query registry value: %w", callErr) + } + return syscall.UTF16ToString(buf), typ, nil +} + // deleteRegistryValue deletes a registry value with proper error handling func (m *windowsFontManager) deleteRegistryValue(key syscall.Handle, valueName string) error { logger := logging.GetLogger() @@ -498,12 +535,14 @@ func (m *windowsFontManager) deleteRegistryValue(key syscall.Handle, valueName s return fmt.Errorf("failed to convert value name to UTF16: %w", err) } - regDeleteValue := syscall.NewLazyDLL("advapi32.dll").NewProc("RegDeleteValueW") ret, _, err := regDeleteValue.Call( uintptr(key), uintptr(unsafe.Pointer(valueNamePtr)), ) if ret != 0 { + if ret == 2 { // ERROR_FILE_NOT_FOUND + return ErrRegistryValueAbsent + } logger.Error("Failed to delete registry value: %v", err) return fmt.Errorf("failed to delete registry value: %w", err) } diff --git a/internal/platform/windows_utils.go b/internal/platform/windows_utils.go index 673f6a4..f9e9319 100644 --- a/internal/platform/windows_utils.go +++ b/internal/platform/windows_utils.go @@ -15,6 +15,7 @@ import ( const ( HWND_BROADCAST = 0xFFFF HKEY_LOCAL_MACHINE = 0x80000002 + KEY_QUERY_VALUE = 0x0001 KEY_WRITE = 0x20006 REG_SZ = 1 ) @@ -30,6 +31,8 @@ var ( removeFontResource = gdi32.NewProc("RemoveFontResourceW") regCreateKeyEx = advapi32.NewProc("RegCreateKeyExW") regSetValueEx = advapi32.NewProc("RegSetValueExW") + regQueryValueEx = advapi32.NewProc("RegQueryValueExW") + regDeleteValue = advapi32.NewProc("RegDeleteValueW") regCloseKey = advapi32.NewProc("RegCloseKey") getDesktopWindow = user32.NewProc("GetDesktopWindow") ) From 79a22dd0aa4720bfc3d1210d72e89d3b2b99a6b8 Mon Sep 17 00:00:00 2001 From: graphixa Date: Thu, 17 Sep 2026 00:56:19 +1000 Subject: [PATCH 18/23] fix(platform): windows-tag machine_remove Without the deleted cross-platform tests, ubuntu staticcheck flags the helpers as unused (U1000). --- internal/platform/machine_remove.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/platform/machine_remove.go b/internal/platform/machine_remove.go index 6215ff7..4c9436f 100644 --- a/internal/platform/machine_remove.go +++ b/internal/platform/machine_remove.go @@ -1,3 +1,5 @@ +//go:build windows + package platform import ( From 8221922ca021209c0c7b266186491d954abdf411 Mon Sep 17 00:00:00 2001 From: graphixa Date: Thu, 17 Sep 2026 09:57:28 +1000 Subject: [PATCH 19/23] Refactor progress tracking in font installation and removal processes --- cmd/add.go | 80 +++++++----- cmd/browse_model.go | 29 ++--- cmd/browse_model_test.go | 58 ++++++++- cmd/import.go | 4 +- cmd/progress_steps.go | 70 ++++++++--- cmd/progress_steps_test.go | 81 +++++++++++- cmd/remove.go | 110 ++++++++-------- cmd/remove_exit_test.go | 153 ++++++++++++++++++++++ internal/platform/machine_remove.go | 4 +- internal/platform/machine_remove_test.go | 154 +++++++++++++++++++++++ internal/platform/windows.go | 70 +++++++---- 11 files changed, 660 insertions(+), 153 deletions(-) create mode 100644 cmd/remove_exit_test.go create mode 100644 internal/platform/machine_remove_test.go diff --git a/cmd/add.go b/cmd/add.go index b11d9cb..f4d5f87 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -715,8 +715,8 @@ Use --scope to set installation location: return } msg := FormatProgressActivity(u.Phase, u.Detail) - if u.Phase == installStepDownload && u.Detail == "" { - msg = "Downloading from " + fontGroup.SourceName + if isInstallPrepPhase(u.Phase) || u.Phase == removeStepRemove { + msg = DownloadFromSourceMessage(fontGroup.SourceName) } send(components.ItemUpdateMsg{ Index: itemIndex, @@ -960,26 +960,56 @@ func archiveSourcePrefixFromFontID(fontID string) string { return strings.ToLower(fontID[:i]) } -// downloadFontVariants downloads all variants of a font family +// downloadFontVariants downloads all variants of a font family. +// Progress is work-unit prep across downloads: (completed + unitFrac) / N inside the shared +// download+extract band. Retries reuse the same unit share; progress is monotonic and only +// advances to the next unit after successful download+extract validation. func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, staging *platform.OperationStaging, fontID string, archiveSourcePrefix string, downloadOpts *repo.DownloadFontOptions, onProgress ProgressFunc) ([]string, error) { start := time.Now() var allFontPaths []string - total := len(fontFiles) + n := len(fontFiles) + var highWater float64 // monotonic prep Done (work units) + + emitPrep := func(phase, detail string, completed int, unitFrac float64) { + if onProgress == nil || n == 0 { + return + } + done := float64(completed) + shared.Clamp01(unitFrac) + if done > float64(n) { + done = float64(n) + } + if done < highWater { + done = highWater + } else { + highWater = done + } + onProgress(ProgressUpdate{ + Phase: phase, + Detail: detail, + Kind: ProgressCount, + Done: done, + Total: float64(n), + }) + } + for i, fontFile := range fontFiles { if ctx != nil { if err := ctx.Err(); err != nil { return nil, err } } - if onProgress != nil && total > 0 { - onProgress(ProgressUpdate{ - Phase: installStepDownload, - Kind: ProgressCount, - Done: float64(i), - Total: float64(total), - }) + unitHigh := 0.0 + bumpUnit := func(phase, detail string, unitFrac float64) { + if unitFrac < unitHigh { + unitFrac = unitHigh + } else { + unitHigh = unitFrac + } + emitPrep(phase, detail, i, unitFrac) } + emitPrep(installStepDownload, "", i, 0) + opts := downloadOpts if opts == nil { opts = &repo.DownloadFontOptions{} @@ -992,25 +1022,10 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin opts.ArchiveFontID = fontID if onProgress != nil { opts.OnBytesDownloaded = func(doneBytes int64, totalBytes int64) { - onProgress(ProgressUpdate{ - Phase: installStepDownload, - Kind: ProgressBytes, - Done: float64(doneBytes), - Total: float64(totalBytes), - }) + bumpUnit(installStepDownload, "", prepDownloadUnitFrac(doneBytes, totalBytes)) } opts.OnExtractProgress = func(done int, total int) { - t := float64(total) - if total <= 0 { - // Package-mode streams (e.g. nerd tar.xz) don't know M until EOF. - t = float64(done + 12) - } - onProgress(ProgressUpdate{ - Phase: installStepExtract, - Kind: ProgressCount, - Done: float64(done), - Total: t, - }) + bumpUnit(installStepExtract, "", prepExtractUnitFrac(done, total)) } } @@ -1025,15 +1040,16 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin output.GetDebug().State("repo.DownloadAndExtractFont() failed for variant %s: %v", fontFile.Variant, err) return nil, err } + // Unit complete only after successful validation/extract. + emitPrep(installStepExtract, "", i+1, 0) allFontPaths = append(allFontPaths, fontPaths...) if len(fontPaths) > 1 { output.GetDebug().State("Extracted %d file(s) from variant: %s", len(fontPaths), fontFile.Variant) } } - if onProgress != nil { - onProgress(ProgressUpdate{Phase: installStepDownload, Kind: ProgressBytes, Done: 1, Total: 0}) - onProgress(ProgressUpdate{Phase: installStepExtract, Kind: ProgressCount, Done: 1, Total: 0}) + if onProgress != nil && n > 0 { + emitPrep(installStepExtract, "", n, 0) } output.GetDebug().State("downloadFontVariants: files=%d extracted=%d total=%dms", len(fontFiles), len(allFontPaths), time.Since(start).Milliseconds()) return allFontPaths, nil @@ -1337,7 +1353,7 @@ func installFont( } archivePrefix := archiveSourcePrefixFromFontID(fontID) if onProgress != nil { - onProgress(ProgressUpdate{Phase: installStepDownload, Kind: ProgressBytes, Done: 0, Total: 1}) + onProgress(ProgressUpdate{Phase: installStepDownload, Kind: ProgressCount, Done: 0, Total: float64(max(1, len(fontFiles)))}) } allFontPaths, downloadErr := downloadFontVariants(ctx, fontFiles, staging, fontID, archivePrefix, downloadOpts, onProgress) if downloadErr != nil { diff --git a/cmd/browse_model.go b/cmd/browse_model.go index 757b707..86abc01 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -417,6 +417,12 @@ func browseNormalizeSourceLabel(s string) string { return s } +// browseKeepOpErr preserves install/remove outcomes. Late context cancellation after a +// successful helper return must not become ErrOperationCancelled. +func browseKeepOpErr(opErr error) error { + return opErr +} + func browseResultFromInstall(fontName, source string, msg installFinishedMsg, scope platform.InstallationScope, force bool) (title string, errorTitle bool, body string) { fontName = strings.TrimSpace(fontName) if fontName == "" { @@ -581,14 +587,15 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea if !th.ShouldSend(u, pct) { return } - ch <- browseOpProgressMsg{phase: FormatProgressActivity(u.Phase, u.Detail), percent: pct} + phase := FormatProgressActivity(u.Phase, u.Detail) + if isInstallPrepPhase(u.Phase) || u.Phase == removeStepRemove { + phase = DownloadFromSourceMessage(sourceLabel) + } + ch <- browseOpProgressMsg{phase: phase, percent: pct} } ir, ierr := installFont(ctx, res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress, nil) - if ctx.Err() != nil && ierr == nil { - ierr = shared.ErrOperationCancelled - } - ch <- installFinishedMsg{result: ir, err: ierr, fontID: fontID} + ch <- installFinishedMsg{result: ir, err: browseKeepOpErr(ierr), fontID: fontID} }() return m.waitForOpMsg() } @@ -596,7 +603,7 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea func (m *browseModel) startUninstallByID(fontID, fontName, sourceLabel string) tea.Cmd { m.removing = true m.statusProgress = -1 - m.statusPhase = "Removing" + m.statusPhase = "" m.removingFontName = fontName if sourceLabel == "" { sourceLabel = shared.PlaceholderNA @@ -625,14 +632,11 @@ func (m *browseModel) startUninstallByID(fontID, fontName, sourceLabel string) t if !th.ShouldSend(u, pct) { return } - ch <- browseOpProgressMsg{phase: FormatProgressActivity(u.Phase, u.Detail), percent: pct} + ch <- browseOpProgressMsg{phase: "", percent: pct} } installReg, _ := m.cachedInstallRegistry() rr, err := removeFont(ctx, fontID, fm, scope, fontDir, repository, installReg, m.cachedManifestFontIDProbe(), onProgress) - if ctx.Err() != nil && err == nil { - err = shared.ErrOperationCancelled - } - ch <- uninstallFinishedMsg{result: rr, err: err, fontID: fontID} + ch <- uninstallFinishedMsg{result: rr, err: browseKeepOpErr(err), fontID: fontID} }() return m.waitForOpMsg() } @@ -1217,9 +1221,6 @@ func (m *browseModel) View() string { maxOuter = components.DefaultStatusPopupMaxOuter } phase := strings.TrimSpace(m.statusPhase) - if phase == "" { - phase = "Removing" - } mid := fmt.Sprintf("'%s' from '%s'", m.removingFontName, m.removingSourceLabel) popup := components.RenderStatusPopupPlain(phase, mid, m.statusProgress, maxOuter) view = components.Composite(popup, view, components.Center, components.Center, 0, 0) diff --git a/cmd/browse_model_test.go b/cmd/browse_model_test.go index 5817308..3ff640a 100644 --- a/cmd/browse_model_test.go +++ b/cmd/browse_model_test.go @@ -1,6 +1,13 @@ package cmd -import "testing" +import ( + "errors" + "strings" + "testing" + + "fontget/internal/platform" + "fontget/internal/shared" +) func TestBrowseSearchTickStale(t *testing.T) { t.Parallel() @@ -26,3 +33,52 @@ func TestBrowseFocusTabCyclesTwoRegions(t *testing.T) { t.Fatalf("expected 0 after 4 tabs, got %d", focus) } } + +func TestBrowseKeepOpErrPreservesSuccess(t *testing.T) { + if err := browseKeepOpErr(nil); err != nil { + t.Fatalf("success must stay nil: %v", err) + } + want := errors.New("real failure") + if got := browseKeepOpErr(want); !errors.Is(got, want) { + t.Fatalf("got %v", got) + } +} + +func TestBrowseResultFromInstallFinalFileLateCancelStillInstalled(t *testing.T) { + // Simulates: installFont succeeded; caller must not inject ErrOperationCancelled. + msg := installFinishedMsg{ + result: &InstallResult{Status: InstallStatusCompleted, Message: "Installed", Success: 1}, + err: browseKeepOpErr(nil), + fontID: "test.final", + } + title, errTitle, body := browseResultFromInstall("Final", "test", msg, platform.UserScope, false) + if errTitle || title != "Installed" { + t.Fatalf("title=%q errTitle=%v body=%q", title, errTitle, body) + } + if strings.Contains(body, "cancelled") || strings.Contains(body, "Cancelled") { + t.Fatalf("must not show cancel: %q", body) + } +} + +func TestBrowseResultFromInstallCancelStillCancelled(t *testing.T) { + msg := installFinishedMsg{ + err: shared.ErrOperationCancelled, + fontID: "test.cancel", + } + title, errTitle, _ := browseResultFromInstall("X", "test", msg, platform.UserScope, false) + if errTitle || title != "Cancelled" { + t.Fatalf("title=%q errTitle=%v", title, errTitle) + } +} + +func TestBrowseResultFromUninstallFinalFileLateCancelStillUninstalled(t *testing.T) { + msg := uninstallFinishedMsg{ + result: &RemoveResult{Status: StatusCompleted, Message: "Removed", Success: 1}, + err: browseKeepOpErr(nil), + fontID: "test.rmfinal", + } + title, errTitle, body := browseResultFromUninstall("Final", platform.UserScope, msg) + if errTitle || title != "Uninstalled" { + t.Fatalf("title=%q errTitle=%v body=%q", title, errTitle, body) + } +} diff --git a/cmd/import.go b/cmd/import.go index d7ed0ed..ba94b5f 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -639,8 +639,8 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni return } msg := FormatProgressActivity(u.Phase, u.Detail) - if u.Phase == installStepDownload && u.Detail == "" { - msg = "Downloading from " + fontGroup.SourceName + if isInstallPrepPhase(u.Phase) || u.Phase == removeStepRemove { + msg = DownloadFromSourceMessage(fontGroup.SourceName) } send(components.ItemUpdateMsg{ Index: itemIndex, diff --git a/cmd/progress_steps.go b/cmd/progress_steps.go index ec97475..c029ae3 100644 --- a/cmd/progress_steps.go +++ b/cmd/progress_steps.go @@ -12,8 +12,8 @@ import ( type ProgressKind int const ( - ProgressBytes ProgressKind = iota // download - ProgressCount // extract / install / remove / backup file counts + ProgressBytes ProgressKind = iota // single-stream bytes (known size only for bar math) + ProgressCount // work units / file counts ProgressFlag // precheck / finalize (0 → 1) ) @@ -23,7 +23,7 @@ type ProgressUpdate struct { Detail string Kind ProgressKind Done float64 - Total float64 // 0 = unknown for this pulse + Total float64 // 0 = unknown; never treat unknown bytes as complete } // ProgressFunc reports progress. Implementations must be lightweight. @@ -45,15 +45,36 @@ const ( ) // Reserved bar segments for a single item (monotonic; install/remove owns most of the bar). +// Download + extract share one prep band so multi-archive packages never jump backwards. const ( segPrecheckEnd = 0.02 - segDownloadEnd = 0.25 - segExtractEnd = 0.35 + segPrepEnd = 0.35 // download+extract preparation segFilesEnd = 0.98 segFinalizeEnd = 1.00 - segRemoveScanEnd = 0.35 // scan uses the whole prelude band + segRemoveScanEnd = 0.35 + + prepDownloadWeight = 0.75 + prepExtractWeight = 0.25 + + // Aliases: prep band replaced the old split download/extract segments. + segDownloadEnd = segPrepEnd + segExtractEnd = segPrepEnd ) +// DownloadFromSourceMessage is the only user-facing label during download/extract prep. +func DownloadFromSourceMessage(sourceName string) string { + sourceName = strings.TrimSpace(sourceName) + if sourceName == "" { + return installStepDownload + "..." + } + return "Downloading from " + sourceName +} + +// isInstallPrepPhase reports download or extract (shared prep band). +func isInstallPrepPhase(phase string) bool { + return phase == installStepDownload || phase == installStepExtract +} + // FormatProgressActivity builds the status line for progress UIs. func FormatProgressActivity(phase, detail string) string { phase = strings.TrimSpace(phase) @@ -79,19 +100,38 @@ func CountDetail(i, n int, name string) string { return fmt.Sprintf("%d/%d %s", i, n, filepath.Base(name)) } +// prepDownloadUnitFrac is progress within one download/extract unit during download [0, prepDownloadWeight]. +// Unknown Content-Length returns 0 (activity via Detail only); never invents completion. +func prepDownloadUnitFrac(doneBytes, totalBytes int64) float64 { + if totalBytes <= 0 { + return 0 + } + return prepDownloadWeight * shared.Clamp01(float64(doneBytes)/float64(totalBytes)) +} + +// prepExtractUnitFrac is progress within one unit during extraction [prepDownloadWeight, 1]. +// Unknown totals hold at prepDownloadWeight until the unit succeeds. +func prepExtractUnitFrac(done, total int) float64 { + if total <= 0 { + return prepDownloadWeight + } + return prepDownloadWeight + prepExtractWeight*shared.Clamp01(float64(done)/float64(total)) +} + // phaseFrac returns progress within the active phase in [0,1]. func phaseFrac(u ProgressUpdate) float64 { switch u.Kind { case ProgressFlag: - if u.Done >= 1 || (u.Total > 0 && u.Done >= u.Total) { + if u.Total > 0 { + return shared.Clamp01(u.Done / u.Total) + } + if u.Done >= 1 { return 1 } return shared.Clamp01(u.Done) case ProgressBytes, ProgressCount: if u.Total <= 0 { - if u.Done >= 1 { - return 1 - } + // Unknown size: never treat bytes received as phase-complete. return 0 } return shared.Clamp01(u.Done / u.Total) @@ -105,12 +145,10 @@ func segmentRange(phase string) (start, end float64) { switch phase { case installStepPrecheck: return 0, segPrecheckEnd - case installStepDownload: - return segPrecheckEnd, segDownloadEnd - case installStepExtract: - return segDownloadEnd, segExtractEnd + case installStepDownload, installStepExtract: + return segPrecheckEnd, segPrepEnd case installStepInstall, removeStepRemove: - return segExtractEnd, segFilesEnd + return segPrepEnd, segFilesEnd case installStepFinalize: return segFilesEnd, segFinalizeEnd case removeStepScan: @@ -118,7 +156,7 @@ func segmentRange(phase string) (start, end float64) { case installStepCompleted, removeStepCompleted: return 1, 1 default: - return segExtractEnd, segFilesEnd + return segPrepEnd, segFilesEnd } } diff --git a/cmd/progress_steps_test.go b/cmd/progress_steps_test.go index 2dd06d0..bc3616d 100644 --- a/cmd/progress_steps_test.go +++ b/cmd/progress_steps_test.go @@ -1,6 +1,25 @@ package cmd -import "testing" +import ( + "testing" + + "fontget/internal/shared" +) + +func TestDownloadFromSourceMessage(t *testing.T) { + if got := DownloadFromSourceMessage("Google Fonts"); got != "Downloading from Google Fonts" { + t.Fatalf("got %q", got) + } + if got := DownloadFromSourceMessage(" "); got != "Downloading..." { + t.Fatalf("empty source got %q", got) + } + if !isInstallPrepPhase(installStepDownload) || !isInstallPrepPhase(installStepExtract) { + t.Fatal("prep phases") + } + if isInstallPrepPhase(installStepInstall) { + t.Fatal("install is not prep") + } +} func TestFormatProgressActivity(t *testing.T) { if got := FormatProgressActivity("Installing", ""); got != "Installing..." { @@ -19,10 +38,10 @@ func TestCountDetail(t *testing.T) { func TestOverallWorkPercent_DownloadThenInstall(t *testing.T) { midDL := OverallWorkPercent(0, 1, ProgressUpdate{ - Phase: installStepDownload, Kind: ProgressBytes, Done: 50, Total: 100, + Phase: installStepDownload, Kind: ProgressCount, Done: 0.5, Total: 1, }) - if midDL < 2 || midDL > 25 { - t.Fatalf("mid download %% = %v, want in (2,25)", midDL) + if midDL < 2 || midDL > 35 { + t.Fatalf("mid download %% = %v, want in (2,35)", midDL) } startInst := OverallWorkPercent(0, 1, ProgressUpdate{ @@ -93,3 +112,57 @@ func TestProgressThrottle(t *testing.T) { t.Fatal("detail change should send") } } + +func TestPhaseFrac_UnknownBytesNeverComplete(t *testing.T) { + if got := phaseFrac(ProgressUpdate{Kind: ProgressBytes, Done: 1, Total: 0}); got != 0 { + t.Fatalf("1 byte unknown size must not complete phase, got %v", got) + } + if got := phaseFrac(ProgressUpdate{Kind: ProgressBytes, Done: 1e9, Total: -1}); got != 0 { + t.Fatalf("unknown total must stay 0, got %v", got) + } + if got := phaseFrac(ProgressUpdate{Kind: ProgressBytes, Done: 50, Total: 100}); shared.Clamp01(got) != 0.5 { + t.Fatalf("known size mid = %v", got) + } +} + +func TestPrepUnitFracs(t *testing.T) { + if got := prepDownloadUnitFrac(50, 100); got < 0.37 || got > 0.38 { + t.Fatalf("half download = %v want ~0.375", got) + } + if got := prepDownloadUnitFrac(100, -1); got != 0 { + t.Fatalf("unknown download = %v want 0", got) + } + if got := prepExtractUnitFrac(0, 0); got != prepDownloadWeight { + t.Fatalf("unknown extract hold = %v", got) + } + if got := prepExtractUnitFrac(1, 2); got < 0.87 || got > 0.88 { + t.Fatalf("half extract = %v want ~0.875", got) + } +} + +func TestMultiArchivePrepIsMonotonic(t *testing.T) { + // Two downloads: end of first unit must be below mid of second; no reset to prep start. + endFirst := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepExtract, Kind: ProgressCount, Done: 1, Total: 2, + }) + midSecond := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepDownload, Kind: ProgressCount, Done: 1.5, Total: 2, + }) + if midSecond <= endFirst { + t.Fatalf("second download must advance past first unit: endFirst=%v midSecond=%v", endFirst, midSecond) + } + startSecond := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepDownload, Kind: ProgressCount, Done: 1, Total: 2, + }) + if startSecond < endFirst-0.01 { + t.Fatalf("starting second unit must not jump backwards: endFirst=%v startSecond=%v", endFirst, startSecond) + } +} + +func TestDownloadExtractSharePrepBand(t *testing.T) { + ds, de := segmentRange(installStepDownload) + es, ee := segmentRange(installStepExtract) + if ds != es || de != ee || de != segPrepEnd { + t.Fatalf("download/extract must share prep band, got dl=(%v,%v) ex=(%v,%v)", ds, de, es, ee) + } +} diff --git a/cmd/remove.go b/cmd/remove.go index ee66142..0ea1f4e 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -25,10 +25,11 @@ import ( // This is kept separate from OperationStatus for command-specific clarity and backward compatibility. // It provides clearer field names (Removed vs Success) for the remove command context. type RemovalStatus struct { - Removed int - Skipped int - Failed int - Details []string + Removed int + Skipped int + Failed int + HadError bool // true when any removeFont call returned a non-cancel error (incl. tracking/lock) + Details []string } // Status constants for removal operations @@ -328,13 +329,36 @@ func updateRemovalStatus(status *RemovalStatus, result *RemoveResult) { status.Failed += result.Failed } +// applyRemoveOutcome records file counts and marks HadError on any non-cancel operation error. +func applyRemoveOutcome(status *RemovalStatus, result *RemoveResult, err error) { + if status == nil { + return + } + updateRemovalStatus(status, result) + if err != nil && !IsCancelErr(err) { + status.HadError = true + } +} + // removalExitAfterSummary returns a non-nil AlreadyPrinted error when any requested // removal failed or fonts were not found. Summary output is assumed already shown. -func removalExitAfterSummary(failedCount, totalCount, notFoundCount int) error { +func removalExitAfterSummary(status *RemovalStatus, totalCount, notFoundCount int) error { + failedCount := 0 + hadError := false + if status != nil { + failedCount = status.Failed + hadError = status.HadError + } failedCount += notFoundCount + if failedCount <= 0 && hadError { + failedCount = 1 + } if failedCount <= 0 { return nil } + if totalCount < failedCount { + totalCount = failedCount + } return shared.AlreadyPrinted(&shared.FontRemovalError{ FailedCount: failedCount, TotalCount: totalCount, @@ -1833,28 +1857,18 @@ Use --scope to set removal location: return FinishRemovalCancel([]string{fontInfo.SearchName}, scopeFlag) } output.GetDebug().State("Error removing font %s in %s: %v", fontInfo.SearchName, scopeLabelName, err) - if result != nil { - updateRemovalStatus(status, result) - // Tracking/lock failures may leave Failed==0 after a successful delete. - if result.Failed == 0 { - status.Failed++ - } - // Show failed variants if available - _, _, failedFiles := processRemoveResult(result) - if len(failedFiles) > 0 { - output.GetDebug().State("Failed variants:") - for _, file := range failedFiles { - output.GetDebug().State(" - %s", file) - } + applyRemoveOutcome(status, result, err) + _, _, failedFiles := processRemoveResult(result) + if len(failedFiles) > 0 { + output.GetDebug().State("Failed variants:") + for _, file := range failedFiles { + output.GetDebug().State(" - %s", file) } - } else { - status.Failed++ } continue } - // Update status - updateRemovalStatus(status, result) + applyRemoveOutcome(status, result, nil) // Show detailed result information in debug mode logRemoveResultDetails(result, fontInfo.SearchName, scopeLabelName) @@ -1876,7 +1890,7 @@ Use --scope to set removal location: GetLogger().Info("Removal complete - Removed: %d, Skipped: %d, Failed: %d", status.Removed, status.Skipped, status.Failed) - return removalExitAfterSummary(status.Failed, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) + return removalExitAfterSummary(status, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) } // Determine title based on scope @@ -1927,7 +1941,7 @@ Use --scope to set removal location: Index: item.ItemIndex, Name: item.ProperName, Status: "in_progress", - Message: "Removing...", + Message: "", }) GetLogger().Info("Processing font: %s in %s scope", item.FontName, item.ScopeLabel) @@ -1943,7 +1957,7 @@ Use --scope to set removal location: Index: item.ItemIndex, Name: item.ProperName, Status: "in_progress", - Message: FormatProgressActivity(u.Phase, u.Detail), + Message: "", }) send(components.ProgressUpdateMsg{Percent: pct}) } @@ -1969,30 +1983,21 @@ Use --scope to set removal location: // Collect variants for display (only in verbose mode) scopeVariants := []string{} - if verbose { + if verbose && result != nil { scopeVariants = result.Details } - // Determine status scopeStatus := StatusCompleted scopeMessage := "Removed" if err != nil { if strings.Contains(err.Error(), "not found") { - // Font not found in this scope - skip (shouldn't happen since we checked) continue } + applyRemoveOutcome(status, result, err) scopeStatus = StatusFailed scopeMessage = err.Error() - status.Failed++ - if result != nil { - status.Failed += result.Failed - status.Skipped += result.Skipped - } } else if result != nil { - status.Removed += result.Success - status.Skipped += result.Skipped - status.Failed += result.Failed - + applyRemoveOutcome(status, result, nil) switch result.Status { case StatusFailed: scopeStatus = StatusFailed @@ -2170,12 +2175,12 @@ Use --scope to set removal location: // Process removal from user scope fontDir := fontManager.GetFontDir(platform.UserScope) onProgress := func(u ProgressUpdate) { - // In this special mode we already drive percent via scan math; only update the step label. + // In this special mode we already drive percent via scan math; omit Removing... labels. send(components.ItemUpdateMsg{ Index: i, Name: properFontName, Status: "in_progress", - Message: FormatProgressActivity(u.Phase, u.Detail), + Message: "", }) } result, err := removeFont( @@ -2199,32 +2204,23 @@ Use --scope to set removal location: } return shared.ErrOperationCancelled } + applyRemoveOutcome(status, result, err) if strings.Contains(err.Error(), "not found") { - // Font not found - mark as failed and continue fontStatus = StatusFailed statusMessage = "Font not found" allErrors = append(allErrors, "Font not found") - status.Failed++ - // Continue to next font instead of exiting } else { fontStatus = StatusFailed statusMessage = err.Error() - // Add error message allErrors = append(allErrors, err.Error()) if result != nil { - status.Failed += result.Failed allErrors = append(allErrors, result.Errors...) } } } else { - status.Removed += result.Success - status.Skipped += result.Skipped - status.Failed += result.Failed - - // Collect errors + applyRemoveOutcome(status, result, nil) allErrors = append(allErrors, result.Errors...) - // Collect variants for display (only in verbose mode) if verbose { allRemovedVariants = append(allRemovedVariants, result.Details...) } @@ -2300,7 +2296,7 @@ Use --scope to set removal location: Index: i, Name: properFontName, Status: "in_progress", - Message: FormatProgressActivity(u.Phase, u.Detail), + Message: "", }) send(components.ProgressUpdateMsg{Percent: pct}) } @@ -2325,29 +2321,23 @@ Use --scope to set removal location: } return shared.ErrOperationCancelled } + applyRemoveOutcome(status, result, err) if strings.Contains(err.Error(), "not found") { fontStatus = StatusFailed statusMessage = "Font not found" allErrors = append(allErrors, "Font not found") - status.Failed++ } else { fontStatus = StatusFailed statusMessage = err.Error() allErrors = append(allErrors, err.Error()) if result != nil { - status.Failed += result.Failed allErrors = append(allErrors, result.Errors...) } } } else { - status.Removed += result.Success - status.Skipped += result.Skipped - status.Failed += result.Failed - - // Collect errors + applyRemoveOutcome(status, result, nil) allErrors = append(allErrors, result.Errors...) - // Collect variants for display (only in verbose mode) if verbose { allRemovedVariants = append(allRemovedVariants, result.Details...) } @@ -2860,7 +2850,7 @@ Use --scope to set removal location: FailedLabel: "Failed", }, output.IsVerboseOutputEnabled()) - return removalExitAfterSummary(status.Failed, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) + return removalExitAfterSummary(status, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) }, } diff --git a/cmd/remove_exit_test.go b/cmd/remove_exit_test.go new file mode 100644 index 0000000..1e70e34 --- /dev/null +++ b/cmd/remove_exit_test.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "fontget/internal/installations" + "fontget/internal/platform" + "fontget/internal/shared" + "fontget/internal/testutil" +) + +func TestApplyRemoveOutcomeTrackingFailureSetsHadError(t *testing.T) { + status := &RemovalStatus{} + applyRemoveOutcome(status, &RemoveResult{Success: 1, Failed: 0}, errors.New("removal tracking failed")) + if !status.HadError { + t.Fatal("expected HadError") + } + if status.Failed != 0 { + t.Fatalf("Failed count should stay 0 for tracking-only error, got %d", status.Failed) + } + err := removalExitAfterSummary(status, 1, 0) + if err == nil { + t.Fatal("expected nonzero exit") + } + var displayed *shared.DisplayedError + if !errors.As(err, &displayed) { + t.Fatalf("want AlreadyPrinted, got %T", err) + } +} + +func TestApplyRemoveOutcomeLockFailureSetsHadError(t *testing.T) { + status := &RemovalStatus{} + applyRemoveOutcome(status, &RemoveResult{Failed: 0, Errors: []string{"lock"}}, errors.New("timed out waiting for lock")) + if !status.HadError { + t.Fatal("expected HadError") + } + if err := removalExitAfterSummary(status, 1, 0); err == nil { + t.Fatal("expected nonzero exit") + } +} + +func TestRemovalExitAfterSummarySuccess(t *testing.T) { + if err := removalExitAfterSummary(&RemovalStatus{Removed: 1}, 1, 0); err != nil { + t.Fatal(err) + } +} + +func TestRemoveFontLockFailureSurfacesWithoutDebug(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + a := filepath.Join(fontDir, "Alpha-Regular.ttf") + if err := os.WriteFile(a, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "test.lockfail", + Scope: "user", + Files: []installations.InstalledFontFile{{Path: a, SFNT: installations.SFNTSnapshot{Family: "Alpha", Style: "Regular"}}}, + }); err != nil { + t.Fatal(err) + } + + holdCtx, holdCancel := context.WithCancel(context.Background()) + defer holdCancel() + unlock, err := installations.LockDestination(holdCtx, fontDir) + if err != nil { + t.Fatal(err) + } + defer unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + fm := &removeTrackingFM{dir: fontDir} + result, remErr := removeFont(ctx, "test.lockfail", fm, platform.UserScope, fontDir, nil, mustLoadReg(t), func(string) bool { return true }, nil) + if remErr == nil { + t.Fatal("expected lock failure") + } + status := &RemovalStatus{} + applyRemoveOutcome(status, result, remErr) + if err := removalExitAfterSummary(status, 1, 0); err == nil { + t.Fatal("normal path must exit nonzero after lock failure") + } +} + +func TestRemoveFontFilesTrackingFailureSurfacesWithoutDebug(t *testing.T) { + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + a := filepath.Join(fontDir, "Alpha-Regular.ttf") + if err := os.WriteFile(a, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "test.trackrm", + Scope: "user", + Files: []installations.InstalledFontFile{{Path: a, SFNT: installations.SFNTSnapshot{Family: "Alpha", Style: "Regular"}}}, + }); err != nil { + t.Fatal(err) + } + + fm := &removeThenBreakRegistryFM{removeTrackingFM: &removeTrackingFM{dir: fontDir}, home: home} + removed, _, failed, _, _, err := removeFontFiles(RemoveFontFilesParams{ + Ctx: context.Background(), + MatchingFonts: []string{"Alpha-Regular.ttf"}, + FontManager: fm, + Scope: platform.UserScope, + FontDir: fontDir, + FontID: "test.trackrm", + }) + if err == nil { + t.Fatal("expected tracking failure") + } + if removed != 1 || failed != 0 { + t.Fatalf("removed=%d failed=%d", removed, failed) + } + if !strings.Contains(err.Error(), "tracking") { + t.Fatalf("got %v", err) + } + status := &RemovalStatus{} + applyRemoveOutcome(status, &RemoveResult{Success: removed, Failed: failed}, err) + if err := removalExitAfterSummary(status, 1, 0); err == nil { + t.Fatal("normal path must exit nonzero after tracking failure") + } +} + +type removeThenBreakRegistryFM struct { + *removeTrackingFM + home string +} + +func (m *removeThenBreakRegistryFM) RemoveFont(name string, _ platform.InstallationScope, _ *platform.RemoveFontOptions) error { + err := os.Remove(filepath.Join(m.dir, name)) + cfg := filepath.Join(m.home, ".fontget") + _ = os.RemoveAll(cfg) + _ = os.WriteFile(cfg, []byte("blocked"), 0644) + return err +} + +func mustLoadReg(t *testing.T) *installations.Registry { + t.Helper() + reg, err := installations.Load() + if err != nil { + t.Fatal(err) + } + return reg +} diff --git a/internal/platform/machine_remove.go b/internal/platform/machine_remove.go index 4c9436f..1148ebe 100644 --- a/internal/platform/machine_remove.go +++ b/internal/platform/machine_remove.go @@ -13,10 +13,10 @@ import ( // ErrRegistryValueAbsent means the Fonts registry value was already gone (idempotent OK). var ErrRegistryValueAbsent = errors.New("registry font value absent") -// registryFontValue is a captured machine-scope Fonts registry entry. +// registryFontValue is a captured machine-scope Fonts registry entry (raw bytes + type). type registryFontValue struct { Name string - Data string + Raw []byte Type uint32 Found bool } diff --git a/internal/platform/machine_remove_test.go b/internal/platform/machine_remove_test.go new file mode 100644 index 0000000..e48899f --- /dev/null +++ b/internal/platform/machine_remove_test.go @@ -0,0 +1,154 @@ +//go:build windows + +package platform + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + "syscall" + "testing" +) + +func TestRegSZByteLenUsesUTF16NotUTF8(t *testing.T) { + s := "カフェ.ttf" // multibyte UTF-8; UTF-16 length differs from (len(s)+1)*2 + got, err := regSZByteLen(s) + if err != nil { + t.Fatal(err) + } + u16, err := syscall.UTF16FromString(s) + if err != nil { + t.Fatal(err) + } + want := len(u16) * 2 + if got != want { + t.Fatalf("got %d want %d", got, want) + } + oldBug := (len(s) + 1) * 2 + if got == oldBug { + t.Fatalf("UTF-8-based length %d must not equal UTF-16 length for %q", oldBug, s) + } +} + +func TestUint16SliceAsBytesMatchesUTF16FromString(t *testing.T) { + s := "café/Fonts/顔.ttf" + u16, err := syscall.UTF16FromString(s) + if err != nil { + t.Fatal(err) + } + raw := uint16SliceAsBytes(u16) + if len(raw) != len(u16)*2 { + t.Fatalf("len raw=%d u16=%d", len(raw), len(u16)) + } + // Round-trip first code unit. + if raw[0] != byte(u16[0]) || raw[1] != byte(u16[0]>>8) { + t.Fatalf("endian packing wrong: %v vs %x", raw[:2], u16[0]) + } +} + +func TestRemoveMachineScopedFont_DeleteFailRestoresExactRegistryBytes(t *testing.T) { + u16, err := syscall.UTF16FromString("カフェ.ttf") + if err != nil { + t.Fatal(err) + } + priorRaw := uint16SliceAsBytes(u16) + prior := registryFontValue{Name: "Face.ttf (TrueType)", Raw: priorRaw, Type: REG_SZ, Found: true} + var restored registryFontValue + err = removeMachineScopedFont("Face.ttf", `C:\Fonts\Face.ttf`, machineRemoveOps{ + RemoveGDI: func(string) error { return nil }, + AddGDI: func(string) error { return nil }, + CaptureReg: func(string) (registryFontValue, error) { return prior, nil }, + DeleteReg: func(string) error { return nil }, + RestoreReg: func(v registryFontValue) error { + restored = v + return nil + }, + RemoveFile: func(string) error { return errors.New("access denied") }, + NotifyChange: func() error { return nil }, + }) + if err == nil { + t.Fatal("expected delete failure") + } + if !bytes.Equal(restored.Raw, priorRaw) || restored.Type != REG_SZ || restored.Name != prior.Name { + t.Fatalf("restore must use exact captured bytes: %+v", restored) + } +} + +func TestRemoveMachineScopedFont_RegistryDeleteFailStopsAndRestoresGDI(t *testing.T) { + var gdiAdded, deletedFile bool + err := removeMachineScopedFont("Face.ttf", `C:\Fonts\Face.ttf`, machineRemoveOps{ + RemoveGDI: func(string) error { return nil }, + AddGDI: func(string) error { + gdiAdded = true + return nil + }, + CaptureReg: func(string) (registryFontValue, error) { + return registryFontValue{Name: "n", Raw: []byte{1, 0}, Type: REG_SZ, Found: true}, nil + }, + DeleteReg: func(string) error { return errors.New("access denied") }, + RestoreReg: func(registryFontValue) error { + t.Fatal("should not restore registry when delete never succeeded") + return nil + }, + RemoveFile: func(string) error { + deletedFile = true + return nil + }, + NotifyChange: func() error { return nil }, + }) + if err == nil { + t.Fatal("expected registry failure") + } + if deletedFile { + t.Fatal("must not delete file after registry removal failure") + } + if !gdiAdded { + t.Fatal("must restore GDI") + } +} + +func TestRemoveMachineScopedFont_DeleteFailSurfacesRepairError(t *testing.T) { + err := removeMachineScopedFont("Face.ttf", `C:\Fonts\Face.ttf`, machineRemoveOps{ + RemoveGDI: func(string) error { return nil }, + AddGDI: func(string) error { return errors.New("gdi restore boom") }, + CaptureReg: func(string) (registryFontValue, error) { + return registryFontValue{Name: "n", Raw: []byte{1, 0}, Type: REG_SZ, Found: true}, nil + }, + DeleteReg: func(string) error { return nil }, + RestoreReg: func(registryFontValue) error { return errors.New("reg restore boom") }, + RemoveFile: func(string) error { return errors.New("delete boom") }, + NotifyChange: func() error { return nil }, + }) + if err == nil { + t.Fatal("expected combined error") + } + msg := err.Error() + if !strings.Contains(msg, "delete boom") || !strings.Contains(msg, "restore") { + t.Fatalf("expected primary+repair in err, got %v", err) + } +} + +func TestRemoveMachineScopedFont_AbsentRegistryIsIdempotent(t *testing.T) { + err := removeMachineScopedFont("Face.ttf", `C:\Fonts\Face.ttf`, machineRemoveOps{ + RemoveGDI: func(string) error { return nil }, + AddGDI: func(string) error { return fmt.Errorf("should not restore") }, + CaptureReg: func(string) (registryFontValue, error) { + return registryFontValue{Name: "Face.ttf (TrueType)", Found: false}, nil + }, + DeleteReg: func(string) error { + t.Fatal("delete should be skipped when not found") + return nil + }, + RestoreReg: func(registryFontValue) error { + t.Fatal("restore should be skipped when not found") + return nil + }, + RemoveFile: func(string) error { return os.ErrNotExist }, + NotifyChange: func() error { return nil }, + }) + if err != nil { + t.Fatalf("absent registry + absent file should succeed: %v", err) + } +} diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 58958df..d996e0d 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -403,7 +403,7 @@ func (m *windowsFontManager) captureFontRegistryValue(fontName string) (registry } return registryFontValue{}, err } - return registryFontValue{Name: valueName, Data: data, Type: typ, Found: true}, nil + return registryFontValue{Name: valueName, Raw: data, Type: typ, Found: true}, nil } func (m *windowsFontManager) restoreFontRegistryValue(v registryFontValue) error { @@ -415,7 +415,7 @@ func (m *windowsFontManager) restoreFontRegistryValue(v registryFontValue) error return err } defer regCloseKey.Call(uintptr(key)) - return m.setRegistryValueTyped(key, v.Name, v.Data, v.Type) + return m.setRegistryValueRaw(key, v.Name, v.Raw, v.Type) } // openFontRegistryKey opens the Windows font registry key for writing @@ -445,12 +445,16 @@ func (m *windowsFontManager) openFontRegistryKey() (syscall.Handle, error) { return key, nil } -// setRegistryValue sets a registry value with proper error handling +// setRegistryValue sets a REG_SZ value using the UTF-16 encoded byte length (not UTF-8 len). func (m *windowsFontManager) setRegistryValue(key syscall.Handle, valueName, value string) error { - return m.setRegistryValueTyped(key, valueName, value, REG_SZ) + u16, err := syscall.UTF16FromString(value) + if err != nil { + return fmt.Errorf("failed to convert value to UTF16: %w", err) + } + return m.setRegistryValueRaw(key, valueName, uint16SliceAsBytes(u16), REG_SZ) } -func (m *windowsFontManager) setRegistryValueTyped(key syscall.Handle, valueName, value string, typ uint32) error { +func (m *windowsFontManager) setRegistryValueRaw(key syscall.Handle, valueName string, data []byte, typ uint32) error { logger := logging.GetLogger() valueNamePtr, err := syscall.UTF16PtrFromString(valueName) @@ -459,22 +463,20 @@ func (m *windowsFontManager) setRegistryValueTyped(key syscall.Handle, valueName return fmt.Errorf("failed to convert value name to UTF16: %w", err) } - valuePtr, err := syscall.UTF16PtrFromString(value) - if err != nil { - logger.Error("Failed to convert value to UTF16: %v", err) - return fmt.Errorf("failed to convert value to UTF16: %w", err) - } - if typ == 0 { typ = REG_SZ } + var dataPtr uintptr + if len(data) > 0 { + dataPtr = uintptr(unsafe.Pointer(&data[0])) + } ret, _, err := regSetValueEx.Call( uintptr(key), uintptr(unsafe.Pointer(valueNamePtr)), 0, uintptr(typ), - uintptr(unsafe.Pointer(valuePtr)), - uintptr((len(value)+1)*2), + dataPtr, + uintptr(len(data)), ) if ret != 0 { logger.Error("Failed to set registry value: %v", err) @@ -483,10 +485,31 @@ func (m *windowsFontManager) setRegistryValueTyped(key syscall.Handle, valueName return nil } -func (m *windowsFontManager) queryRegistryValue(key syscall.Handle, valueName string) (string, uint32, error) { +func uint16SliceAsBytes(u []uint16) []byte { + if len(u) == 0 { + return nil + } + b := make([]byte, len(u)*2) + for i, v := range u { + b[i*2] = byte(v) + b[i*2+1] = byte(v >> 8) + } + return b +} + +// regSZByteLen returns the REG_SZ cbData for s (UTF-16 code units including NUL, times 2). +func regSZByteLen(s string) (int, error) { + u16, err := syscall.UTF16FromString(s) + if err != nil { + return 0, err + } + return len(u16) * 2, nil +} + +func (m *windowsFontManager) queryRegistryValue(key syscall.Handle, valueName string) ([]byte, uint32, error) { valueNamePtr, err := syscall.UTF16PtrFromString(valueName) if err != nil { - return "", 0, fmt.Errorf("failed to convert value name to UTF16: %w", err) + return nil, 0, fmt.Errorf("failed to convert value name to UTF16: %w", err) } var typ uint32 var dataLen uint32 @@ -500,14 +523,14 @@ func (m *windowsFontManager) queryRegistryValue(key syscall.Handle, valueName st ) if ret != 0 { if ret == 2 { // ERROR_FILE_NOT_FOUND - return "", 0, ErrRegistryValueAbsent + return nil, 0, ErrRegistryValueAbsent } - return "", 0, fmt.Errorf("failed to query registry value: %w", callErr) + return nil, 0, fmt.Errorf("failed to query registry value: %w", callErr) } if dataLen == 0 { - return "", typ, nil + return nil, typ, nil } - buf := make([]uint16, (dataLen+1)/2) + buf := make([]byte, dataLen) ret, _, callErr = regQueryValueEx.Call( uintptr(key), uintptr(unsafe.Pointer(valueNamePtr)), @@ -518,11 +541,14 @@ func (m *windowsFontManager) queryRegistryValue(key syscall.Handle, valueName st ) if ret != 0 { if ret == 2 { - return "", 0, ErrRegistryValueAbsent + return nil, 0, ErrRegistryValueAbsent } - return "", 0, fmt.Errorf("failed to query registry value: %w", callErr) + return nil, 0, fmt.Errorf("failed to query registry value: %w", callErr) + } + if int(dataLen) < len(buf) { + buf = buf[:dataLen] } - return syscall.UTF16ToString(buf), typ, nil + return buf, typ, nil } // deleteRegistryValue deletes a registry value with proper error handling From ad0d6732aeb9930dc8767c6585b6543735255a2a Mon Sep 17 00:00:00 2001 From: graphixa Date: Fri, 18 Sep 2026 12:11:27 +1000 Subject: [PATCH 20/23] Fix progress tracking and messaging in font operations and improve progress messaging. --- cmd/add.go | 69 ++++++----- cmd/backup.go | 9 +- cmd/browse_model.go | 15 +-- cmd/export.go | 59 +++++----- cmd/import.go | 16 ++- cmd/progress_steps.go | 174 +++++++++++++++++++++++----- cmd/progress_steps_test.go | 152 ++++++++++++++++++++++-- cmd/remove.go | 115 +++++++++--------- internal/components/progress_bar.go | 79 ++++++++----- 9 files changed, 487 insertions(+), 201 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index f4d5f87..8404aba 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -702,7 +702,7 @@ Use --scope to set installation location: send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", - Message: "Downloading from " + fontGroup.SourceName, + Message: DownloadFromSourceMessage(fontGroup.SourceName), }) percent := float64(itemIndex) / float64(len(fontsToInstall)) * 100 @@ -714,15 +714,13 @@ Use --scope to set installation location: if !th.ShouldSend(u, pct) { return } - msg := FormatProgressActivity(u.Phase, u.Detail) - if isInstallPrepPhase(u.Phase) || u.Phase == removeStepRemove { - msg = DownloadFromSourceMessage(fontGroup.SourceName) + if msg := ProgressActivityLabel(u, fontGroup.SourceName); msg != "" { + send(components.ItemUpdateMsg{ + Index: itemIndex, + Status: "in_progress", + Message: msg, + }) } - send(components.ItemUpdateMsg{ - Index: itemIndex, - Status: "in_progress", - Message: msg, - }) send(components.ProgressUpdateMsg{Percent: pct}) } result, err := installFont( @@ -1000,6 +998,7 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin } } unitHigh := 0.0 + sawExtract := false bumpUnit := func(phase, detail string, unitFrac float64) { if unitFrac < unitHigh { unitFrac = unitHigh @@ -1025,6 +1024,7 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin bumpUnit(installStepDownload, "", prepDownloadUnitFrac(doneBytes, totalBytes)) } opts.OnExtractProgress = func(done int, total int) { + sawExtract = true bumpUnit(installStepExtract, "", prepExtractUnitFrac(done, total)) } } @@ -1040,8 +1040,12 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin output.GetDebug().State("repo.DownloadAndExtractFont() failed for variant %s: %v", fontFile.Variant, err) return nil, err } - // Unit complete only after successful validation/extract. - emitPrep(installStepExtract, "", i+1, 0) + // Unit complete only after successful validation; Extracting... only when an archive was processed. + if sawExtract { + emitPrep(installStepExtract, "", i+1, 0) + } else { + emitPrep(installStepDownload, "", i+1, 0) + } allFontPaths = append(allFontPaths, fontPaths...) if len(fontPaths) > 1 { output.GetDebug().State("Extracted %d file(s) from variant: %s", len(fontPaths), fontFile.Variant) @@ -1049,7 +1053,7 @@ func downloadFontVariants(ctx context.Context, fontFiles []repo.FontFile, stagin } if onProgress != nil && n > 0 { - emitPrep(installStepExtract, "", n, 0) + emitPrep(installStepDownload, "", n, 0) } output.GetDebug().State("downloadFontVariants: files=%d extracted=%d total=%dms", len(fontFiles), len(allFontPaths), time.Since(start).Milliseconds()) return allFontPaths, nil @@ -1088,21 +1092,25 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager batchOpts := &platform.InstallFontOptions{SkipPostInstallCacheRefresh: true} total := len(fontPaths) + emitInstallProgress := func(completed int) { + if onProgress == nil || total <= 0 { + return + } + onProgress(ProgressUpdate{ + Phase: installStepInstall, + Kind: ProgressCount, + Done: float64(completed), + Total: float64(total), + }) + } for i, fontPath := range fontPaths { if ctxErr := ctx.Err(); ctxErr != nil { err = ctxErr break } fontDisplayName := filepath.Base(fontPath) - if onProgress != nil && total > 0 { - onProgress(ProgressUpdate{ - Phase: installStepInstall, - Detail: fmt.Sprintf("variants (%d of %d)", i+1, total), - Kind: ProgressCount, - Done: float64(i), - Total: float64(total), - }) - } + // Label shows current (i+1 of total); bar holds completed count until this file succeeds. + emitInstallProgress(i) if fileInfo, statErr := os.Stat(fontPath); statErr == nil { downloadSize += fileInfo.Size() @@ -1122,6 +1130,7 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager break } } + emitInstallProgress(i + 1) continue } } @@ -1220,6 +1229,7 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager break } _ = platform.CommitMutation(mut) + emitInstallProgress(i + 1) if tc != nil && tc.failAfterMutations > 0 && installed >= tc.failAfterMutations { err = fmt.Errorf("injected failure after mutation count %d", installed) @@ -1230,15 +1240,6 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager } } - if onProgress != nil { - onProgress(ProgressUpdate{ - Phase: installStepInstall, - Kind: ProgressCount, - Done: float64(total), - Total: float64(max(total, 1)), - }) - } - if installed > 0 || (err != nil && len(present) > 0) { if onProgress != nil { onProgress(ProgressUpdate{Phase: installStepFinalize, Kind: ProgressFlag, Done: 0, Total: 1}) @@ -1378,8 +1379,12 @@ func installFont( if force { existing := packageBasenamesFromRegistry(fontID, fontDir) if len(existing) > 0 { + forceProgress := onProgress if onProgress != nil { - onProgress(ProgressUpdate{Phase: removeStepRemove, Detail: "existing package", Kind: ProgressCount, Done: 0, Total: float64(len(existing))}) + forceProgress = func(u ProgressUpdate) { + onProgress(remapForceInstallProgress(u)) + } + forceProgress(ProgressUpdate{Phase: installStepForceRemove, Kind: ProgressCount, Done: 0, Total: float64(len(existing))}) } removed, _, remFailed, _, remErrs, remErr := removeFontFiles(RemoveFontFilesParams{ Ctx: ctx, @@ -1389,7 +1394,7 @@ func installFont( FontDir: fontDir, FontID: fontID, IsCriticalSystemFont: shared.IsCriticalSystemFont, - OnProgress: onProgress, + OnProgress: forceProgress, }) if remErr != nil || remFailed > 0 { msg := "Force removal failed" diff --git a/cmd/backup.go b/cmd/backup.go index de5d726..c999596 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -550,10 +550,9 @@ func createBackupZipArchive(sourceFamilyMap map[string]map[string][]fontFileInfo } } - // Update progress after each file for smooth progress bar (if callback provided) + // Update progress after each file (leave headroom for archive finalize). if send != nil && totalFiles > 0 { - percent := float64(processedFiles) / float64(totalFiles) * 100 - send(components.ProgressUpdateMsg{Percent: percent}) + send(components.ProgressUpdateMsg{Percent: OverallBackupPercent(processedFiles, totalFiles, false)}) } } @@ -614,6 +613,10 @@ func createBackupZipArchive(sourceFamilyMap map[string]map[string][]fontFileInfo } } + if send != nil { + send(components.ProgressUpdateMsg{Percent: OverallBackupPercent(processedFiles, totalFiles, true)}) + } + output.GetVerbose().Info("Backup archive created: %d font families, %d files", familyCount, fileCount) output.GetDebug().State("Backup operation complete - Families: %d, Files: %d", familyCount, fileCount) diff --git a/cmd/browse_model.go b/cmd/browse_model.go index 86abc01..80addcc 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -548,7 +548,7 @@ func (m *browseModel) waitForOpMsg() tea.Cmd { func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea.Cmd { m.installing = true m.statusProgress = 0 - m.statusPhase = "Installing" + m.statusPhase = DownloadFromSourceMessage(sourceLabel) m.installPopupFontName = fontName if sourceLabel == "" { sourceLabel = shared.PlaceholderNA @@ -587,10 +587,7 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea if !th.ShouldSend(u, pct) { return } - phase := FormatProgressActivity(u.Phase, u.Detail) - if isInstallPrepPhase(u.Phase) || u.Phase == removeStepRemove { - phase = DownloadFromSourceMessage(sourceLabel) - } + phase := ProgressActivityLabel(u, sourceLabel) ch <- browseOpProgressMsg{phase: phase, percent: pct} } @@ -603,7 +600,7 @@ func (m *browseModel) startInstallByID(fontID, fontName, sourceLabel string) tea func (m *browseModel) startUninstallByID(fontID, fontName, sourceLabel string) tea.Cmd { m.removing = true m.statusProgress = -1 - m.statusPhase = "" + m.statusPhase = progressLabelRemove m.removingFontName = fontName if sourceLabel == "" { sourceLabel = shared.PlaceholderNA @@ -632,7 +629,7 @@ func (m *browseModel) startUninstallByID(fontID, fontName, sourceLabel string) t if !th.ShouldSend(u, pct) { return } - ch <- browseOpProgressMsg{phase: "", percent: pct} + ch <- browseOpProgressMsg{phase: ProgressActivityLabel(u, ""), percent: pct} } installReg, _ := m.cachedInstallRegistry() rr, err := removeFont(ctx, fontID, fm, scope, fontDir, repository, installReg, m.cachedManifestFontIDProbe(), onProgress) @@ -1012,7 +1009,7 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.installing && !m.removing { return m, nil } - if m.statusPhase == "Cancelling..." { + if m.statusPhase == progressLabelCancel { return m, m.waitForOpMsg() } if strings.TrimSpace(msg.phase) != "" { @@ -1059,7 +1056,7 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.opCancel() m.opCancel = nil } - m.statusPhase = "Cancelling..." + m.statusPhase = progressLabelCancel return m, m.waitForOpMsg() } } diff --git a/cmd/export.go b/cmd/export.go index 4cbeb53..e30e119 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -233,16 +233,19 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 1: Collect fonts (0-20% progress) - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Scanning", "")}) - send(components.ProgressUpdateMsg{Percent: 5.0}) + // Prep band: scan → group → match → filter (constant title; no activity thrash). + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepPrep, Kind: ProgressFlag, Done: 0, Total: 1, + })}) output.GetVerbose().Info("Scanning fonts to determine export scope...") fonts, collectErr := collectFonts(scopes, fontManager, "", true) // Suppress verbose - we have our own high-level message if collectErr != nil { return fmt.Errorf("unable to collect fonts: %w", collectErr) } output.GetDebug().State("Total fonts to export: %d", len(fonts)) - send(components.ProgressUpdateMsg{Percent: 20.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepPrep, Kind: ProgressFlag, Done: 0.25, Total: 1, + })}) // Check for cancellation select { @@ -251,11 +254,11 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 2: Group by family (20-30% progress) - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Grouping", "")}) families := groupByFamily(fonts) output.GetVerbose().Info("Grouped into %d font families", len(families)) - send(components.ProgressUpdateMsg{Percent: 30.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepPrep, Kind: ProgressFlag, Done: 0.4, Total: 1, + })}) // Check for cancellation select { @@ -264,15 +267,16 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 3: Match installed fonts to repository (30-50% progress) + // Phase 3: Match installed fonts to repository var names []string for k := range families { names = append(names, k) } sort.Strings(names) - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Matching", "")}) - send(components.ProgressUpdateMsg{Percent: 35.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepPrep, Kind: ProgressFlag, Done: 0.5, Total: 1, + })}) matches, matchErr := cmdutils.MatchInstalledFontsToRepository(names, GetLogger(), shared.IsCriticalSystemFont) if matchErr != nil { // Continue without matches if exportAll is true @@ -281,7 +285,9 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor } matches = make(map[string]*repo.InstalledFontMatch) } - send(components.ProgressUpdateMsg{Percent: 50.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepPrep, Kind: ProgressFlag, Done: 0.75, Total: 1, + })}) // Check for cancellation select { @@ -290,8 +296,6 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 4: Populate match data and filter fonts (50-60% progress) - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Filtering", "")}) populateFontMatchData(families, matches) fontIDGroups, skippedSystem, skippedUnmatched, skippedByFilter := filterFontsForExport(FilterFontsForExportParams{ @@ -313,7 +317,9 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor // Update total items now that we know the count // This will make it show "Exporting Fonts (0 of y)" immediately send(components.TotalItemsUpdateMsg{TotalItems: totalFamilies}) - send(components.ProgressUpdateMsg{Percent: 60.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepPrep, Kind: ProgressFlag, Done: 1, Total: 1, + })}) // Check for cancellation select { @@ -322,8 +328,6 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 5: Perform the actual export operation (60-100% progress) - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Writing", "")}) params := ExportProgressParams{ FontManager: fontManager, Scopes: scopes, @@ -409,15 +413,16 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms } if send != nil { - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Building", "")}) - send(components.ProgressUpdateMsg{Percent: 70.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepWrite, Kind: ProgressCount, Done: 0, Total: 1, + })}) } // Build export manifest manifest, totalVariants := buildExportManifest( params.FontIDGroups, params.MatchFilter, params.SourceFilter, params.OnlyMatched, params.SkippedSystem, params.SkippedUnmatched, params.SkippedByFilter) - // Mark families complete with i/N activity (70–95%) + // Mark families complete within the write band (prep already finished at 40%). if send != nil { n := len(manifest.Fonts) for i, font := range manifest.Fonts { @@ -432,11 +437,9 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms if label == "" && len(font.FamilyNames) > 0 { label = font.FamilyNames[0] } - pct := 70.0 - if n > 0 { - pct = 70.0 + (float64(i+1)/float64(n))*25.0 - } - send(components.ProgressUpdateMsg{Percent: pct}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepWrite, Kind: ProgressCount, Done: float64(i + 1), Total: float64(max(n, 1)), + })}) send(components.ItemUpdateMsg{ Index: i, Name: label, @@ -456,8 +459,9 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms } if send != nil { - send(components.TitleUpdateMsg{Title: FormatProgressActivity("Writing", "manifest")}) - send(components.ProgressUpdateMsg{Percent: 95.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepWrite, Kind: ProgressCount, Done: 1, Total: 1, + })}) } // Write manifest @@ -490,8 +494,7 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms output.GetVerbose().Info("Export file written successfully") if send != nil { - send(components.TitleUpdateMsg{Title: "Exporting Fonts"}) - send(components.ProgressUpdateMsg{Percent: 100.0}) + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{Phase: exportStepDone})}) } return manifest.Fonts, totalVariants, nil diff --git a/cmd/import.go b/cmd/import.go index ba94b5f..55653ab 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -626,7 +626,7 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", - Message: "Downloading from " + fontGroup.SourceName, + Message: DownloadFromSourceMessage(fontGroup.SourceName), }) percent := float64(itemIndex) / float64(len(fontsToInstall)) * 100 @@ -638,15 +638,13 @@ Fonts are installed using their Font IDs. Missing fonts are skipped with a warni if !th.ShouldSend(u, pct) { return } - msg := FormatProgressActivity(u.Phase, u.Detail) - if isInstallPrepPhase(u.Phase) || u.Phase == removeStepRemove { - msg = DownloadFromSourceMessage(fontGroup.SourceName) + if msg := ProgressActivityLabel(u, fontGroup.SourceName); msg != "" { + send(components.ItemUpdateMsg{ + Index: itemIndex, + Status: "in_progress", + Message: msg, + }) } - send(components.ItemUpdateMsg{ - Index: itemIndex, - Status: "in_progress", - Message: msg, - }) send(components.ProgressUpdateMsg{Percent: pct}) } result, err := installFont( diff --git a/cmd/progress_steps.go b/cmd/progress_steps.go index c029ae3..7931f42 100644 --- a/cmd/progress_steps.go +++ b/cmd/progress_steps.go @@ -29,45 +29,121 @@ type ProgressUpdate struct { // ProgressFunc reports progress. Implementations must be lightweight. type ProgressFunc func(ProgressUpdate) -// Legacy phase name constants (UI labels + segment keys). +// Internal phase keys (not user-facing labels). const ( - installStepPrecheck = "Checking installed" - installStepDownload = "Downloading" - installStepExtract = "Extracting" - installStepInstall = "Installing" - installStepFinalize = "Finalizing" - installStepCompleted = "Installed" + installStepPrecheck = "Checking installed" + installStepDownload = "Downloading" + installStepExtract = "Extracting" + installStepForceRemove = "ForceRemoving" + installStepInstall = "Installing" + installStepFinalize = "InstallFinalizing" + installStepCompleted = "Installed" removeStepScan = "Scanning" removeStepRemove = "Removing" - removeStepFinalize = "Finalizing" + removeStepFinalize = "RemoveFinalizing" removeStepCompleted = "Removed" + + exportStepPrep = "ExportPrep" + exportStepWrite = "ExportWrite" + exportStepDone = "ExportDone" + + backupStepFiles = "BackupFiles" + backupStepDone = "BackupDone" +) + +// Exact user-facing activity labels (checklist). +const ( + progressLabelExtract = "Extracting..." + progressLabelRemove = "Removing fonts..." + progressLabelExport = "Exporting font list..." + progressLabelBackup = "Backing up fonts..." + progressLabelCancel = "Cancelling..." ) -// Reserved bar segments for a single item (monotonic; install/remove owns most of the bar). -// Download + extract share one prep band so multi-archive packages never jump backwards. +// Reserved bar segments for a single package/item (monotonic). +// Prep aggregates downloads+extracts; force-remove band is reserved even when unused. const ( - segPrecheckEnd = 0.02 - segPrepEnd = 0.35 // download+extract preparation - segFilesEnd = 0.98 - segFinalizeEnd = 1.00 - segRemoveScanEnd = 0.35 + segPrecheckEnd = 0.02 + segPrepEnd = 0.28 // download+extract preparation + segForceRemoveEnd = 0.38 // force reinstall removal (idle when not force) + segFilesEnd = 0.98 + segFinalizeEnd = 1.00 + segRemoveScanEnd = 0.35 prepDownloadWeight = 0.75 prepExtractWeight = 0.25 + // Export: prep (scan/group/match/filter) then write, then finalize after file flush. + segExportPrepEnd = 0.40 + segExportWriteEnd = 0.98 + + // Backup: file copy band, finalize after archive close. + segBackupFilesEnd = 0.98 + // Aliases: prep band replaced the old split download/extract segments. segDownloadEnd = segPrepEnd segExtractEnd = segPrepEnd ) -// DownloadFromSourceMessage is the only user-facing label during download/extract prep. +// DownloadFromSourceMessage is the user-facing download label. func DownloadFromSourceMessage(sourceName string) string { sourceName = strings.TrimSpace(sourceName) if sourceName == "" { - return installStepDownload + "..." + return "Downloading..." + } + return "Downloading from " + sourceName + "..." +} + +// InstallingVariantMessage is the user-facing install label (1-based current). +func InstallingVariantMessage(current, total int) string { + if total < 1 { + total = 1 + } + if current < 1 { + current = 1 + } + if current > total { + current = total + } + return fmt.Sprintf("Installing variant (%d of %d)...", current, total) +} + +// ProgressActivityLabel returns the exact checklist label for a progress pulse. +// Empty means keep the previous activity text (brief internal steps). +func ProgressActivityLabel(u ProgressUpdate, sourceName string) string { + switch u.Phase { + case installStepDownload: + return DownloadFromSourceMessage(sourceName) + case installStepExtract: + return progressLabelExtract + case installStepInstall: + total := int(u.Total) + if total <= 0 { + return "" + } + // Done is completed count; current file is Done+1 while work is in flight. + cur := int(u.Done) + 1 + if u.Done >= u.Total { + cur = total + } + return InstallingVariantMessage(cur, total) + case installStepForceRemove, removeStepRemove: + return progressLabelRemove + case removeStepScan: + // Brief scan: keep prior label / avoid chatter. + return "" + case installStepPrecheck, installStepFinalize, removeStepFinalize: + return "" + case exportStepPrep, exportStepWrite, exportStepDone: + return progressLabelExport + case backupStepFiles, backupStepDone: + return progressLabelBackup + case installStepCompleted, removeStepCompleted: + return "" + default: + return "" } - return "Downloading from " + sourceName } // isInstallPrepPhase reports download or extract (shared prep band). @@ -75,7 +151,7 @@ func isInstallPrepPhase(phase string) bool { return phase == installStepDownload || phase == installStepExtract } -// FormatProgressActivity builds the status line for progress UIs. +// FormatProgressActivity builds a generic status line (legacy helpers / tests). func FormatProgressActivity(phase, detail string) string { phase = strings.TrimSpace(phase) detail = strings.TrimSpace(detail) @@ -101,7 +177,7 @@ func CountDetail(i, n int, name string) string { } // prepDownloadUnitFrac is progress within one download/extract unit during download [0, prepDownloadWeight]. -// Unknown Content-Length returns 0 (activity via Detail only); never invents completion. +// Unknown Content-Length returns 0 (activity via label only); never invents completion. func prepDownloadUnitFrac(doneBytes, totalBytes int64) float64 { if totalBytes <= 0 { return 0 @@ -140,29 +216,42 @@ func phaseFrac(u ProgressUpdate) float64 { } } -// segmentRange returns [start,end) in 0..1 for a phase label. +// segmentRange returns [start,end) in 0..1 for a phase key. func segmentRange(phase string) (start, end float64) { switch phase { case installStepPrecheck: return 0, segPrecheckEnd case installStepDownload, installStepExtract: return segPrecheckEnd, segPrepEnd - case installStepInstall, removeStepRemove: - return segPrepEnd, segFilesEnd + case installStepForceRemove: + return segPrepEnd, segForceRemoveEnd + case installStepInstall: + return segForceRemoveEnd, segFilesEnd case installStepFinalize: return segFilesEnd, segFinalizeEnd case removeStepScan: return 0, segRemoveScanEnd - case installStepCompleted, removeStepCompleted: + case removeStepRemove: + return segRemoveScanEnd, segFilesEnd + case removeStepFinalize: + return segFilesEnd, segFinalizeEnd + case exportStepPrep: + return 0, segExportPrepEnd + case exportStepWrite: + return segExportPrepEnd, segExportWriteEnd + case exportStepDone, backupStepDone, installStepCompleted, removeStepCompleted: return 1, 1 + case backupStepFiles: + return 0, segBackupFilesEnd default: - return segPrepEnd, segFilesEnd + return segForceRemoveEnd, segFilesEnd } } // itemFrac maps a ProgressUpdate to 0..1 progress within one catalog item. func itemFrac(u ProgressUpdate) float64 { - if u.Phase == installStepCompleted || u.Phase == removeStepCompleted { + if u.Phase == installStepCompleted || u.Phase == removeStepCompleted || + u.Phase == exportStepDone || u.Phase == backupStepDone { return 1 } start, end := segmentRange(u.Phase) @@ -189,6 +278,21 @@ func OverallWorkPercent(itemIndex, itemCount int, u ProgressUpdate) float64 { return shared.Clamp01(overall) * 100 } +// remapForceInstallProgress keeps force-reinstall removal inside the install force-remove +// band. Without this, removeFontFiles' removeStepFinalize maps to 98–100% and the bar +// appears to complete, then install starts again near 38%. +func remapForceInstallProgress(u ProgressUpdate) ProgressUpdate { + switch u.Phase { + case removeStepRemove: + u.Phase = installStepForceRemove + return u + case removeStepFinalize, removeStepScan, removeStepCompleted: + return ProgressUpdate{Phase: installStepForceRemove, Kind: ProgressCount, Done: 1, Total: 1} + default: + return u + } +} + // OverallInstallPercent maps a simple phase fraction for install (used by thin callers). func OverallInstallPercent(itemIndex, itemCount int, phase string, phasePct float64) float64 { return OverallWorkPercent(itemIndex, itemCount, ProgressUpdate{ @@ -209,6 +313,24 @@ func OverallRemovePercent(itemIndex, itemCount int, phase string, phasePct float }) } +// OverallExportPercent maps export work to 0..100 for a single-command bar. +func OverallExportPercent(u ProgressUpdate) float64 { + return OverallWorkPercent(0, 1, u) +} + +// OverallBackupPercent maps backup file work to 0..100; finalized only after archive close. +func OverallBackupPercent(doneFiles, totalFiles int, finalized bool) float64 { + if finalized { + return 100 + } + return OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: backupStepFiles, + Kind: ProgressCount, + Done: float64(doneFiles), + Total: float64(max(totalFiles, 1)), + }) +} + // progressThrottle reduces UI spam for byte pulses while always forwarding phase/detail changes. type progressThrottle struct { lastPhase string diff --git a/cmd/progress_steps_test.go b/cmd/progress_steps_test.go index bc3616d..23cd1a5 100644 --- a/cmd/progress_steps_test.go +++ b/cmd/progress_steps_test.go @@ -7,7 +7,7 @@ import ( ) func TestDownloadFromSourceMessage(t *testing.T) { - if got := DownloadFromSourceMessage("Google Fonts"); got != "Downloading from Google Fonts" { + if got := DownloadFromSourceMessage("Google Fonts"); got != "Downloading from Google Fonts..." { t.Fatalf("got %q", got) } if got := DownloadFromSourceMessage(" "); got != "Downloading..." { @@ -21,6 +21,31 @@ func TestDownloadFromSourceMessage(t *testing.T) { } } +func TestProgressActivityLabel(t *testing.T) { + cases := []struct { + u ProgressUpdate + src string + want string + }{ + {ProgressUpdate{Phase: installStepDownload}, "Google Fonts", "Downloading from Google Fonts..."}, + {ProgressUpdate{Phase: installStepExtract}, "", progressLabelExtract}, + {ProgressUpdate{Phase: installStepInstall, Done: 0, Total: 10}, "", "Installing variant (1 of 10)..."}, + {ProgressUpdate{Phase: installStepInstall, Done: 9, Total: 10}, "", "Installing variant (10 of 10)..."}, + {ProgressUpdate{Phase: installStepInstall, Done: 10, Total: 10}, "", "Installing variant (10 of 10)..."}, + {ProgressUpdate{Phase: installStepForceRemove}, "", progressLabelRemove}, + {ProgressUpdate{Phase: removeStepRemove}, "", progressLabelRemove}, + {ProgressUpdate{Phase: removeStepScan}, "", ""}, + {ProgressUpdate{Phase: installStepPrecheck}, "", ""}, + {ProgressUpdate{Phase: exportStepPrep}, "", progressLabelExport}, + {ProgressUpdate{Phase: backupStepFiles}, "", progressLabelBackup}, + } + for _, tc := range cases { + if got := ProgressActivityLabel(tc.u, tc.src); got != tc.want { + t.Fatalf("phase %q: got %q want %q", tc.u.Phase, got, tc.want) + } + } +} + func TestFormatProgressActivity(t *testing.T) { if got := FormatProgressActivity("Installing", ""); got != "Installing..." { t.Fatalf("got %q", got) @@ -40,21 +65,21 @@ func TestOverallWorkPercent_DownloadThenInstall(t *testing.T) { midDL := OverallWorkPercent(0, 1, ProgressUpdate{ Phase: installStepDownload, Kind: ProgressCount, Done: 0.5, Total: 1, }) - if midDL < 2 || midDL > 35 { - t.Fatalf("mid download %% = %v, want in (2,35)", midDL) + if midDL < 2 || midDL > 28 { + t.Fatalf("mid download %% = %v, want in (2,28)", midDL) } startInst := OverallWorkPercent(0, 1, ProgressUpdate{ Phase: installStepInstall, Kind: ProgressCount, Done: 0, Total: 400, }) - if startInst < 34 || startInst > 36 { - t.Fatalf("install start %% = %v, want ~35", startInst) + if startInst < 37 || startInst > 39 { + t.Fatalf("install start %% = %v, want ~38", startInst) } midInst := OverallWorkPercent(0, 1, ProgressUpdate{ Phase: installStepInstall, Kind: ProgressCount, Done: 200, Total: 400, }) - wantMid := 35 + (98-35)*0.5 + wantMid := segForceRemoveEnd*100 + (segFilesEnd-segForceRemoveEnd)*100*0.5 if midInst < wantMid-2 || midInst > wantMid+2 { t.Fatalf("install mid %% = %v, want ~%v", midInst, wantMid) } @@ -65,12 +90,78 @@ func TestOverallWorkPercent_DownloadThenInstall(t *testing.T) { } } +func TestOverallWorkPercent_ForceRemoveThenInstall(t *testing.T) { + endPrep := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepDownload, Kind: ProgressCount, Done: 1, Total: 1, + }) + midForce := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepForceRemove, Kind: ProgressCount, Done: 0.5, Total: 1, + }) + if midForce <= endPrep { + t.Fatalf("force remove must continue after prep: prep=%v force=%v", endPrep, midForce) + } + startInst := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepInstall, Kind: ProgressCount, Done: 0, Total: 10, + }) + if startInst < midForce { + t.Fatalf("install must not reset below force remove: force=%v install=%v", midForce, startInst) + } +} + func TestOverallWorkPercent_MultiItem(t *testing.T) { got := OverallWorkPercent(1, 2, ProgressUpdate{ Phase: installStepInstall, Kind: ProgressCount, Done: 0, Total: 10, }) - if got < 65 || got > 70 { - t.Fatalf("multi-item %% = %v, want ~67.5", got) + // package 2 starts at 50% + force-remove band (38%) → ~69% + if got < 68 || got > 70 { + t.Fatalf("multi-item %% = %v, want ~69", got) + } + pkg1Done := OverallWorkPercent(0, 2, ProgressUpdate{Phase: installStepCompleted}) + if pkg1Done != 50 { + t.Fatalf("package 1 complete = %v want 50", pkg1Done) + } + if got < pkg1Done { + t.Fatalf("package 2 must continue from package 1 endpoint") + } +} + +func TestForceRemoveFinalizeDoesNotCompleteBar(t *testing.T) { + // Reproduce google.lekton --force: after prep, removal finalize must not hit 100% + // before install, or the bar looks like it completes then restarts. + endPrep := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepDownload, Kind: ProgressCount, Done: 1, Total: 1, + }) + midForce := OverallWorkPercent(0, 1, remapForceInstallProgress(ProgressUpdate{ + Phase: removeStepRemove, Kind: ProgressCount, Done: 1, Total: 2, + })) + endForceFiles := OverallWorkPercent(0, 1, remapForceInstallProgress(ProgressUpdate{ + Phase: removeStepRemove, Kind: ProgressCount, Done: 2, Total: 2, + })) + // Bug: raw removeStepFinalize jumps to ~100%. + rawFinalize := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: removeStepFinalize, Kind: ProgressFlag, Done: 1, Total: 1, + }) + if rawFinalize < 98 { + t.Fatalf("sanity: raw remove finalize should be ~100, got %v", rawFinalize) + } + mappedFinalize := OverallWorkPercent(0, 1, remapForceInstallProgress(ProgressUpdate{ + Phase: removeStepFinalize, Kind: ProgressFlag, Done: 1, Total: 1, + })) + startInstall := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepInstall, Kind: ProgressCount, Done: 0, Total: 2, + }) + + seq := []float64{endPrep, midForce, endForceFiles, mappedFinalize, startInstall} + for i := 1; i < len(seq); i++ { + if seq[i]+0.01 < seq[i-1] { + t.Fatalf("progress reset at step %d: %v → %v (seq=%v)", i, seq[i-1], seq[i], seq) + } + } + if mappedFinalize >= 90 { + t.Fatalf("mapped force finalize must stay in force band, got %v (raw was %v)", mappedFinalize, rawFinalize) + } + if startInstall+0.01 < mappedFinalize { + t.Fatalf("install must not restart below force end: finalize=%v install=%v", mappedFinalize, startInstall) } } @@ -95,6 +186,39 @@ func TestOverallRemovePercent_Bounds(t *testing.T) { } } +func TestOverallExportPercent_NoPrematureComplete(t *testing.T) { + prep := OverallExportPercent(ProgressUpdate{Phase: exportStepPrep, Kind: ProgressFlag, Done: 1, Total: 1}) + if prep < 39 || prep > 41 { + t.Fatalf("prep end = %v want ~40", prep) + } + writeMid := OverallExportPercent(ProgressUpdate{Phase: exportStepWrite, Kind: ProgressCount, Done: 1, Total: 2}) + if writeMid <= prep { + t.Fatalf("write must advance past prep: prep=%v write=%v", prep, writeMid) + } + if writeMid >= 100 { + t.Fatalf("write mid must not be 100: %v", writeMid) + } + done := OverallExportPercent(ProgressUpdate{Phase: exportStepDone}) + if done != 100 { + t.Fatalf("done = %v want 100", done) + } +} + +func TestOverallBackupPercent_FinalizeOnlyAfterClose(t *testing.T) { + mid := OverallBackupPercent(5, 10, false) + if mid < 48 || mid > 50 { + t.Fatalf("mid backup = %v want ~49", mid) + } + allFiles := OverallBackupPercent(10, 10, false) + if allFiles >= 100 { + t.Fatalf("all files archived must leave headroom for finalize: %v", allFiles) + } + done := OverallBackupPercent(10, 10, true) + if done != 100 { + t.Fatalf("finalized = %v want 100", done) + } +} + func TestProgressThrottle(t *testing.T) { var th progressThrottle u := ProgressUpdate{Phase: installStepDownload, Detail: "", Kind: ProgressBytes, Done: 1, Total: 100} @@ -166,3 +290,15 @@ func TestDownloadExtractSharePrepBand(t *testing.T) { t.Fatalf("download/extract must share prep band, got dl=(%v,%v) ex=(%v,%v)", ds, de, es, ee) } } + +func TestSingleVariantDoesNotFillPrepBand(t *testing.T) { + // One of four download units at full unit progress should sit at 25% of prep band, not 100%. + got := OverallWorkPercent(0, 1, ProgressUpdate{ + Phase: installStepDownload, Kind: ProgressCount, Done: 1, Total: 4, + }) + prepSpan := (segPrepEnd - segPrecheckEnd) * 100 + want := segPrecheckEnd*100 + prepSpan*0.25 + if got < want-1 || got > want+1 { + t.Fatalf("unit 1/4 complete = %v want ~%v (must not fill entire prep)", got, want) + } +} diff --git a/cmd/remove.go b/cmd/remove.go index 0ea1f4e..99d17e9 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -505,10 +505,22 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int fontPath := filepath.Join(params.FontDir, matchingFont) display := shared.GetDisplayNameFromFilename(matchingFont) + advanceRemove := func() { + if params.OnProgress != nil && total > 0 { + params.OnProgress(ProgressUpdate{ + Phase: removeStepRemove, + Kind: ProgressCount, + Done: float64(i + 1), + Total: float64(total), + }) + } + } + if params.IsCriticalSystemFont != nil && params.IsCriticalSystemFont(matchingFont) { skipped++ details = append(details, display+" (Skipped - Protected system font)") left = removeBasename(left, matchingFont) + advanceRemove() continue } if _, statErr := os.Stat(fontPath); os.IsNotExist(statErr) { @@ -523,6 +535,7 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int break } } + advanceRemove() continue } @@ -539,6 +552,7 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int break } } + advanceRemove() continue } failed++ @@ -570,6 +584,7 @@ func removeFontFiles(params RemoveFontFilesParams) (removed, skipped, failed int break } } + advanceRemove() } if params.OnProgress != nil { @@ -1941,7 +1956,7 @@ Use --scope to set removal location: Index: item.ItemIndex, Name: item.ProperName, Status: "in_progress", - Message: "", + Message: progressLabelRemove, }) GetLogger().Info("Processing font: %s in %s scope", item.FontName, item.ScopeLabel) @@ -1953,12 +1968,15 @@ Use --scope to set removal location: if !th.ShouldSend(u, pct) { return } - send(components.ItemUpdateMsg{ - Index: item.ItemIndex, - Name: item.ProperName, - Status: "in_progress", - Message: "", - }) + itemMsg := components.ItemUpdateMsg{ + Index: item.ItemIndex, + Name: item.ProperName, + Status: "in_progress", + } + if msg := ProgressActivityLabel(u, ""); msg != "" { + itemMsg.Message = msg + } + send(itemMsg) send(components.ProgressUpdateMsg{Percent: pct}) } result, err := removeFont( @@ -2063,25 +2081,10 @@ Use --scope to set removal location: if len(scopes) == 1 && scopes[0] == platform.UserScope { // Resolve Font ID to font name if needed searchName := resolveFontNameOrID(fontName, r) - // Create progress callback for finding phase (0-50% of total progress) - // Split progress between machine and user scope checks - findingProgress := 0.0 - progressCb := func(percent float64) { - // percent is 0-50 from findFontFamilyFiles, map to 0-25 for each scope - // First scope (machine) uses 0-25%, second scope (user) uses 25-50% - if findingProgress < 25.0 { - // Machine scope: 0-25% - findingProgress = percent * 0.5 // Map 0-50% to 0-25% - } else { - // User scope: 25-50% - findingProgress = 25.0 + (percent * 0.5) // Map 0-50% to 25-50% - } - // Calculate overall progress: finding (0-50%) + base progress from font index - baseProgress := float64(i) / float64(len(foundFonts)) * 100 - totalProgress := baseProgress + (findingProgress / float64(len(foundFonts))) - send(components.ProgressUpdateMsg{Percent: totalProgress}) - } - // Check both scopes efficiently with progress updates + // Finding is label-only — do not drive the bar here (avoids a parallel + // 0–50% scheme that jumps backwards before OverallWorkPercent removal). + progressCb := func(float64) {} + // Check both scopes efficiently machineFonts, werr := findFontFamilyFiles(searchName, fontManager, platform.MachineScope, progressCb) if werr != nil { if log := GetLogger(); log != nil { @@ -2089,7 +2092,6 @@ Use --scope to set removal location: } machineFonts = nil } - findingProgress = 25.0 // Set to 25% after machine scope completes userFonts, werr2 := findFontFamilyFiles(searchName, fontManager, platform.UserScope, progressCb) if werr2 != nil { if log := GetLogger(); log != nil { @@ -2110,7 +2112,6 @@ Use --scope to set removal location: } machineFonts = nil } - findingProgress = 25.0 userFonts, werr2 = findFontFamilyFiles(repoSearchName, fontManager, platform.UserScope, progressCb) if werr2 != nil { if log := GetLogger(); log != nil { @@ -2120,11 +2121,6 @@ Use --scope to set removal location: } } } - // Finding phase complete - ensure we're at 50% for this font - findingProgress = 50.0 - baseProgress := float64(i) / float64(len(foundFonts)) * 100 - totalProgress := baseProgress + (findingProgress / float64(len(foundFonts))) - send(components.ProgressUpdateMsg{Percent: totalProgress}) // Handle different scenarios // Heuristic filename walk often misses Nerd-style installs; installation registry @@ -2136,52 +2132,50 @@ Use --scope to set removal location: fontStatus = StatusFailed statusMessage = "Font not found" status.Failed++ - // Send update with proper name and update progress - // Note: ErrorMessage is set directly, no need to append to allErrors since we continue send(components.ItemUpdateMsg{ Index: i, - Name: properFontName, // Use proper name from map + Name: properFontName, Status: fontStatus, Message: statusMessage, ErrorMessage: "Font not found", }) - // Update progress percentage (finding complete at 50%, this is a failure so stay at 50%) - percent = 50.0 + (float64(i+1) / float64(len(foundFonts)) * 50.0) - send(components.ProgressUpdateMsg{Percent: percent}) - // Continue to next font instead of exiting + send(components.ProgressUpdateMsg{Percent: OverallWorkPercent(i, len(foundFonts), ProgressUpdate{Phase: removeStepCompleted})}) continue } else if len(userFonts) == 0 && len(machineFonts) > 0 { // Font only exists in machine scope status.Skipped++ fontStatus = StatusSkipped statusMessage = "Only installed in machine scope" - // Send update before continuing send(components.ItemUpdateMsg{ Index: i, Name: properFontName, Status: fontStatus, Message: statusMessage, }) - // Update progress percentage (finding complete at 50%, this is a skip so stay at 50%) - percent = 50.0 + (float64(i+1) / float64(len(foundFonts)) * 50.0) - send(components.ProgressUpdateMsg{Percent: percent}) + send(components.ProgressUpdateMsg{Percent: OverallWorkPercent(i, len(foundFonts), ProgressUpdate{Phase: removeStepCompleted})}) continue } // Note: We'll check if font still exists in opposite scope AFTER removal // Don't track here to avoid duplicates - // properFontName already set from fontInfo.ProperName - // Process removal from user scope fontDir := fontManager.GetFontDir(platform.UserScope) + var th progressThrottle onProgress := func(u ProgressUpdate) { - // In this special mode we already drive percent via scan math; omit Removing... labels. - send(components.ItemUpdateMsg{ - Index: i, - Name: properFontName, - Status: "in_progress", - Message: "", - }) + pct := OverallWorkPercent(i, len(foundFonts), u) + if !th.ShouldSend(u, pct) { + return + } + itemMsg := components.ItemUpdateMsg{ + Index: i, + Name: properFontName, + Status: "in_progress", + } + if msg := ProgressActivityLabel(u, ""); msg != "" { + itemMsg.Message = msg + } + send(itemMsg) + send(components.ProgressUpdateMsg{Percent: pct}) } result, err := removeFont( ctx, @@ -2292,12 +2286,15 @@ Use --scope to set removal location: if !th.ShouldSend(u, pct) { return } - send(components.ItemUpdateMsg{ - Index: i, - Name: properFontName, - Status: "in_progress", - Message: "", - }) + itemMsg := components.ItemUpdateMsg{ + Index: i, + Name: properFontName, + Status: "in_progress", + } + if msg := ProgressActivityLabel(u, ""); msg != "" { + itemMsg.Message = msg + } + send(itemMsg) send(components.ProgressUpdateMsg{Percent: pct}) } result, err := removeFont( diff --git a/internal/components/progress_bar.go b/internal/components/progress_bar.go index dc16b73..4e87db4 100644 --- a/internal/components/progress_bar.go +++ b/internal/components/progress_bar.go @@ -201,7 +201,9 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Items[msg.Index].Name = msg.Name } m.Items[msg.Index].Status = msg.Status - m.Items[msg.Index].StatusMessage = msg.Message + if msg.Message != "" { + m.Items[msg.Index].StatusMessage = msg.Message + } if msg.ErrorMessage != "" { m.Items[msg.Index].ErrorMessage = msg.ErrorMessage } @@ -257,8 +259,11 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.quitting = true m.err = msg.err - // Ensure progress is at 100% when operation completes - cmd := m.ProgressBar.SetPercent(1.0) + // Reach 100% only on successful completion — never fabricate progress on failure/cancel. + var cmd tea.Cmd + if msg.err == nil { + cmd = m.ProgressBar.SetPercent(1.0) + } // For progress bars without items, quit immediately (no delay needed) // For progress bars with items, show final state briefly before quitting if m.TotalItems == 0 { @@ -268,12 +273,17 @@ func (m ProgressBarModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Show final state with items, then quit after a brief delay // Reduced from 2s to 300ms for better responsiveness - return m, tea.Batch( - cmd, - tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg { - return quitMsg{} - }), - ) + if cmd != nil { + return m, tea.Batch( + cmd, + tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg { + return quitMsg{} + }), + ) + } + return m, tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg { + return quitMsg{} + }) case quitMsg: // Handle explicit quit message @@ -635,26 +645,41 @@ func RunProgressBar(title string, items []OperationItem, verboseMode bool, debug func runPlainProgressBar(items []OperationItem, operation func(send func(msg tea.Msg), cancelChan <-chan struct{}) error) error { cancelChan := make(chan struct{}) + trace := os.Getenv("FONTGET_PROGRESS_TRACE") == "1" + var lastPct float64 = -1 send := func(msg tea.Msg) { - update, ok := msg.(ItemUpdateMsg) - if !ok || update.Index < 0 || update.Index >= len(items) { - return - } - name := items[update.Index].Name - if update.Name != "" { - name = update.Name - } - switch update.Status { - case "failed": - if update.ErrorMessage != "" { - fmt.Printf("%s: failed: %s\n", name, update.ErrorMessage) - } else { - fmt.Printf("%s: failed\n", name) + switch update := msg.(type) { + case ProgressUpdateMsg: + if trace { + fmt.Fprintf(os.Stderr, "[progress] %5.1f%%\n", update.Percent) + if lastPct >= 0 && update.Percent+0.05 < lastPct { + fmt.Fprintf(os.Stderr, "[progress] RESET %.1f%% → %.1f%%\n", lastPct, update.Percent) + } + lastPct = update.Percent + } + case ItemUpdateMsg: + if update.Index < 0 || update.Index >= len(items) { + return + } + name := items[update.Index].Name + if update.Name != "" { + name = update.Name + } + if trace && update.Message != "" { + fmt.Fprintf(os.Stderr, "[progress] %s — %s\n", name, update.Message) + } + switch update.Status { + case "failed": + if update.ErrorMessage != "" { + fmt.Printf("%s: failed: %s\n", name, update.ErrorMessage) + } else { + fmt.Printf("%s: failed\n", name) + } + case "completed": + fmt.Printf("%s: installed\n", name) + case "skipped": + fmt.Printf("%s: skipped\n", name) } - case "completed": - fmt.Printf("%s: installed\n", name) - case "skipped": - fmt.Printf("%s: skipped\n", name) } } return operation(send, cancelChan) From 7d8c26acd70f8fbe7e2435eadb74153afb1cbe8e Mon Sep 17 00:00:00 2001 From: graphixa Date: Fri, 18 Sep 2026 12:54:24 +1000 Subject: [PATCH 21/23] Remove obsolete segment aliases in progress_steps.go --- cmd/progress_steps.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cmd/progress_steps.go b/cmd/progress_steps.go index 7931f42..6809719 100644 --- a/cmd/progress_steps.go +++ b/cmd/progress_steps.go @@ -80,10 +80,6 @@ const ( // Backup: file copy band, finalize after archive close. segBackupFilesEnd = 0.98 - - // Aliases: prep band replaced the old split download/extract segments. - segDownloadEnd = segPrepEnd - segExtractEnd = segPrepEnd ) // DownloadFromSourceMessage is the user-facing download label. From 0179c41378a6d5519c3541499666ca221e8b3316 Mon Sep 17 00:00:00 2001 From: graphixa Date: Fri, 18 Sep 2026 13:01:34 +1000 Subject: [PATCH 22/23] Fixed proper cancellation handling after a successful skip+persist. --- cmd/add.go | 6 +++ cmd/cancel_contract_test.go | 82 ++++++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 8404aba..e8511e2 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1131,6 +1131,9 @@ func installDownloadedFonts(ctx context.Context, fontPaths []string, fontManager } } emitInstallProgress(i + 1) + if tc != nil && tc.afterTrackedSkip != nil { + tc.afterTrackedSkip() + } continue } } @@ -1271,6 +1274,9 @@ type installTestControl struct { failRegister bool failAfterMutations int // fail when len(mutations) >= N; 0 = off failProvenance bool + // afterTrackedSkip runs after a successful skip+persist. Tests cancel ctx here so the + // next loop iteration sees ctx.Err() before processing the following file. + afterTrackedSkip func() } // buildInstallResult builds InstallResult from installation outcomes diff --git a/cmd/cancel_contract_test.go b/cmd/cancel_contract_test.go index 6725b77..bb2eb49 100644 --- a/cmd/cancel_contract_test.go +++ b/cmd/cancel_contract_test.go @@ -290,6 +290,8 @@ func TestTrackingFailureStopsBeforeNextFile(t *testing.T) { } func TestInstallRetryPreservesRetainedInventory(t *testing.T) { + // Critical timing: cancel after skipping A (tracking update done), before B is processed. + // Cancelling after B would re-add B and hide inventory-loss bugs on skip+persist of A. home := t.TempDir() testutil.SetHome(t, home) fontDir := t.TempDir() @@ -299,40 +301,64 @@ func TestInstallRetryPreservesRetainedInventory(t *testing.T) { } t.Cleanup(func() { _ = staging.Cleanup() }) - write := func(name, fam, style string) string { + pathA := filepath.Join(fontDir, "Alpha-Regular.ttf") + pathB := filepath.Join(fontDir, "Beta-Regular.ttf") + if err := os.WriteFile(pathA, testutil.MinimalTTF("Alpha", "Regular"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathB, testutil.MinimalTTF("Beta", "Regular"), 0644); err != nil { + t.Fatal(err) + } + expected := []string{"Alpha-Regular.ttf", "Beta-Regular.ttf", "Gamma-Regular.ttf"} + if err := installations.UpsertInstallation(installations.UpsertParams{ + FontID: "test.retry", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: pathA, SFNT: installations.SFNTSnapshot{Family: "Alpha", Style: "Regular"}}, + {Path: pathB, SFNT: installations.SFNTSnapshot{Family: "Beta", Style: "Regular"}}, + }, + Status: installations.StatusIncompleteInstall, + Remaining: []string{"Gamma-Regular.ttf"}, + }); err != nil { + t.Fatal(err) + } + + writeStage := func(name, fam, style string) string { p := filepath.Join(staging.Root, name) if err := os.WriteFile(p, testutil.MinimalTTF(fam, style), 0644); err != nil { t.Fatal(err) } return p } - pathA := write("Alpha-Regular.ttf", "Alpha", "Regular") - pathB := write("Beta-Regular.ttf", "Beta", "Regular") - pathC := write("Gamma-Regular.ttf", "Gamma", "Regular") - expected := []string{"Alpha-Regular.ttf", "Beta-Regular.ttf", "Gamma-Regular.ttf"} + stageA := writeStage("Alpha-Regular.ttf", "Alpha", "Regular") + stageB := writeStage("Beta-Regular.ttf", "Beta", "Regular") + stageC := writeStage("Gamma-Regular.ttf", "Gamma", "Regular") - ctx1, cancel1 := context.WithCancel(context.Background()) - fm1 := &cancelAfterInstallFM{copyFontManager: ©FontManager{dir: fontDir}, cancel: cancel1} - track1 := newInstallTracker("test.retry", nil, platform.UserScope, fontDir, expected) - _, _, _, _, _, _, _, err = installDownloadedFonts(ctx1, []string{pathA, pathB, pathC}, fm1, platform.UserScope, fontDir, false, nil, nil, track1) - if err == nil { - t.Fatal("expected cancel after A") + ctx, cancel := context.WithCancel(context.Background()) + fm := ©FontManager{dir: fontDir} + track := newInstallTracker("test.retry", nil, platform.UserScope, fontDir, expected) + tc := &installTestControl{ + afterTrackedSkip: cancel, // fire only after A's skip+persist; B must not start } - - // Restage B/C (A already installed; staging copies are consumed). - pathB = write("Beta-Regular.ttf", "Beta", "Regular") - pathC = write("Gamma-Regular.ttf", "Gamma", "Regular") - pathA2 := write("Alpha-Regular.ttf", "Alpha", "Regular") - - ctx2, cancel2 := context.WithCancel(context.Background()) - fm2 := &cancelAfterInstallFM{copyFontManager: ©FontManager{dir: fontDir}, cancel: cancel2} - track2 := newInstallTracker("test.retry", nil, platform.UserScope, fontDir, expected) - _, skipped, _, _, _, _, _, err := installDownloadedFonts(ctx2, []string{pathA2, pathB, pathC}, fm2, platform.UserScope, fontDir, false, nil, nil, track2) + _, skipped, _, _, _, _, _, err := installDownloadedFonts( + ctx, []string{stageA, stageB, stageC}, fm, platform.UserScope, fontDir, false, nil, tc, track) if err == nil { - t.Fatal("expected cancel after B") + t.Fatal("expected cancellation after skipping A") + } + if !IsCancelErr(err) { + t.Fatalf("want cancel error, got %v", err) } if skipped != 1 { - t.Fatalf("expected skip A, got skipped=%d", skipped) + t.Fatalf("expected skip A only, got skipped=%d", skipped) + } + if _, statErr := os.Stat(pathA); statErr != nil { + t.Fatalf("A must still exist: %v", statErr) + } + if _, statErr := os.Stat(pathB); statErr != nil { + t.Fatalf("B must still exist: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(fontDir, "Gamma-Regular.ttf")); !os.IsNotExist(statErr) { + t.Fatal("C must not have been installed") } reg, loadErr := installations.Load() @@ -349,7 +375,7 @@ func TestInstallRetryPreservesRetainedInventory(t *testing.T) { have[strings.ToLower(b)] = true } if !have["alpha-regular.ttf"] || !have["beta-regular.ttf"] { - t.Fatalf("A and B must remain tracked, got %#v", bases) + t.Fatalf("A and B must remain tracked after skip+cancel, got %#v", bases) } if have["gamma-regular.ttf"] { t.Fatal("C must not be tracked yet") @@ -361,7 +387,7 @@ func TestInstallRetryPreservesRetainedInventory(t *testing.T) { removed, _, _, _, _, remErr := removeFontFiles(RemoveFontFilesParams{ Ctx: context.Background(), MatchingFonts: bases, - FontManager: &removeTrackingFM{dir: fontDir}, + FontManager: ©FontManager{dir: fontDir}, Scope: platform.UserScope, FontDir: fontDir, FontID: "test.retry", @@ -372,6 +398,12 @@ func TestInstallRetryPreservesRetainedInventory(t *testing.T) { if removed != 2 { t.Fatalf("removal must remove A and B, got %d", removed) } + if _, statErr := os.Stat(pathA); !os.IsNotExist(statErr) { + t.Fatal("A must be removed from disk") + } + if _, statErr := os.Stat(pathB); !os.IsNotExist(statErr) { + t.Fatal("B must be removed from disk") + } reg, _ = installations.Load() if reg.FindByFontID("test.retry") != nil { t.Fatal("package record must be cleared after full removal") From 1c2d7c6a79e068d3d8459fc3f883a78207ffe6d9 Mon Sep 17 00:00:00 2001 From: graphixa Date: Fri, 18 Sep 2026 13:11:37 +1000 Subject: [PATCH 23/23] Add tests for font installation with force replace under single lock --- cmd/add_reliability_test.go | 120 ++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/cmd/add_reliability_test.go b/cmd/add_reliability_test.go index 972ecc1..b8033b6 100644 --- a/cmd/add_reliability_test.go +++ b/cmd/add_reliability_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "fontget/internal/installations" "fontget/internal/platform" "fontget/internal/repo" "fontget/internal/shared" @@ -364,3 +365,122 @@ func TestInstallProvenanceFailRollsBackCurrentFile(t *testing.T) { t.Fatalf("provenance failure left installed file %s", e.Name()) } } + +// recordingFontManager records remove/install order for force-replace lock regressions. +type recordingFontManager struct { + *copyFontManager + ops []string +} + +func (m *recordingFontManager) RemoveFont(fontName string, scope platform.InstallationScope, opts *platform.RemoveFontOptions) error { + m.ops = append(m.ops, "remove:"+fontName) + return m.copyFontManager.RemoveFont(fontName, scope, opts) +} + +func (m *recordingFontManager) InstallFont(fontPath string, scope platform.InstallationScope, force bool, opts *platform.InstallFontOptions) error { + m.ops = append(m.ops, "install:"+filepath.Base(fontPath)) + return m.copyFontManager.InstallFont(fontPath, scope, force, opts) +} + +func TestInstallFontForceReplaceTrackedPackageUnderSingleLock(t *testing.T) { + // Regression: force must not re-acquire LockDestination while installFont already holds it. + // Nested lock waits on itself until the short deadline; production lock timeout is 30s. + home := t.TempDir() + testutil.SetHome(t, home) + fontDir := t.TempDir() + + oldPayload := testutil.MinimalTTF("OldFace", "Regular") + newPayload := testutil.MinimalTTF("NewFace", "Bold") + if bytes.Equal(oldPayload, newPayload) { + t.Fatal("fixtures must differ") + } + + existing := filepath.Join(fontDir, "Face.ttf") + if err := os.WriteFile(existing, oldPayload, 0644); err != nil { + t.Fatal(err) + } + if err := installations.RecordInstallation(installations.RecordParams{ + FontID: "test.force-lock", + Scope: "user", + Files: []installations.InstalledFontFile{ + {Path: existing, SFNT: installations.SFNTSnapshot{Family: "OldFace", Style: "Regular"}}, + }, + }); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "font/ttf") + _, _ = w.Write(newPayload) + })) + t.Cleanup(srv.Close) + + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + + fm := &recordingFontManager{copyFontManager: ©FontManager{dir: fontDir}} + files := []repo.FontFile{{ + Name: "NewFace", + Variant: "Bold", + Path: "Face.ttf", + DownloadURL: srv.URL + "/Face.ttf", + }} + + // Shorter than the 30s production lock wait so nested-lock regressions fail promptly. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + res, err := installFont(ctx, files, "test.force-lock", fm, platform.UserScope, true, fontDir, staging, true, nil, nil) + if err != nil { + t.Fatalf("force replace under single lock: %v", err) + } + if res == nil || res.Status != InstallStatusCompleted || res.Success != 1 { + t.Fatalf("expected completed force install: %+v", res) + } + if len(fm.ops) != 2 || !strings.HasPrefix(fm.ops[0], "remove:") || !strings.HasPrefix(fm.ops[1], "install:") { + t.Fatalf("want remove then install, got %#v", fm.ops) + } + if fm.ops[0] != "remove:Face.ttf" { + t.Fatalf("must remove tracked Face.ttf first, got %q", fm.ops[0]) + } + installedBase := strings.TrimPrefix(fm.ops[1], "install:") + if installedBase == "" { + t.Fatal("missing install basename") + } + + if _, statErr := os.Stat(existing); !os.IsNotExist(statErr) { + t.Fatal("old tracked Face.ttf must be gone after force remove") + } + dest := filepath.Join(fontDir, installedBase) + got, readErr := os.ReadFile(dest) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(got, newPayload) { + t.Fatal("destination must contain replacement content") + } + + reg, loadErr := installations.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + inst := reg.FindByFontID("test.force-lock") + if inst == nil || inst.IsIncomplete() { + t.Fatalf("expected complete tracked install: %+v", inst) + } + bases := inst.BasenamesForDir(fontDir) + if len(bases) != 1 || !strings.EqualFold(bases[0], installedBase) { + t.Fatalf("registry files: %#v want %q", bases, installedBase) + } + + lockCtx, lockCancel := context.WithTimeout(context.Background(), time.Second) + defer lockCancel() + unlock, lockErr := installations.LockDestination(lockCtx, fontDir) + if lockErr != nil { + t.Fatalf("destination lock must be free after force install: %v", lockErr) + } + unlock() +}