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/add.go b/cmd/add.go index 794bcc2..e8511e2 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -16,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" @@ -213,13 +213,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 @@ -316,13 +310,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 @@ -467,13 +455,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 @@ -486,10 +468,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 +493,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 +511,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 +556,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 +585,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 +597,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,86 +642,137 @@ 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 + var incompleteCancelIDs []string - // 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 + for j := itemIndex; j < len(fontsToInstall); j++ { + incompleteCancelIDs = append(incompleteCancelIDs, fontsToInstall[j].FontID) + } + return shared.ErrOperationCancelled + } + send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", - Message: "Downloading from " + fontGroup.SourceName, + Message: DownloadFromSourceMessage(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 - 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 = "Downloading from " + 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: OverallInstallPercent(itemIndex, len(fontsToInstall), step, stepPct), - }) + send(components.ProgressUpdateMsg{Percent: pct}) } 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, + nil, ) if err != nil { - status.Failed += result.Failed + 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 { + result.Status = InstallStatusFailed + } + if result.Failed == 0 && result.Success == 0 { + status.Failed++ + } else { + status.Failed += result.Failed + status.Installed += result.Success + status.Skipped += result.Skipped + } + 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{ @@ -756,32 +784,16 @@ Use --scope to set installation location: 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 +805,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,14 +822,13 @@ 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)}) + send(components.ProgressUpdateMsg{Percent: OverallWorkPercent(itemIndex, len(fontsToInstall), ProgressUpdate{Phase: installStepCompleted})}) } return nil @@ -833,23 +836,20 @@ Use --scope to set installation location: ) if progressErr != nil { - // Check if it was a cancellation - if errors.Is(progressErr, shared.ErrOperationCancelled) { - fmt.Printf("%s\n", ui.WarningText.Render("Installation cancelled.")) - fmt.Println() - return nil // Don't return error for cancellation + if errors.Is(progressErr, shared.ErrOperationCancelled) || cancelled { + 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 } - // 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 +857,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 }, } @@ -894,122 +901,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( - fontGroup.Fonts, - fontGroup.FontID, - fontManager, - installScope, - force, - fontDir, - false, // debug path: allow per-file verbose download lines - 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 { @@ -1067,184 +958,325 @@ 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(fontFiles []repo.FontFile, tempDir string, archiveSourcePrefix, archiveFontID string, downloadOpts *repo.DownloadFontOptions, onProgress StepProgressFunc) ([]string, error) { +// 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 - // Download each variant - only log errors and unusual cases - 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 onProgress != nil && total > 0 { - onProgress(installStepDownload, float64(i)/float64(total)) + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + } + unitHigh := 0.0 + sawExtract := false + 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{} + } 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)) - } + bumpUnit(installStepDownload, "", prepDownloadUnitFrac(doneBytes, totalBytes)) } opts.OnExtractProgress = func(done int, total int) { - if total > 0 { - 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 + sawExtract = true + bumpUnit(installStepExtract, "", prepExtractUnitFrac(done, total)) } } + 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 } + // 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...) - // 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) } } - if onProgress != nil { - onProgress(installStepDownload, 1) - onProgress(installStepExtract, 1) + if onProgress != nil && 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 } -// 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) { +// 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 { + 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 + } + + 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) + 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 onProgress != nil && total > 0 { - onProgress(installStepInstall, float64(i)/float64(total)) + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + break } fontDisplayName := filepath.Base(fontPath) + // Label shows current (i+1 of total); bar holds completed count until this file succeeds. + emitInstallProgress(i) - // 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) + present = append(present, fontDisplayName) + if track != nil { + if trackErr := track.persistInstallState(present, nil); trackErr != nil { + err = fmt.Errorf("installation tracking failed: %w", trackErr) + break + } + } + emitInstallProgress(i + 1) + if tc != nil && tc.afterTrackedSkip != nil { + tc.afterTrackedSkip() + } continue } } - // Install the font (defer OS cache / Windows font notification until after batch) + var mut platform.FileMutation + batchOpts.Mutation = &mut + if tc != nil && tc.failRegister { + 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 installErr != nil { - // Actual installation failure - os.Remove(fontPath) // Clean up temp file + _ = os.Remove(fontPath) + if mut.DestPath != "" { + _ = platform.RollbackMutation(mut) + } 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 + if track != nil && len(present) > 0 { + _ = track.persistInstallState(present, errs) + } + 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) + _ = platform.RollbackMutation(mut) 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 + if track != nil && len(present) > 0 { + _ = track.persistInstallState(present, errs) + } + break } } output.GetDebug().State("Successfully installed font: %s", fontDisplayName) - - // Clean up temp file - os.Remove(fontPath) + _ = 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) + emitInstallProgress(i + 1) - if onProgress != nil { - onProgress(installStepInstall, 1) + 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 + } } - // Single cache refresh / font-change notification after all copies (avoids pkill fontd / fc-cache / WM_FONTCHANGE per file) - if installed > 0 { + if installed > 0 || (err != nil && len(present) > 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() 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) } } if onProgress != nil { - onProgress(installStepFinalize, 1) + onProgress(ProgressUpdate{Phase: installStepFinalize, Kind: ProgressFlag, Done: 1, Total: 1}) } } - // 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 +} + +// 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 + // 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 @@ -1264,51 +1296,41 @@ 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, fontID string, fontManager platform.FontManager, installScope platform.InstallationScope, force bool, 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}) } - // 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) + onProgress(ProgressUpdate{Phase: installStepPrecheck, Kind: ProgressFlag, Done: 1, Total: 1}) + onProgress(ProgressUpdate{Phase: installStepCompleted}) } - // 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 { @@ -1318,157 +1340,126 @@ func installFont( } } if onProgress != nil { - onProgress(installStepPrecheck, 1) + onProgress(ProgressUpdate{Phase: installStepPrecheck, Kind: ProgressFlag, Done: 1, Total: 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) + onProgress(ProgressUpdate{Phase: installStepDownload, Kind: ProgressCount, Done: 0, Total: float64(max(1, len(fontFiles)))}) } - 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 } + if err := ctx.Err(); err != nil { + return buildInstallResult(InstallStatusFailed, msgDownloadCancelledShort, 0, 0, len(fontFiles), nil, nil, 0), err + } - // 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() + + expected := make([]string, 0, len(allFontPaths)) + for _, p := range allFontPaths { + expected = append(expected, filepath.Base(p)) + } + track := newInstallTracker(fontID, fontFiles, installScope, fontDir, expected) + + if force { + existing := packageBasenamesFromRegistry(fontID, fontDir) + if len(existing) > 0 { + forceProgress := onProgress + if onProgress != nil { + 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, + MatchingFonts: existing, + FontManager: fontManager, + Scope: installScope, + FontDir: fontDir, + FontID: fontID, + IsCriticalSystemFont: shared.IsCriticalSystemFont, + OnProgress: forceProgress, + }) + 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 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") + } - // 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) 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) { - if fontID == "" || result == nil { - return - } - if result.Status != InstallStatusCompleted || result.Success <= 0 || result.Failed != 0 { - return - } - if result.Success > len(result.Details) { - output.GetDebug().Warning("installation registry: details shorter than success count, skipping record") - return - } - catalogName := "" - variantByBasename := make(map[string]string) - for _, ff := range fontFiles { - b := filepath.Base(strings.TrimSpace(ff.Path)) - if b == "" { +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 } - 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 { - output.GetDebug().Error("installation registry: incomplete metadata (%d/%d faces); not updating registry for %q", len(files), nonEmptyBasenames, fontID) - return - } - if len(files) == 0 { - return - } - 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) - } - err := 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) + out = append(out, s) } + return out } -// makeUserFriendlyError converts technical error messages to user-friendly explanations func makeUserFriendlyError(fontName string, err error) string { errStr := strings.ToLower(err.Error()) @@ -1493,86 +1484,54 @@ func makeUserFriendlyError(fontName string, err error) string { return fmt.Sprintf("%s could not be installed. Check logs for details.", fontName) } -// 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, " ", "") @@ -1585,7 +1544,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 } @@ -1595,6 +1553,39 @@ 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 { + 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))) { + 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/add_reliability_test.go b/cmd/add_reliability_test.go new file mode 100644 index 0000000..b8033b6 --- /dev/null +++ b/cmd/add_reliability_test.go @@ -0,0 +1,486 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "fontget/internal/installations" + "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 + registered map[string]bool +} + +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{} + } + 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") + } + 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 TestInstallKeepsCompletedFileAfterInjectedFailure(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) + } + + 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 installed != 1 { + t.Fatalf("expected 1 completed file kept, got %d", installed) + } + if _, statErr := os.Stat(filepath.Join(fontDir, "Alpha-Regular.ttf")); statErr != nil { + t.Fatalf("completed file must remain: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(fontDir, "Beta-Regular.ttf")); !os.IsNotExist(statErr) { + t.Fatal("second file must not have been installed") + } +} + +func TestInstallForceReplaceNoBackup(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) + } + 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") + } + if err := platform.RollbackMutation(mut); err != nil { + t.Fatal(err) + } + got, _ = os.ReadFile(dst) + if !bytes.Equal(got, neu) { + t.Fatal("without backup, rollback leaves replaced 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 TestInstallKeepsCompletedFilesAfterLaterInjectedFailure(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) + } + 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 installed != 2 || len(mutations) < 2 { + t.Fatalf("both files should complete before stop: installed=%d mutations=%d", installed, len(mutations)) + } + 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) + } + } +} + +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) + } + _, _, _, _, _, _, 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 len(mutations) != 0 { + t.Fatalf("failed file should not remain in mutations: %d", len(mutations)) + } + entries, _ := os.ReadDir(fontDir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + t.Fatalf("register failure left %s", e.Name()) + } +} + +func TestInstallProvenanceFailRollsBackCurrentFile(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() }) + 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, &installTestControl{failProvenance: true}) + 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()) + } +} + +// 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() +} 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.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 292ffeb..80addcc 100644 --- a/cmd/browse_model.go +++ b/cmd/browse_model.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "strings" "sync" @@ -117,7 +118,8 @@ type browseModel struct { statusProgress float64 statusPhase string - opMsgCh <-chan tea.Msg + opMsgCh <-chan tea.Msg + opCancel context.CancelFunc debounceGen int @@ -415,13 +417,23 @@ func browseNormalizeSourceLabel(s string) string { return s } -func browseResultFromInstall(fontName, source string, msg installFinishedMsg) (title string, errorTitle bool, body string) { +// 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 == "" { fontName = shared.PlaceholderNA } source = browseNormalizeSourceLabel(source) if msg.err != nil { + 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()) } if msg.result == nil { @@ -450,6 +462,10 @@ func browseResultFromUninstall(fontName string, installScope platform.Installati fontName = shared.PlaceholderNA } if msg.err != nil { + 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()) } if msg.result == nil { @@ -532,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 @@ -544,10 +560,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} @@ -558,12 +578,21 @@ 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 + } + phase := ProgressActivityLabel(u, sourceLabel) + ch <- browseOpProgressMsg{phase: phase, percent: pct} } - ir, ierr := installFont(res.Fonts, res.FontID, fm, scope, force, fontDir, true, onProgress) - ch <- installFinishedMsg{result: ir, err: ierr, fontID: fontID} + ir, ierr := installFont(ctx, res.Fonts, res.FontID, fm, scope, force, fontDir, nil, true, onProgress, nil) + ch <- installFinishedMsg{result: ir, err: browseKeepOpErr(ierr), fontID: fontID} }() return m.waitForOpMsg() } @@ -571,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 = "Removing" + m.statusPhase = progressLabelRemove m.removingFontName = fontName if sourceLabel == "" { sourceLabel = shared.PlaceholderNA @@ -583,16 +612,28 @@ 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: ProgressActivityLabel(u, ""), percent: pct} } installReg, _ := m.cachedInstallRegistry() - rr, err := removeFont(fontID, fm, scope, fontDir, repository, installReg, m.cachedManifestFontIDProbe(), onProgress) - ch <- uninstallFinishedMsg{result: rr, err: err, fontID: fontID} + rr, err := removeFont(ctx, fontID, fm, scope, fontDir, repository, installReg, m.cachedManifestFontIDProbe(), onProgress) + ch <- uninstallFinishedMsg{result: rr, err: browseKeepOpErr(err), fontID: fontID} }() return m.waitForOpMsg() } @@ -968,6 +1009,9 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.installing && !m.removing { return m, nil } + if m.statusPhase == progressLabelCancel { + return m, m.waitForOpMsg() + } if strings.TrimSpace(msg.phase) != "" { m.statusPhase = msg.phase } @@ -982,6 +1026,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() @@ -996,15 +1041,24 @@ func (m *browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusProgress = 0 m.statusPhase = "" m.opMsgCh = nil - title, errTitle, body := browseResultFromInstall(fontName, source, msg) + m.opCancel = nil + title, errTitle, body := browseResultFromInstall(fontName, source, msg, m.installScope, m.force) m.openResultModal(title, errTitle, body) cmd := m.syncTableDimensions() return m, 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 = progressLabelCancel + return m, m.waitForOpMsg() + } } return m, nil } @@ -1164,9 +1218,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/cancel_contract_test.go b/cmd/cancel_contract_test.go new file mode 100644 index 0000000..bb2eb49 --- /dev/null +++ b/cmd/cancel_contract_test.go @@ -0,0 +1,547 @@ +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") + } +} + +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() + staging, err := platform.NewOperationStaging() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = staging.Cleanup() }) + + 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 + } + stageA := writeStage("Alpha-Regular.ttf", "Alpha", "Regular") + stageB := writeStage("Beta-Regular.ttf", "Beta", "Regular") + stageC := writeStage("Gamma-Regular.ttf", "Gamma", "Regular") + + 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 + } + _, skipped, _, _, _, _, _, err := installDownloadedFonts( + ctx, []string{stageA, stageB, stageC}, fm, platform.UserScope, fontDir, false, nil, tc, track) + if err == nil { + 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 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() + 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 after skip+cancel, 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: ©FontManager{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) + } + 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") + } +} + +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/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/export.go b/cmd/export.go index 0e4d5ad..e30e119 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -233,15 +233,19 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 1: Collect fonts (0-20% progress) - 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 { @@ -250,10 +254,11 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 2: Group by family (20-30% progress) 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 { @@ -262,14 +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.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 @@ -278,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 { @@ -287,7 +296,6 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 4: Populate match data and filter fonts (50-60% progress) populateFontMatchData(families, matches) fontIDGroups, skippedSystem, skippedUnmatched, skippedByFilter := filterFontsForExport(FilterFontsForExportParams{ @@ -309,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 { @@ -318,7 +328,6 @@ func runExportWithProgressBar(fontManager platform.FontManager, scopes []platfor default: } - // Phase 5: Perform the actual export operation (60-100% progress) params := ExportProgressParams{ FontManager: fontManager, Scopes: scopes, @@ -403,13 +412,40 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms } } + if send != nil { + 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) - // Update progress + // Mark families complete within the write band (prep already finished at 40%). 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] + } + 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, + Status: "completed", + }) + } } // Check for cancellation before writing @@ -422,6 +458,12 @@ func performExportWithProgress(params ExportProgressParams, send func(msg tea.Ms } } + if send != nil { + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{ + Phase: exportStepWrite, Kind: ProgressCount, Done: 1, Total: 1, + })}) + } + // Write manifest output.GetVerbose().Info("Writing export file...") jsonData, err := json.MarshalIndent(*manifest, "", " ") @@ -451,17 +493,8 @@ 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.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", - }) - } + send(components.ProgressUpdateMsg{Percent: OverallExportPercent(ProgressUpdate{Phase: exportStepDone})}) } return manifest.Fonts, totalVariants, nil diff --git a/cmd/import.go b/cmd/import.go index c1ee87a..55653ab 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "errors" "fmt" @@ -32,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: @@ -592,61 +592,101 @@ 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, 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 { + cancelled = true + for j := itemIndex; j < len(fontsToInstall); j++ { + incompleteCancelIDs = append(incompleteCancelIDs, fontsToInstall[j].FontID) + } + return shared.ErrOperationCancelled + } send(components.ItemUpdateMsg{ Index: itemIndex, Status: "in_progress", - Message: "Downloading from " + fontGroup.SourceName, + Message: DownloadFromSourceMessage(fontGroup.SourceName), }) 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 = "Downloading from " + 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: OverallInstallPercent(itemIndex, len(fontsToInstall), step, stepPct), - }) + send(components.ProgressUpdateMsg{Percent: pct}) } result, err := installFont( + ctx, fontGroup.Fonts, fontGroup.FontID, fontManager, installScope, force, fontDir, + nil, true, onProgress, + nil, ) if err != nil { - status.Failed += result.Failed + 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++ + } GetLogger().Error("Failed to process font %s: %v", fontGroup.FontName, err) errorMsg := err.Error() send(components.ItemUpdateMsg{ @@ -680,7 +720,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 @@ -688,15 +728,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) @@ -746,14 +786,17 @@ 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, + 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/install_tracking.go b/cmd/install_tracking.go new file mode 100644 index 0000000..ff8699f --- /dev/null +++ b/cmd/install_tracking.go @@ -0,0 +1,263 @@ +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) +} + +// 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/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/progress_steps.go b/cmd/progress_steps.go index 74dde2a..6809719 100644 --- a/cmd/progress_steps.go +++ b/cmd/progress_steps.go @@ -1,132 +1,346 @@ 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 ( - installStepPrecheck = "Checking installed" - installStepDownload = "Downloading" - installStepExtract = "Extracting" - installStepInstall = "Installing" - installStepFinalize = "Finalizing" - installStepCompleted = "Installed" + ProgressBytes ProgressKind = iota // single-stream bytes (known size only for bar math) + ProgressCount // work units / file counts + ProgressFlag // precheck / finalize (0 → 1) ) -// 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, +// ProgressUpdate is one pulse of work-unit progress. +type ProgressUpdate struct { + Phase string + Detail string + Kind ProgressKind + Done float64 + Total float64 // 0 = unknown; never treat unknown bytes as complete } -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) -} +// ProgressFunc reports progress. Implementations must be lightweight. +type ProgressFunc func(ProgressUpdate) +// Internal phase keys (not user-facing labels). const ( + 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 package/item (monotonic). +// Prep aggregates downloads+extracts; force-remove band is reserved even when unused. +const ( + 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 ) -var removeStepOrder = []string{ - removeStepScan, - removeStepRemove, - removeStepFinalize, +// DownloadFromSourceMessage is the user-facing download label. +func DownloadFromSourceMessage(sourceName string) string { + sourceName = strings.TrimSpace(sourceName) + if sourceName == "" { + return "Downloading..." + } + return "Downloading from " + sourceName + "..." } -var removeStepWeights = []float64{ - 0.55, // scan/find (can be expensive on some platforms) - 0.40, // remove files - 0.05, // cache flush / finalize +// 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) } -func removeStepIndex(step string) int { - for i, s := range removeStepOrder { - if s == step { - return i +// 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 0 } -func OverallRemovePercent(fontIndex int, totalFonts int, step string, stepPct float64) float64 { - if totalFonts <= 0 { - return 0 - } - if fontIndex < 0 { - fontIndex = 0 +// isInstallPrepPhase reports download or extract (shared prep band). +func isInstallPrepPhase(phase string) bool { + return phase == installStepDownload || phase == installStepExtract +} + +// FormatProgressActivity builds a generic status line (legacy helpers / tests). +func FormatProgressActivity(phase, detail string) string { + phase = strings.TrimSpace(phase) + detail = strings.TrimSpace(detail) + if phase == "" { + if detail == "" { + return "" + } + return detail } - if fontIndex >= totalFonts { - fontIndex = totalFonts - 1 - stepPct = 1 + if detail == "" { + return phase + "..." } + return phase + " " + detail +} - if step == removeStepCompleted { - return (float64(fontIndex+1) / float64(totalFonts)) * 100.0 +// 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)) +} - stepIdx := removeStepIndex(step) - perFont := shared.WeightedPhaseProgress(removeStepWeights, stepIdx, stepPct) - overall := (float64(fontIndex) + perFont) / float64(totalFonts) - if overall < 0 { - overall = 0 +// prepDownloadUnitFrac is progress within one download/extract unit during download [0, prepDownloadWeight]. +// Unknown Content-Length returns 0 (activity via label only); never invents completion. +func prepDownloadUnitFrac(doneBytes, totalBytes int64) float64 { + if totalBytes <= 0 { + return 0 } - if overall > 1 { - overall = 1 + 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 overall * 100.0 + return prepDownloadWeight + prepExtractWeight*shared.Clamp01(float64(done)/float64(total)) } -func installStepIndex(step string) int { - for i, s := range installStepOrder { - if s == step { - return i +// phaseFrac returns progress within the active phase in [0,1]. +func phaseFrac(u ProgressUpdate) float64 { + switch u.Kind { + case ProgressFlag: + 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 { + // Unknown size: never treat bytes received as phase-complete. + return 0 + } + return shared.Clamp01(u.Done / u.Total) + default: + return 0 + } +} + +// 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 installStepForceRemove: + return segPrepEnd, segForceRemoveEnd + case installStepInstall: + return segForceRemoveEnd, segFilesEnd + case installStepFinalize: + return segFilesEnd, segFinalizeEnd + case removeStepScan: + return 0, segRemoveScanEnd + 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 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 || + u.Phase == exportStepDone || u.Phase == backupStepDone { + 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 { + 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 +// 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 } +} - stepIdx := installStepIndex(step) - perFont := shared.WeightedPhaseProgress(installStepWeights, stepIdx, stepPct) // 0..1 within this font +// 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, + }) +} - overall := (float64(fontIndex) + perFont) / float64(totalFonts) // 0..1 overall - if overall < 0 { - overall = 0 - } - if overall > 1 { - overall = 1 +// 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, + }) +} + +// 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 overall * 100.0 + 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 + 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..23cd1a5 100644 --- a/cmd/progress_steps_test.go +++ b/cmd/progress_steps_test.go @@ -1,6 +1,169 @@ 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 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) + } + 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: ProgressCount, Done: 0.5, Total: 1, + }) + 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 < 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 := segForceRemoveEnd*100 + (segFilesEnd-segForceRemoveEnd)*100*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_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, + }) + // 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) + } +} func TestOverallInstallPercent_Bounds(t *testing.T) { if got := OverallInstallPercent(0, 0, installStepDownload, 0.5); got != 0 { @@ -23,3 +186,119 @@ 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} + 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") + } +} + +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) + } +} + +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/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 5f505ac..99d17e9 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -10,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" @@ -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 @@ -79,7 +80,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 @@ -217,7 +218,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 @@ -246,22 +247,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 { @@ -344,6 +329,42 @@ 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(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, + }) +} + // 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) { @@ -364,7 +385,6 @@ func findFontFilesForRemoval(fontName string, fontManager platform.FontManager, 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 { @@ -372,7 +392,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") @@ -388,6 +407,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 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) + } + if len(matchingFonts) == 0 { return nil, false, fmt.Errorf("font not found: %s", fontName) } @@ -395,72 +422,183 @@ 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. +func sfntFamilyAllowedForFontID(family, targetFontID string) bool { + return isNerdFontID(targetFontID) == sfntLooksLikeNerdFont(family) +} + +// 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 { + family := extractFontFamilyNameFromPath(filepath.Join(fontDir, base)) + ok := false + if index == nil { + ok = sfntFamilyAllowedForFontID(family, targetFontID) + } else { + ok = checkFontMatchesFontID(family, targetFontID, index) + } + if !ok { + output.GetDebug().State("Font ID filter: skip %q family %q for %q", base, family, targetFontID) + continue + } + out = append(out, base) + } + return out +} + // RemoveFontFilesParams contains parameters for removeFontFiles function type RemoveFontFilesParams struct { + Ctx context.Context MatchingFonts []string FontManager platform.FontManager Scope platform.InstallationScope FontDir string + FontID string // catalog id when known (for incomplete tracking) 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) { - batchOpts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} +// 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() + } + opts := &platform.RemoveFontOptions{SkipPostRemoveCacheRefresh: true} total := len(params.MatchingFonts) + left := append([]string(nil), params.MatchingFonts...) + track := strings.TrimSpace(params.FontID) != "" + for i, matchingFont := range params.MatchingFonts { + 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(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) + 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), + }) + } + } - // 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)") + left = removeBasename(left, matchingFont) + advanceRemove() + 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) + 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 + } + } + advanceRemove() continue } - fontDisplayName := extractFontDisplayNameFromPath(fontPath) - - // Remove font - output.GetDebug().State("Calling fontManager.RemoveFont(%s, %s)", matchingFont, params.Scope) - err := params.FontManager.RemoveFont(matchingFont, params.Scope, batchOpts) - - if err != nil { - // Actual removal failure (cache flush is handled once after the batch) + 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, 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 + } + } + advanceRemove() + 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" + 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 { - 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) - 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 } - output.GetDebug().State("Successfully removed font: %s", fontDisplayName) removed++ - details = append(details, fontDisplayName) + 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 + } + } + advanceRemove() } 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 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()) @@ -473,11 +611,17 @@ 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}) } } - 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 @@ -522,6 +666,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, @@ -529,11 +674,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 { @@ -543,25 +694,40 @@ func removeFont( result.Message = "Font not found" return result, err } + fontID := "" + if usedRegistry { + fontID = fontName + } else if installations.ShouldConsultRegistryForRemoval(fontName, installReg, manifestProbe) { + fontID = fontName + } if onProgress != nil { - onProgress(removeStepScan, 1) + 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 := 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 usedRegistry && failed == 0 && removed == len(matchingFonts) && removed > 0 { - if rmErr := installations.RemoveInstallation(fontName); rmErr != nil { - output.GetDebug().Error("remove installation registry entry: %v", rmErr) - } + if remErr != nil { + return res, remErr } return res, nil } @@ -576,6 +742,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) } @@ -1110,7 +1279,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) @@ -1168,7 +1337,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 @@ -1436,13 +1605,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 @@ -1588,13 +1751,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 @@ -1654,7 +1811,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 @@ -1662,7 +1819,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) @@ -1699,6 +1856,7 @@ Use --scope to set removal location: output.GetDebug().State("Calling removeFont(%s, %s, %s)", fontInfo.SearchName, scopeLabelName, fontDir) result, err := removeFont( + cmd.Context(), fontInfo.SearchName, fontManager, scopeType, @@ -1710,23 +1868,22 @@ 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) - // 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) } } continue } - // Update status - updateRemovalStatus(status, result) + applyRemoveOutcome(status, result, nil) // Show detailed result information in debug mode logRemoveResultDetails(result, fontInfo.SearchName, scopeLabelName) @@ -1748,7 +1905,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, len(foundFonts)+len(notFoundFonts), len(notFoundFonts)) } // Determine title based on scope @@ -1760,6 +1917,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( @@ -1768,42 +1932,55 @@ 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(opCtx) + 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 { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, item.FontName) + return shared.ErrOperationCancelled + } // Send initial "in_progress" message send(components.ItemUpdateMsg{ Index: item.ItemIndex, Name: item.ProperName, Status: "in_progress", - Message: "Removing...", + Message: progressLabelRemove, }) 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), - }) + 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( + ctx, item.FontName, fontManager, item.ScopeType, @@ -1813,33 +1990,32 @@ Use --scope to set removal location: manifestProbe, onProgress, ) + if IsCancelErr(err) { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, item.FontName) + if result != nil { + updateRemovalStatus(status, result) + } + return shared.ErrOperationCancelled + } // 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 @@ -1871,11 +2047,16 @@ 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 { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, fontInfo.SearchName) + return shared.ErrOperationCancelled + } // Use proper font name from pre-extracted map properFontName := fontInfo.ProperName fontName := fontInfo.SearchName @@ -1900,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 { @@ -1926,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 { @@ -1947,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 { @@ -1957,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 @@ -1973,54 +2132,53 @@ 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) - onProgress := func(step string, stepPct float64) { - // 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 + "...", - }) + var th progressThrottle + onProgress := func(u ProgressUpdate) { + 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, fontName, fontManager, platform.UserScope, @@ -2032,32 +2190,31 @@ Use --scope to set removal location: ) if err != nil { + if IsCancelErr(err) { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, fontName) + if result != nil { + updateRemovalStatus(status, result) + } + 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...) } @@ -2123,26 +2280,25 @@ 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), - }) + 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, fontName, fontManager, scopeType, @@ -2154,29 +2310,31 @@ Use --scope to set removal location: ) if err != nil { + if IsCancelErr(err) { + cancelledRemove = true + incompleteCancelIDs = append(incompleteCancelIDs, fontName) + if result != nil { + updateRemovalStatus(status, result) + } + 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...) } @@ -2261,13 +2419,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", @@ -2469,13 +2629,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 @@ -2622,13 +2776,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 @@ -2699,9 +2847,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, 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/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/cmd/remove_stale_test.go b/cmd/remove_stale_test.go new file mode 100644 index 0000000..14bc88d --- /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) + } + // 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/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/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/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 89% rename from docs/development/BUILD.md rename to docs/development/build-guide.md index c8a76d7..c78f96d 100644 --- a/docs/development/BUILD.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/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 diff --git a/go.mod b/go.mod index d2bef5e..09a2086 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,16 @@ 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 ) @@ -25,18 +24,19 @@ 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 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/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/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..4e87db4 100644 --- a/internal/components/progress_bar.go +++ b/internal/components/progress_bar.go @@ -2,7 +2,9 @@ package components import ( "fmt" + "os" "strings" + "sync/atomic" "time" "fontget/internal/shared" @@ -12,6 +14,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 +44,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 +118,7 @@ func NewProgressBar(title string, items []OperationItem, verboseMode bool, debug ProgressBar: prog, Spinner: spin, cancelChan: make(chan struct{}), + opDone: make(chan struct{}), } } @@ -128,14 +133,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 } @@ -148,31 +164,26 @@ 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 { - // If already completed, any key quits + if m.cancelled { 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) - } + } + m.quitting = true + m.cancelled = true + m.err = shared.ErrOperationCancelled + m.Title = "Cancelling..." + if m.cancelChan != nil { + select { + case <-m.cancelChan: + default: + close(m.cancelChan) } - // Quit immediately - don't wait for operation - return m, tea.Quit } + // Quit TUI immediately; RunProgressBar joins the worker after p.Run(). + return m, tea.Quit } - // 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 @@ -190,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 } @@ -246,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 { @@ -257,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 @@ -599,33 +620,106 @@ 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{}) + trace := os.Getenv("FONTGET_PROGRESS_TRACE") == "1" + var lastPct float64 = -1 + send := func(msg tea.Msg) { + 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) + } + } + } + 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 + var workStarted atomic.Bool 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 + workStarted.Store(true) return operation(func(msg tea.Msg) { + select { + case <-model.cancelChan: + return + default: + } program.Send(msg) }, model.cancelChan) } - // Run the program finalModel, err := p.Run() + joinWorker := func() { + if !workStarted.Load() || model.opDone == nil { + return + } + select { + case <-model.cancelChan: + default: + close(model.cancelChan) + } + <-model.opDone + } if err != nil { + joinWorker() return err } + joinWorker() - // Check if there was an operation error or cancellation if m, ok := finalModel.(ProgressBarModel); ok { if m.cancelled { return shared.ErrOperationCancelled @@ -634,6 +728,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..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 @@ -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 } @@ -297,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 } @@ -341,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 0e4e376..0000000 --- a/internal/components/unified_form.go +++ /dev/null @@ -1,686 +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] - if comp.Type == 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 - } - } - } else if comp.Type == 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 { - // 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/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/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/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/lock.go b/internal/installations/lock.go new file mode 100644 index 0000000..157a450 --- /dev/null +++ b/internal/installations/lock.go @@ -0,0 +1,67 @@ +package installations + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "fontget/internal/network" +) + +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", 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", network.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/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/recovery.go b/internal/installations/recovery.go new file mode 100644 index 0000000..e569d84 --- /dev/null +++ b/internal/installations/recovery.go @@ -0,0 +1,64 @@ +package installations + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "fontget/internal/config" + "fontget/internal/platform" + "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 != "" { + part := platform.SanitizePathPart(rec.PackageID) + if part == "" { + part = "package" + } + name = rec.OperationID + "-" + part + } + 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 +} 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..fd17197 100644 --- a/internal/installations/registry.go +++ b/internal/installations/registry.go @@ -2,6 +2,7 @@ package installations import ( + "context" "encoding/json" "fmt" "os" @@ -56,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"` @@ -65,10 +73,16 @@ 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. -const schemaVersion = "1.0" +const schemaVersion = "1.1" func normalizeFamilyGroups(in []FamilyGroup) []FamilyGroup { if len(in) == 0 { @@ -141,9 +155,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 +220,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 { @@ -242,36 +264,78 @@ 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)) - 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) + 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), + 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) + }) } func normalizeInstalledFiles(in []InstalledFontFile) []InstalledFontFile { @@ -385,20 +449,40 @@ 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)) 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/installations/registry_migrate.go b/internal/installations/registry_migrate.go index 07be76a..d88526a 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,15 @@ 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: 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)) @@ -80,3 +79,41 @@ func applyRegistryMigrations(reg *Registry) (changed bool, err error) { func CurrentRegistrySchemaVersion() string { return schemaVersion } + +func applyFontIDRenames(reg *Registry, raw []byte) error { + 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) + } + 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 { + 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..6ae7f5a --- /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 := applyFontIDRenames(reg, nerdFontsV1ToV2JSON); 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 := applyFontIDRenames(reg, nerdFontsV1ToV2JSON); 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/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 new file mode 100644 index 0000000..4719723 --- /dev/null +++ b/internal/network/classify.go @@ -0,0 +1,247 @@ +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 + // 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 ActionAdvanceCandidate + } + if statusCode >= 500 { + return ActionRetrySame + } + return ActionAdvanceCandidate + } +} + +// 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 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 + } + 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. +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..89199f7 --- /dev/null +++ b/internal/network/classify_test.go @@ -0,0 +1,115 @@ +package network + +import ( + "context" + "errors" + "io" + "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, ActionAdvanceCandidate}, + {401, false, ActionAdvanceCandidate}, + {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 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") { + 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..b07acd6 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" @@ -16,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{} @@ -30,9 +32,30 @@ 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 isZipMagic(b []byte) bool { +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 +} + +// IsZipMagic reports whether b starts with a ZIP local/EOCD/span signature. +func IsZipMagic(b []byte) bool { if len(b) < 4 { return false } @@ -71,6 +94,7 @@ type FallbackAttemptError struct { URL string Report *DownloadFallbackReport attempt []string + cause error } func (e *FallbackAttemptError) Error() string { @@ -78,14 +102,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", RedactDownloadURL(e.URL)) +} + +func (e *FallbackAttemptError) Unwrap() error { + if e == nil { + return nil } - return fmt.Sprintf("download fallback failed for %s", e.URL) + 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 +124,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 +142,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,79 +153,125 @@ 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, 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) (string, error) { args := []string{ "-q", "-O", targetPath, + "--server-response", } if opts.UserAgent != "" { args = append(args, "--user-agent", opts.UserAgent) @@ -279,14 +413,21 @@ 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...) + status := scrapeHTTPStatus(out, err) if err != nil { - return fmt.Errorf("%s", normalizeToolError(out, err)) + if isCancelErr(err) { + return status, 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(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" @@ -307,15 +448,71 @@ func runPowerShell(runner CommandRunner, psPath, url, targetPath string, opts Do 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 := runner.CombinedOutput(psPath, args...) + out, err := runTool(ctx, runner, opts, psPath, args...) + status := scrapeHTTPStatus(out, err) + if err != nil { + if isCancelErr(err) { + return status, err + } + if code, ok := parseHTTPStatus(status); ok && code != 0 { + return status, NewHTTPStatusError(code, url, 0) + } + return status, fmt.Errorf("%s", normalizeToolError(out, err)) + } + 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 { - return fmt.Errorf("%s", normalizeToolError(out, err)) + blob += "\n" + err.Error() } - return nil + 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 { + if ctx == nil { + ctx = context.Background() + } + 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 sleepCtx(ctx context.Context, d time.Duration) error { + return SleepCtx(ctx, d) } func normalizeToolError(out []byte, err error) string { diff --git a/internal/network/download_fallbacks_test.go b/internal/network/download_fallbacks_test.go index a861fe8..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{}, @@ -130,3 +138,75 @@ 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_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{ + "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..8bfb936 --- /dev/null +++ b/internal/network/exec.go @@ -0,0 +1,206 @@ +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 + // defaultWaitAfterKill bounds how long we wait for Wait() after forced termination. + defaultWaitAfterKill = 10 * time.Second +) + +// ExecOptions controls cancellable external process execution. +type ExecOptions struct { + InactivityTimeout time.Duration + TerminateWait time.Duration + // 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 (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. +// 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() + } + if err := ctx.Err(); err != nil { + return nil, err + } + + cmd := exec.Command(name, args...) + prepareProcessGroup(cmd) + + var out limitedBuffer + out.max = DefaultMaxCapturedOutput + + 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() { + waitErr := cmd.Wait() + copyWG.Wait() + done <- waitErr + }() + + 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) + select { + case <-done: + case <-time.After(defaultWaitAfterKill): + return out.Bytes(), fmt.Errorf("%w: process did not exit after kill", cause) + } + } + 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()...) +} diff --git a/internal/network/exec_test.go b/internal/network/exec_test.go new file mode 100644 index 0000000..088b90b --- /dev/null +++ b/internal/network/exec_test.go @@ -0,0 +1,180 @@ +package network + +import ( + "context" + "fmt" + "os" + "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) { + 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) + } + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, runErr := RunCancellable(ctx, ExecOptions{InactivityTimeout: time.Minute, TerminateWait: time.Second}, name, args...) + errCh <- runErr + }() + + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + 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 { + 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(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(1200 * time.Millisecond) + if _, err := os.Stat(marker); err == nil { + t.Fatal("descendant still alive after cancel (marker recreated)") + } +} + +func descendantMarkerArgs(dir, marker string) (string, []string, error) { + if runtime.GOOS == "windows" { + // 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 + `"; (sleep 60 &); while true; do touch "` + marker + `"; sleep 1; done` + return "sh", []string{"-c", script}, nil +} + +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") + } +} + +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) + } +} + +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 new file mode 100644 index 0000000..1f3c4a8 --- /dev/null +++ b/internal/network/exec_unix.go @@ -0,0 +1,40 @@ +//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 { + if cmd == nil || cmd.Process == nil { + return nil + } + pgid := cmd.Process.Pid + _ = syscall.Kill(-pgid, syscall.SIGTERM) + 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 + } + pgid := cmd.Process.Pid + return syscall.Kill(-pgid, syscall.SIGKILL) +} diff --git a/internal/network/exec_windows.go b/internal/network/exec_windows.go new file mode 100644 index 0000000..afcf68d --- /dev/null +++ b/internal/network/exec_windows.go @@ -0,0 +1,55 @@ +//go:build windows + +package network + +import ( + "context" + "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 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) + _ = runTaskkill(false, pid, wait) + 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) + return runTaskkill(true, pid, DefaultExternalTerminateWait) +} 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/darwin.go b/internal/platform/darwin.go index 20779e2..0a57302 100644 --- a/internal/platform/darwin.go +++ b/internal/platform/darwin.go @@ -78,31 +78,25 @@ 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) + 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) } 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) } } @@ -125,7 +119,12 @@ func (m *darwinFontManager) RemoveFont(fontName string, scope InstallationScope, fontPath := filepath.Join(targetDir, fontName) - // Delete the font file + if opts != nil && opts.UnregisterOnly { + return nil + } + + // 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) } @@ -165,7 +164,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..ef592dd 100644 --- a/internal/platform/linux.go +++ b/internal/platform/linux.go @@ -77,28 +77,24 @@ 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) + 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) } 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) } } @@ -121,7 +117,12 @@ func (m *linuxFontManager) RemoveFont(fontName string, scope InstallationScope, fontPath := filepath.Join(targetDir, fontName) - // Delete the font file + if opts != nil && opts.UnregisterOnly { + return nil + } + + // 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/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) } } diff --git a/internal/platform/machine_remove.go b/internal/platform/machine_remove.go new file mode 100644 index 0000000..1148ebe --- /dev/null +++ b/internal/platform/machine_remove.go @@ -0,0 +1,129 @@ +//go:build windows + +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 (raw bytes + type). +type registryFontValue struct { + Name string + Raw []byte + 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/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/mutation.go b/internal/platform/mutation.go new file mode 100644 index 0000000..8994948 --- /dev/null +++ b/internal/platform/mutation.go @@ -0,0 +1,222 @@ +package platform + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +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) +} + +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. 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 + + 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 { + exportMutation(opts, mut) + return mut, err + } + } else if !os.IsNotExist(err) { + return mut, fmt.Errorf("stat destination: %w", err) + } else { + if err := createNewFontFile(src, dest, &mut, opts); err != nil { + exportMutation(opts, mut) + return mut, err + } + } + + exportMutation(opts, mut) + return mut, nil +} + +func createNewFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { + 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 + } + return failPointError(InstallFailCopyAfterWrite) + } + + 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 + } + removeTracked(mut, staged) + mut.Created = true + return nil +} + +func replaceExistingFontFile(src, dest string, mut *FileMutation, opts *InstallFontOptions) error { + mut.Replaced = true + if opts != nil && opts.FailPoint == InstallFailReplace { + return failPointError(InstallFailReplace) + } + + staged := uniqueArtifact(dest, "new") + trackArtifact(mut, staged) + if err := copyFile(src, staged); err != nil { + removeTracked(mut, staged) + return fmt.Errorf("stage replacement: %w", err) + } + if opts != nil && opts.FailPoint == InstallFailCopyAfterWrite { + return failPointError(InstallFailCopyAfterWrite) + } + + if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { + removeTracked(mut, staged) + return fmt.Errorf("remove existing font: %w", err) + } + if err := os.Rename(staged, dest); err != nil { + if copyErr := copyFile(staged, dest); copyErr != nil { + removeTracked(mut, staged) + return fmt.Errorf("install replacement: %w", copyErr) + } + } + removeTracked(mut, staged) + return nil +} + +// CommitMutation deletes disposable staging artifacts after a successful package commit. +func CommitMutation(mut FileMutation) error { + var first error + paths := append([]string{}, mut.ArtifactPaths...) + if mut.BackupPath != "" { + paths = append(paths, mut.BackupPath) + } + 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 first +} + +// 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 { + return err + } + } else if err := undoFontRegistration(mut); err != nil { + return err + } + + 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) + } + } else if mut.Created && mut.DestPath != "" { + if err := os.Remove(mut.DestPath); err != nil && !os.IsNotExist(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 + } + + 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 new file mode 100644 index 0000000..f43f52b --- /dev/null +++ b/internal/platform/mutation_test.go @@ -0,0 +1,128 @@ +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 TestPlaceFontFileForceReplaceLeavesOldOnInjectedFail(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 := 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("force replace must not create backups") + } +} + +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) + } + 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) + } +} + +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 TestCheckDestinationCollisions(t *testing.T) { + 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{a, c}); err != nil { + t.Fatal(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/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/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..b2360ac 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -27,11 +27,52 @@ const ( MachineScope InstallationScope = "machine" ) +// InstallFailPoint is a test-only hook that stops an install after the named step. +type InstallFailPoint string + +const ( + InstallFailNone InstallFailPoint = "" + InstallFailCopyAfterWrite InstallFailPoint = "copy-after-write" + InstallFailReplace InstallFailPoint = "replace" + InstallFailRegister InstallFailPoint = "register" +) + +// 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 staging files 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). 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). @@ -39,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/temp.go b/internal/platform/temp.go index 00d81f2..09a98c1 100644 --- a/internal/platform/temp.go +++ b/internal/platform/temp.go @@ -4,24 +4,24 @@ import ( "fmt" "os" "path/filepath" + "strings" ) const ( - // TempDirName is the name of our temporary directory - TempDirName = "Fontget" - // TempFontsDir is the name of the fonts subdirectory - TempFontsDir = "fonts" + // TempDirName is the name of our temporary directory (shared container only). + TempDirName = "Fontget" + 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,49 +30,101 @@ func GetTempDir() (string, error) { return fontgetTempDir, nil } -// GetTempFontsDir returns the path to the temporary fonts directory -func GetTempFontsDir() (string, error) { - // Get the base 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 + return err + } + 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 +} - // 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) +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") +} - return fontsDir, nil +// 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 } -// CleanupTempDir removes all files from the temp directory -func CleanupTempDir() error { - tempDir, err := GetTempDir() +// NewOperationStaging allocates a unique operation directory under the shared Fontget temp root. +func NewOperationStaging() (*OperationStaging, error) { + base, err := GetTempDir() if err != nil { - return err + 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 +} - // Remove the entire temp directory and its contents - if err := os.RemoveAll(tempDir); err != nil { - return fmt.Errorf("failed to cleanup temp directory: %w", err) +// 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") + } + pkgName := SanitizePathPart(packageID) + if pkgName == "" { + pkgName = "package" + } + varName := SanitizePathPart(variant) + if varName == "" { + varName = "variant" + } + 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 +} +// 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 } -// CleanupTempFontsDir removes all files from the temp fonts directory -func CleanupTempFontsDir() error { - fontsDir, err := GetTempFontsDir() - if err != nil { - return err +// SanitizePathPart returns a filesystem-safe fragment for staging and recovery filenames. +func SanitizePathPart(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" } - - // Remove the fonts directory and its contents - if err := os.RemoveAll(fontsDir); err != nil { - return fmt.Errorf("failed to cleanup fonts directory: %w", err) + 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('_') + } } - - return nil + 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..9b3c791 --- /dev/null +++ b/internal/platform/temp_test.go @@ -0,0 +1,87 @@ +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) + } + } +} diff --git a/internal/platform/windows.go b/internal/platform/windows.go index 6d40a09..d996e0d 100644 --- a/internal/platform/windows.go +++ b/internal/platform/windows.go @@ -4,6 +4,7 @@ package platform import ( + "errors" "fmt" "os" "path/filepath" @@ -86,41 +87,57 @@ 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...") + if rerr := RemoveFontResource(targetPath); rerr == nil { + if opts != nil && opts.Mutation != nil { + opts.Mutation.PriorResourceRemoved = true + } + } } - 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 + } + mut.FontName = fontName + mut.Scope = scope + if existed && force { + mut.PriorResourceRemoved = true + } + if opts != nil && opts.Mutation != nil { + *opts.Mutation = mut } - // 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) + 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) } 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 @@ -128,28 +145,72 @@ 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) + mut.ResourceRegistered = false + if rbErr := RollbackMutation(mut); rbErr != nil { + logger.Error("Rollback after registry failure: %v", rbErr) + } 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) - // Clean up on error - logger.Debug("Cleaning up after failed notification...") - 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) } - os.Remove(targetPath) return fmt.Errorf("failed to notify font change: %w", err) } logger.Debug("Font change notification sent successfully") @@ -177,63 +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") - } - } - - // Delete the font file - 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) - } - return fmt.Errorf("failed to remove font file: %w", err) + 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 { - // 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) - 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 } @@ -354,31 +387,74 @@ 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, Raw: 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.setRegistryValueRaw(key, v.Name, v.Raw, v.Type) +} + // openFontRegistryKey opens the Windows font registry key for writing 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, - uintptr(KEY_WRITE), + uintptr(KEY_WRITE|KEY_QUERY_VALUE), 0, uintptr(unsafe.Pointer(&key)), 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 } -// 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 { + 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) setRegistryValueRaw(key syscall.Handle, valueName string, data []byte, typ uint32) error { logger := logging.GetLogger() valueNamePtr, err := syscall.UTF16PtrFromString(valueName) @@ -387,19 +463,20 @@ func (m *windowsFontManager) setRegistryValue(key syscall.Handle, valueName, val 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(REG_SZ), - uintptr(unsafe.Pointer(valuePtr)), - uintptr((len(value)+1)*2), + uintptr(typ), + dataPtr, + uintptr(len(data)), ) if ret != 0 { logger.Error("Failed to set registry value: %v", err) @@ -408,6 +485,72 @@ func (m *windowsFontManager) setRegistryValue(key syscall.Handle, valueName, val return nil } +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 nil, 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 nil, 0, ErrRegistryValueAbsent + } + return nil, 0, fmt.Errorf("failed to query registry value: %w", callErr) + } + if dataLen == 0 { + return nil, typ, nil + } + buf := make([]byte, dataLen) + 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 nil, 0, ErrRegistryValueAbsent + } + return nil, 0, fmt.Errorf("failed to query registry value: %w", callErr) + } + if int(dataLen) < len(buf) { + buf = buf[:dataLen] + } + return 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() @@ -418,12 +561,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") ) 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/checksum.go b/internal/repo/checksum.go new file mode 100644 index 0000000..38ddc14 --- /dev/null +++ b/internal/repo/checksum.go @@ -0,0 +1,120 @@ +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:]) + } + b, err := hex.DecodeString(expected) + if err != nil || len(b) != 32 { + return "", fmt.Errorf("%w: expected 64 hex characters", ErrMalformedChecksum) + } + return hex.EncodeToString(b), 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..1fb93cd --- /dev/null +++ b/internal/repo/download_reliability_test.go @@ -0,0 +1,211 @@ +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 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 + 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..bdca462 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" @@ -53,22 +52,18 @@ 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{}{} ) -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 +75,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 +171,19 @@ 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 +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() 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: + 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) + if !network.IsZipMagic(hdr) { + 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,14 @@ 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() + } + return network.SleepCtx(ctx, d) +} + func isHTTP2HeaderTimeout(err error) bool { if err == nil { return false @@ -537,11 +562,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 +590,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 +613,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, network.ActionRateLimit: + return nil, err + case network.ActionAdvanceCandidate, network.ActionRetrySame, 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. @@ -686,6 +749,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) 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 c4565a4..ff4fac7 100644 --- a/internal/shared/errors.go +++ b/internal/shared/errors.go @@ -41,33 +41,47 @@ 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 +// 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") +) + +// 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 *ConfigurationError) Error() string { - if e.Hint != "" { - return fmt.Sprintf("configuration error in field '%s' with value '%s': %s", e.Field, e.Value, e.Hint) +func (e *DisplayedError) Error() string { + if e == nil || e.Cause == nil { + return "error already displayed" } - return fmt.Sprintf("configuration error in field '%s' with value '%s'", e.Field, e.Value) + return e.Cause.Error() } -// ElevationError represents elevation-related errors -type ElevationError struct { - Operation string - Platform string +func (e *DisplayedError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause } -func (e *ElevationError) Error() string { - return fmt.Sprintf("elevation required for operation '%s' on platform '%s'", e.Operation, e.Platform) +// 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} } -// ErrOperationCancelled is a sentinel error used to indicate that an operation was cancelled by the user. -var ErrOperationCancelled = errors.New("operation cancelled") - // 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/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/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) - } -} - 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/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) + } +} 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/testutil/minifont.go b/internal/testutil/minifont.go new file mode 100644 index 0000000..4650cda --- /dev/null +++ b/internal/testutil/minifont.go @@ -0,0 +1,101 @@ +package testutil + +import ( + "encoding/binary" + "fmt" + "math" + "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 = binary.BigEndian.AppendUint16(store, r) + } + recs = append(recs, nr{ + id: n.id, + length: toUint16(len(u) * 2), + offset: toUint16(off), + }) + } + + 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) + 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], toUint32(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 +} + +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 +} 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) } } 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) } }