diff --git a/docs/aim-add.1 b/docs/aim-add.1 index 87ef9e2..673f905 100644 --- a/docs/aim-add.1 +++ b/docs/aim-add.1 @@ -1,5 +1,5 @@ .nh -.TH "AIM-ADD" "1" "Apr 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" +.TH "AIM-ADD" "1" "May 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" .SH NAME aim-add - Install an AppImage from a file, URL, or provider diff --git a/docs/aim-info.1 b/docs/aim-info.1 index 29f95ee..b46c640 100644 --- a/docs/aim-info.1 +++ b/docs/aim-info.1 @@ -1,5 +1,5 @@ .nh -.TH "AIM-INFO" "1" "Apr 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" +.TH "AIM-INFO" "1" "May 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" .SH NAME aim-info - Show AppImage or package details diff --git a/docs/aim-list.1 b/docs/aim-list.1 index f090934..42405de 100644 --- a/docs/aim-list.1 +++ b/docs/aim-list.1 @@ -1,5 +1,5 @@ .nh -.TH "AIM-LIST" "1" "Apr 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" +.TH "AIM-LIST" "1" "May 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" .SH NAME aim-list - List managed AppImages diff --git a/docs/aim-remove.1 b/docs/aim-remove.1 index 5747a9d..d1f1903 100644 --- a/docs/aim-remove.1 +++ b/docs/aim-remove.1 @@ -1,5 +1,5 @@ .nh -.TH "AIM-REMOVE" "1" "Apr 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" +.TH "AIM-REMOVE" "1" "May 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" .SH NAME aim-remove - Remove a managed AppImage or only its desktop integration diff --git a/docs/aim-update.1 b/docs/aim-update.1 index 227ad9e..c7f61d6 100644 --- a/docs/aim-update.1 +++ b/docs/aim-update.1 @@ -1,5 +1,5 @@ .nh -.TH "AIM-UPDATE" "1" "Apr 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" +.TH "AIM-UPDATE" "1" "May 2026" "aim " "Manage AppImages from the terminal without manual desktop setup or update juggling" .SH NAME aim-update - Check, apply, or configure updates diff --git a/docs/aim.1 b/docs/aim.1 index 808fc24..ea6a530 100644 --- a/docs/aim.1 +++ b/docs/aim.1 @@ -1,5 +1,5 @@ .nh -.TH "AIM" "1" "Apr 2026" "aim dev" "Manage AppImages from the terminal without manual desktop setup or update juggling" +.TH "AIM" "1" "May 2026" "aim dev" "Manage AppImages from the terminal without manual desktop setup or update juggling" .SH NAME aim - Manage AppImages from the terminal without manual desktop setup or update juggling diff --git a/internal/core/icon.go b/internal/core/icon.go index b44f456..d72bd8f 100644 --- a/internal/core/icon.go +++ b/internal/core/icon.go @@ -10,10 +10,10 @@ import ( util "github.com/slobbe/appimage-manager/internal/helpers" ) -func InstallDesktopIcon(appID, iconSrc string) (string, string, error) { - appID = strings.TrimSpace(appID) - if appID == "" { - return "", "", fmt.Errorf("app id cannot be empty") +func InstallDesktopIcon(iconID, iconSrc string) (string, string, error) { + iconID = strings.TrimSpace(iconID) + if iconID == "" { + return "", "", fmt.Errorf("icon id cannot be empty") } iconSrc = strings.TrimSpace(iconSrc) @@ -27,7 +27,7 @@ func InstallDesktopIcon(appID, iconSrc string) (string, string, error) { } destDir := iconInstallDir(ext) - destName := appID + ext + destName := iconID + ext destPath := filepath.Join(destDir, destName) desktopIconValue := destPath diff --git a/internal/core/integrate.go b/internal/core/integrate.go index 2da08ac..38efff6 100644 --- a/internal/core/integrate.go +++ b/internal/core/integrate.go @@ -54,67 +54,13 @@ func integrateFromLocalFile(ctx context.Context, src string, confirmUpdateOverwr appInfo.ID = extractionData.DesktopStem } - appID := appInfo.ID - - outDir := filepath.Join(config.AimDir, appID) - if err := os.MkdirAll(outDir, 0o755); err != nil { - return nil, err - } - tmpDir := (*extractionData).Dir defer func() { _ = os.RemoveAll(tmpDir) }() - extractionData.Dir = outDir - - if extractionData.ExecPath, err = util.Move(extractionData.ExecPath, filepath.Join(extractionData.Dir, appID+filepath.Ext(extractionData.ExecPath))); err != nil { - return nil, err - } - if extractionData.DesktopEntryPath, err = util.Move(extractionData.DesktopEntryPath, filepath.Join(extractionData.Dir, appID+filepath.Ext(extractionData.DesktopEntryPath))); err != nil { - return nil, err - } - if extractionData.IconPath, err = util.Move(extractionData.IconPath, filepath.Join(extractionData.Dir, appID+filepath.Ext(extractionData.IconPath))); err != nil { - return nil, err - } - - installedIconPath, desktopIconValue, err := InstallDesktopIcon(appID, extractionData.IconPath) - if err != nil { - return nil, err - } - extractionData.IconPath = installedIconPath - - if err := UpdateDesktopEntry(ctx, extractionData.DesktopEntryPath, extractionData.ExecPath, desktopIconValue); err != nil { - return nil, err - } - - if err := ValidateDesktopEntry(ctx, extractionData.DesktopEntryPath); err != nil { - return nil, err - } - - if err := util.MakeExecutable(extractionData.ExecPath); err != nil { - return nil, err - } - - desktopEntryLink, err := MakeDesktopLink(extractionData.DesktopEntryPath, appID+".desktop", "aim-"+appID+".desktop") - if err != nil { - return nil, err - } - - var existingApp *models.App - var replacementApp *models.App var updateFromAppImage *models.UpdateSource - - var updateInfoWG sync.WaitGroup - updateInfoWG.Add(1) - go func() { - defer updateInfoWG.Done() - - updateInfo, err := getEmbeddedUpdateInfo(extractionData.ExecPath) - if err != nil || updateInfo.Kind != models.UpdateZsync { - return - } - + if updateInfo, err := getEmbeddedUpdateInfo(extractionData.ExecPath); err == nil && updateInfo.Kind == models.UpdateZsync { updateFromAppImage = &models.UpdateSource{ Kind: models.UpdateZsync, Zsync: &models.ZsyncUpdateSource{ @@ -122,16 +68,8 @@ func integrateFromLocalFile(ctx context.Context, src string, confirmUpdateOverwr Transport: updateInfo.Transport, }, } - }() - - if appData, err := repo.GetApp(appID); err == nil { - existingApp = appData - } else if !strings.Contains(err.Error(), "does not exists in database") { - return nil, err } - updateInfoWG.Wait() - timestampNow := util.NowISO() addedAt := timestampNow lastCheckedAt := "" @@ -146,6 +84,36 @@ func integrateFromLocalFile(ctx context.Context, src string, confirmUpdateOverwr update = updateFromAppImage } + source := models.Source{ + Kind: models.SourceLocalFile, + LocalFile: &models.LocalFileSource{ + IntegratedAt: timestampNow, + OriginalPath: src, + }, + } + + upstreamID := appInfo.ID + incomingIdentity := &models.App{ + ID: upstreamID, + Name: appInfo.Name, + Source: source, + Update: update, + } + + appID, replacementApp, err := ResolveManagedAppID(appInfo.Name, upstreamID, src, incomingIdentity) + if err != nil { + return nil, err + } + + var existingApp *models.App + if replacementApp != nil { + existingApp = replacementApp + } else if appData, err := repo.GetApp(appID); err == nil { + existingApp = appData + } else if !strings.Contains(err.Error(), "does not exists in database") { + return nil, err + } + if existingApp != nil { if strings.TrimSpace(existingApp.AddedAt) != "" { addedAt = existingApp.AddedAt @@ -165,43 +133,48 @@ func integrateFromLocalFile(ctx context.Context, src string, confirmUpdateOverwr } } - source := models.Source{ - Kind: models.SourceLocalFile, - LocalFile: &models.LocalFileSource{ - IntegratedAt: timestampNow, - OriginalPath: src, - }, + outDir := filepath.Join(config.AimDir, appID) + if err := os.MkdirAll(outDir, 0o755); err != nil { + return nil, err } - if existingApp == nil { - match, err := FindEquivalentManagedApp(&models.App{ - ID: appID, - Source: source, - Update: update, - }) - if err != nil { - return nil, err - } - if match != nil { - existingApp = match - replacementApp = match - if strings.TrimSpace(existingApp.AddedAt) != "" { - addedAt = existingApp.AddedAt - } - lastCheckedAt = existingApp.LastCheckedAt + extractionData.Dir = outDir - if !updateFound { - update = existingApp.Update - } else if existingApp.Update != nil && existingApp.Update.Kind != models.UpdateNone && confirmUpdateOverwrite != nil { - overwrite, err := confirmUpdateOverwrite(existingApp.Update, update) - if err != nil { - return nil, err - } - if !overwrite { - update = existingApp.Update - } - } - } + if extractionData.ExecPath, err = util.Move(extractionData.ExecPath, filepath.Join(extractionData.Dir, appID+filepath.Ext(extractionData.ExecPath))); err != nil { + return nil, err + } + if extractionData.DesktopEntryPath, err = util.Move(extractionData.DesktopEntryPath, filepath.Join(extractionData.Dir, appID+filepath.Ext(extractionData.DesktopEntryPath))); err != nil { + return nil, err + } + if extractionData.IconPath, err = util.Move(extractionData.IconPath, filepath.Join(extractionData.Dir, appID+filepath.Ext(extractionData.IconPath))); err != nil { + return nil, err + } + + installedIconPath, desktopIconValue, err := InstallDesktopIcon(appID, extractionData.IconPath) + if err != nil { + return nil, err + } + extractionData.IconPath = installedIconPath + + if existingApp != nil && replacementApp == nil { + removeStaleInstalledIcon(existingApp.IconPath, installedIconPath, appID) + } + + if err := UpdateDesktopEntry(ctx, extractionData.DesktopEntryPath, extractionData.ExecPath, desktopIconValue); err != nil { + return nil, err + } + + if err := ValidateDesktopEntry(ctx, extractionData.DesktopEntryPath); err != nil { + return nil, err + } + + if err := util.MakeExecutable(extractionData.ExecPath); err != nil { + return nil, err + } + + desktopEntryLink, err := MakeDesktopLink(extractionData.DesktopEntryPath, appID+".desktop", "aim-"+appID+".desktop") + if err != nil { + return nil, err } var ( @@ -236,7 +209,7 @@ func integrateFromLocalFile(ctx context.Context, src string, confirmUpdateOverwr app := &models.App{ Name: appInfo.Name, - ID: appInfo.ID, + ID: appID, Version: appInfo.Version, ExecPath: extractionData.ExecPath, DesktopEntryPath: extractionData.DesktopEntryPath, @@ -326,3 +299,31 @@ func MakeDesktopLink(src, preferredName, fallbackName string) (string, error) { return desktopLink, nil } + +func removeStaleInstalledIcon(oldPath, newPath, appID string) { + oldPath = filepath.Clean(strings.TrimSpace(oldPath)) + newPath = filepath.Clean(strings.TrimSpace(newPath)) + if oldPath == "." || oldPath == "" || oldPath == newPath { + return + } + + appDir := filepath.Join(config.AimDir, appID) + if oldPath == appDir || strings.HasPrefix(oldPath, appDir+string(filepath.Separator)) { + return + } + + allApps, err := repo.GetAllApps() + if err != nil { + return + } + for _, app := range allApps { + if app == nil || strings.TrimSpace(app.ID) == appID { + continue + } + if filepath.Clean(strings.TrimSpace(app.IconPath)) == oldPath { + return + } + } + + _ = os.Remove(oldPath) +} diff --git a/internal/core/integrate_test.go b/internal/core/integrate_test.go index 17b19e5..33f696b 100644 --- a/internal/core/integrate_test.go +++ b/internal/core/integrate_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -35,8 +36,8 @@ func TestIntegrateFromLocalFileWithSymlinkedDesktopEntry(t *testing.T) { if app.Name != "0 A.D." { t.Fatalf("app.Name = %q, want %q", app.Name, "0 A.D.") } - if app.ID != "0ad" { - t.Fatalf("app.ID = %q, want %q", app.ID, "0ad") + if app.ID != "0-ad" { + t.Fatalf("app.ID = %q, want %q", app.ID, "0-ad") } desktopInfo, err := os.Lstat(app.DesktopEntryPath) @@ -150,7 +151,7 @@ func TestIntegrateFromLocalFileReturnsPromptlyWhenContextCanceled(t *testing.T) } } -func TestIntegrateFromLocalFilePreservesUpstreamDesktopStemForManagedIdentity(t *testing.T) { +func TestIntegrateFromLocalFileUsesReadableManagedIdentity(t *testing.T) { tmp := t.TempDir() setupIntegrationConfigForTest(t, tmp) stubDesktopValidationForTest(t) @@ -164,28 +165,184 @@ func TestIntegrateFromLocalFilePreservesUpstreamDesktopStemForManagedIdentity(t t.Fatalf("IntegrateFromLocalFile returned error: %v", err) } - if app.ID != "t3-code-desktop" { - t.Fatalf("app.ID = %q, want %q", app.ID, "t3-code-desktop") + if app.ID != "t3-code-alpha" { + t.Fatalf("app.ID = %q, want %q", app.ID, "t3-code-alpha") } - expectedAppDir := filepath.Join(config.AimDir, "t3-code-desktop") - if app.ExecPath != filepath.Join(expectedAppDir, "t3-code-desktop.AppImage") { + expectedAppDir := filepath.Join(config.AimDir, "t3-code-alpha") + if app.ExecPath != filepath.Join(expectedAppDir, "t3-code-alpha.AppImage") { t.Fatalf("ExecPath = %q", app.ExecPath) } - if app.DesktopEntryPath != filepath.Join(expectedAppDir, "t3-code-desktop.desktop") { + if app.DesktopEntryPath != filepath.Join(expectedAppDir, "t3-code-alpha.desktop") { t.Fatalf("DesktopEntryPath = %q", app.DesktopEntryPath) } - if app.IconPath != filepath.Join(config.IconThemeDir, "scalable", "apps", "t3-code-desktop.svg") { + if app.IconPath != filepath.Join(config.IconThemeDir, "scalable", "apps", "t3-code-alpha.svg") { t.Fatalf("IconPath = %q", app.IconPath) } if _, err := os.Stat(app.IconPath); err != nil { t.Fatalf("expected installed icon at %q: %v", app.IconPath, err) } - if app.DesktopEntryLink != filepath.Join(config.DesktopDir, "t3-code-desktop.desktop") { + if app.DesktopEntryLink != filepath.Join(config.DesktopDir, "t3-code-alpha.desktop") { t.Fatalf("DesktopEntryLink = %q", app.DesktopEntryLink) } } +func TestIntegrateFromLocalFileUsesNameForGenericDesktopID(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + stubDesktopValidationForTest(t) + stubIntegrationCacheRefreshForTest(t) + + appImagePath := filepath.Join(tmp, "desktop.AppImage") + writeFakeAppImageExtractorWithDesktop(t, appImagePath, "desktop.desktop", "ClickUp", "2603135dev5rzzi", "desktop", "desktop.svg") + + app, err := IntegrateFromLocalFile(context.Background(), appImagePath, nil) + if err != nil { + t.Fatalf("IntegrateFromLocalFile returned error: %v", err) + } + + if app.ID != "clickup" { + t.Fatalf("app.ID = %q, want %q", app.ID, "clickup") + } + + expectedAppDir := filepath.Join(config.AimDir, "clickup") + if app.ExecPath != filepath.Join(expectedAppDir, "clickup.AppImage") { + t.Fatalf("ExecPath = %q", app.ExecPath) + } + if app.DesktopEntryPath != filepath.Join(expectedAppDir, "clickup.desktop") { + t.Fatalf("DesktopEntryPath = %q", app.DesktopEntryPath) + } + if app.DesktopEntryLink != filepath.Join(config.DesktopDir, "clickup.desktop") { + t.Fatalf("DesktopEntryLink = %q", app.DesktopEntryLink) + } + if app.IconPath != filepath.Join(config.IconThemeDir, "scalable", "apps", "clickup.svg") { + t.Fatalf("IconPath = %q", app.IconPath) + } + + content, err := os.ReadFile(app.DesktopEntryPath) + if err != nil { + t.Fatalf("failed to read desktop entry: %v", err) + } + desktopEntry := string(content) + if !strings.Contains(desktopEntry, "Exec="+app.ExecPath+" %U") { + t.Fatalf("expected rewritten Exec to reference clickup AppImage, got:\n%s", desktopEntry) + } + if !strings.Contains(desktopEntry, "Icon="+app.IconPath) { + t.Fatalf("expected rewritten Icon to reference clickup icon, got:\n%s", desktopEntry) + } +} + +func TestIntegrateFromLocalFileMigratesGenericOldIDToReadableID(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + stubDesktopValidationForTest(t) + stubIntegrationCacheRefreshForTest(t) + + appImagePath := filepath.Join(tmp, "desktop.AppImage") + oldAppDir := filepath.Join(config.AimDir, "desktop") + oldExecPath := filepath.Join(oldAppDir, "desktop.AppImage") + oldDesktopPath := filepath.Join(oldAppDir, "desktop.desktop") + oldDesktopLink := filepath.Join(config.DesktopDir, "desktop.desktop") + oldIconPath := filepath.Join(config.IconThemeDir, "scalable", "apps", "desktop.svg") + + if err := os.MkdirAll(oldAppDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(oldIconPath), 0o755); err != nil { + t.Fatal(err) + } + for _, path := range []string{oldExecPath, oldDesktopPath, oldIconPath} { + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(oldDesktopPath, oldDesktopLink); err != nil { + t.Fatal(err) + } + if err := repo.AddApp(&models.App{ + ID: "desktop", + Name: "ClickUp", + Version: "2603135dev5rzzi", + ExecPath: oldExecPath, + DesktopEntryPath: oldDesktopPath, + DesktopEntryLink: oldDesktopLink, + IconPath: oldIconPath, + AddedAt: "2026-04-01T12:00:00Z", + LastCheckedAt: "2026-04-02T12:00:00Z", + Source: models.Source{ + Kind: models.SourceLocalFile, + LocalFile: &models.LocalFileSource{ + OriginalPath: appImagePath, + }, + }, + Update: &models.UpdateSource{Kind: models.UpdateNone}, + }, true); err != nil { + t.Fatal(err) + } + + writeFakeAppImageExtractorWithDesktop(t, appImagePath, "desktop.desktop", "ClickUp", "2603135dev5rzzi", "desktop", "desktop.svg") + + app, err := IntegrateFromLocalFile(context.Background(), appImagePath, nil) + if err != nil { + t.Fatalf("IntegrateFromLocalFile returned error: %v", err) + } + + if app.ID != "clickup" { + t.Fatalf("app.ID = %q, want clickup", app.ID) + } + if app.AddedAt != "2026-04-01T12:00:00Z" { + t.Fatalf("app.AddedAt = %q", app.AddedAt) + } + if app.LastCheckedAt != "2026-04-02T12:00:00Z" { + t.Fatalf("app.LastCheckedAt = %q", app.LastCheckedAt) + } + if _, err := repo.GetApp("desktop"); err == nil { + t.Fatal("expected old desktop id to be removed from database") + } + if _, err := repo.GetApp("clickup"); err != nil { + t.Fatalf("expected clickup id to be persisted: %v", err) + } + if _, err := os.Stat(oldAppDir); !os.IsNotExist(err) { + t.Fatalf("expected old app dir to be removed, got err=%v", err) + } + if _, err := os.Lstat(oldDesktopLink); !os.IsNotExist(err) { + t.Fatalf("expected old desktop link to be removed, got err=%v", err) + } + if _, err := os.Stat(oldIconPath); !os.IsNotExist(err) { + t.Fatalf("expected old icon to be removed, got err=%v", err) + } +} + +func TestRemoveStaleInstalledIconKeepsIconReferencedByAnotherApp(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + oldIconPath := filepath.Join(config.IconThemeDir, "scalable", "apps", "shared.svg") + newIconPath := filepath.Join(config.IconThemeDir, "scalable", "apps", "clickup.svg") + if err := os.MkdirAll(filepath.Dir(oldIconPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(oldIconPath, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newIconPath, []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + if err := repo.AddApp(&models.App{ + ID: "other", + Name: "Other", + IconPath: oldIconPath, + }, true); err != nil { + t.Fatal(err) + } + + removeStaleInstalledIcon(oldIconPath, newIconPath, "clickup") + + if _, err := os.Stat(oldIconPath); err != nil { + t.Fatalf("expected shared icon to remain: %v", err) + } +} + func TestIntegrateFromLocalFileReplacesEquivalentManagedAppWhenDesktopIDChanges(t *testing.T) { tmp := t.TempDir() setupIntegrationConfigForTest(t, tmp) diff --git a/internal/core/integration_cache.go b/internal/core/integration_cache.go index e7255e0..fb68498 100644 --- a/internal/core/integration_cache.go +++ b/internal/core/integration_cache.go @@ -21,6 +21,10 @@ func refreshDesktopIntegrationCaches(ctx context.Context) { integrationCacheWarn(fmt.Sprintf("Warning: failed to refresh desktop database: %v", err)) } + if err := refreshKDEServiceCache(ctx); err != nil { + integrationCacheWarn(fmt.Sprintf("Warning: failed to refresh KDE service cache: %v", err)) + } + ranXDG, err := runCommandIfAvailable(ctx, "xdg-icon-resource", "forceupdate") if err != nil { integrationCacheWarn(fmt.Sprintf("Warning: failed to refresh icon cache via xdg-icon-resource: %v", err)) @@ -40,6 +44,16 @@ func RefreshDesktopIntegrationCaches(ctx context.Context) { refreshDesktopIntegrationCaches(ctx) } +func refreshKDEServiceCache(ctx context.Context) error { + if ran, err := runCommandIfAvailable(ctx, "kbuildsycoca6"); ran || err != nil { + return err + } + if _, err := runCommandIfAvailable(ctx, "kbuildsycoca5"); err != nil { + return err + } + return nil +} + func runCommandIfAvailable(ctx context.Context, name string, args ...string) (bool, error) { binary, err := integrationCacheLookPath(name) if err != nil { diff --git a/internal/core/integration_cache_test.go b/internal/core/integration_cache_test.go index a0c71da..8aa62c3 100644 --- a/internal/core/integration_cache_test.go +++ b/internal/core/integration_cache_test.go @@ -97,6 +97,153 @@ func TestRefreshDesktopIntegrationCachesFallsBackToGtkIconCache(t *testing.T) { } } +func TestRefreshDesktopIntegrationCachesUsesKBuildSycoca6(t *testing.T) { + originalLookPath := integrationCacheLookPath + originalCommand := integrationCacheCommandContext + originalWarn := integrationCacheWarn + t.Cleanup(func() { + integrationCacheLookPath = originalLookPath + integrationCacheCommandContext = originalCommand + integrationCacheWarn = originalWarn + }) + + integrationCacheLookPath = func(name string) (string, error) { + switch name { + case "update-desktop-database", "kbuildsycoca6", "xdg-icon-resource": + return name, nil + default: + return "", exec.ErrNotFound + } + } + + var calls [][]string + integrationCacheCommandContext = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + call := append([]string{name}, arg...) + calls = append(calls, call) + return exec.CommandContext(ctx, "sh", "-c", "exit 0") + } + integrationCacheWarn = func(string) {} + + refreshDesktopIntegrationCaches(context.Background()) + + if len(calls) != 3 { + t.Fatalf("expected 3 command calls, got %d", len(calls)) + } + if calls[1][0] != "kbuildsycoca6" { + t.Fatalf("second command = %q, want kbuildsycoca6", calls[1][0]) + } +} + +func TestRefreshDesktopIntegrationCachesFallsBackToKBuildSycoca5(t *testing.T) { + originalLookPath := integrationCacheLookPath + originalCommand := integrationCacheCommandContext + originalWarn := integrationCacheWarn + t.Cleanup(func() { + integrationCacheLookPath = originalLookPath + integrationCacheCommandContext = originalCommand + integrationCacheWarn = originalWarn + }) + + integrationCacheLookPath = func(name string) (string, error) { + switch name { + case "update-desktop-database", "kbuildsycoca5", "xdg-icon-resource": + return name, nil + case "kbuildsycoca6": + return "", exec.ErrNotFound + default: + return "", exec.ErrNotFound + } + } + + var calls [][]string + integrationCacheCommandContext = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + call := append([]string{name}, arg...) + calls = append(calls, call) + return exec.CommandContext(ctx, "sh", "-c", "exit 0") + } + integrationCacheWarn = func(string) {} + + refreshDesktopIntegrationCaches(context.Background()) + + if len(calls) != 3 { + t.Fatalf("expected 3 command calls, got %d", len(calls)) + } + if calls[1][0] != "kbuildsycoca5" { + t.Fatalf("second command = %q, want kbuildsycoca5", calls[1][0]) + } +} + +func TestRefreshDesktopIntegrationCachesDoesNotWarnWhenKDEServiceCacheToolsMissing(t *testing.T) { + originalLookPath := integrationCacheLookPath + originalCommand := integrationCacheCommandContext + originalWarn := integrationCacheWarn + t.Cleanup(func() { + integrationCacheLookPath = originalLookPath + integrationCacheCommandContext = originalCommand + integrationCacheWarn = originalWarn + }) + + integrationCacheLookPath = func(name string) (string, error) { + switch name { + case "update-desktop-database", "xdg-icon-resource": + return name, nil + default: + return "", exec.ErrNotFound + } + } + integrationCacheCommandContext = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "exit 0") + } + + warnings := 0 + integrationCacheWarn = func(string) { + warnings++ + } + + refreshDesktopIntegrationCaches(context.Background()) + + if warnings != 0 { + t.Fatalf("warnings = %d, want 0", warnings) + } +} + +func TestRefreshDesktopIntegrationCachesWarnsWhenKDEServiceCacheFails(t *testing.T) { + originalLookPath := integrationCacheLookPath + originalCommand := integrationCacheCommandContext + originalWarn := integrationCacheWarn + t.Cleanup(func() { + integrationCacheLookPath = originalLookPath + integrationCacheCommandContext = originalCommand + integrationCacheWarn = originalWarn + }) + + integrationCacheLookPath = func(name string) (string, error) { + switch name { + case "update-desktop-database", "kbuildsycoca6", "xdg-icon-resource": + return name, nil + default: + return "", exec.ErrNotFound + } + } + integrationCacheCommandContext = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + if name == "kbuildsycoca6" { + return exec.CommandContext(ctx, "sh", "-c", "exit 1") + } + return exec.CommandContext(ctx, "sh", "-c", "exit 0") + } + + warnings := 0 + integrationCacheWarn = func(string) { + warnings++ + } + + refreshDesktopIntegrationCaches(context.Background()) + + if warnings == 0 { + t.Fatal("expected warning when KDE service cache refresh fails") + } +} + func TestRefreshDesktopIntegrationCachesWarnsOnCommandFailure(t *testing.T) { originalLookPath := integrationCacheLookPath originalCommand := integrationCacheCommandContext diff --git a/internal/core/managed_identity.go b/internal/core/managed_identity.go index bde5a02..8b698f5 100644 --- a/internal/core/managed_identity.go +++ b/internal/core/managed_identity.go @@ -1,13 +1,104 @@ package core import ( + "crypto/sha1" + "encoding/hex" + "fmt" "path/filepath" "strings" + util "github.com/slobbe/appimage-manager/internal/helpers" repo "github.com/slobbe/appimage-manager/internal/repository" models "github.com/slobbe/appimage-manager/internal/types" ) +func ResolveManagedAppID(appName, upstreamID, hashSeed string, incoming *models.App) (string, *models.App, error) { + candidates := managedIDCandidates(appName, upstreamID, hashSeed) + if len(candidates) == 0 { + return "", nil, fmt.Errorf("managed app id cannot be empty") + } + + allApps, err := repo.GetAllApps() + if err != nil { + return "", nil, err + } + + var equivalentApp *models.App + for _, existing := range allApps { + if existing == nil || strings.TrimSpace(existing.ID) == "" { + continue + } + if appsShareManagedIdentity(existing, incoming) { + if equivalentApp != nil && strings.TrimSpace(equivalentApp.ID) != strings.TrimSpace(existing.ID) { + equivalentApp = nil + break + } + equivalentApp = existing + } + } + + for _, candidate := range candidates { + existing := allApps[candidate] + if existing == nil { + if equivalentApp != nil && strings.TrimSpace(equivalentApp.ID) != candidate { + return candidate, equivalentApp, nil + } + return candidate, nil, nil + } + if appsShareManagedIdentity(existing, incoming) { + return candidate, nil, nil + } + } + + fallback := candidates[len(candidates)-1] + if equivalentApp != nil && strings.TrimSpace(equivalentApp.ID) != fallback { + return fallback, equivalentApp, nil + } + return fallback, nil, nil +} + +func managedIDCandidates(appName, upstreamID, hashSeed string) []string { + upstreamID = strings.TrimSpace(upstreamID) + base := util.Slugify(appName) + if base == "" { + base = upstreamID + } + base = strings.TrimSpace(base) + if base == "" { + return nil + } + + candidates := []string{base} + if upstreamID != "" { + withUpstream := base + "-" + upstreamID + if !containsString(candidates, withUpstream) { + candidates = append(candidates, withUpstream) + } + + withHash := withUpstream + "-" + shortIdentityHash(upstreamID, hashSeed) + if !containsString(candidates, withHash) { + candidates = append(candidates, withHash) + } + } + + return candidates +} + +func shortIdentityHash(parts ...string) string { + seed := strings.Join(parts, "\x00") + sum := sha1.Sum([]byte(seed)) + return hex.EncodeToString(sum[:])[:6] +} + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + func FindEquivalentManagedApp(incoming *models.App) (*models.App, error) { if incoming == nil { return nil, nil @@ -60,7 +151,7 @@ func UpdateSourcesEqual(a, b *models.UpdateSource) bool { switch a.Kind { case models.UpdateNone: - return true + return false case models.UpdateGitHubRelease: return a.GitHubRelease != nil && b.GitHubRelease != nil && strings.TrimSpace(a.GitHubRelease.Repo) == strings.TrimSpace(b.GitHubRelease.Repo) && diff --git a/internal/core/managed_identity_test.go b/internal/core/managed_identity_test.go new file mode 100644 index 0000000..7ce6b51 --- /dev/null +++ b/internal/core/managed_identity_test.go @@ -0,0 +1,145 @@ +package core + +import ( + "path/filepath" + "testing" + + repo "github.com/slobbe/appimage-manager/internal/repository" + models "github.com/slobbe/appimage-manager/internal/types" +) + +func TestResolveManagedAppIDUsesSlugifiedName(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + id, replacement, err := ResolveManagedAppID("ClickUp", "desktop", "/tmp/desktop.AppImage", incomingIdentity("desktop", "ClickUp", "/tmp/desktop.AppImage")) + if err != nil { + t.Fatalf("ResolveManagedAppID returned error: %v", err) + } + if id != "clickup" { + t.Fatalf("id = %q, want clickup", id) + } + if replacement != nil { + t.Fatalf("replacement = %#v, want nil", replacement) + } +} + +func TestResolveManagedAppIDFallsBackToUpstreamID(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + id, _, err := ResolveManagedAppID("---", "desktop", "/tmp/desktop.AppImage", incomingIdentity("desktop", "---", "/tmp/desktop.AppImage")) + if err != nil { + t.Fatalf("ResolveManagedAppID returned error: %v", err) + } + if id != "desktop" { + t.Fatalf("id = %q, want desktop", id) + } +} + +func TestResolveManagedAppIDDisambiguatesWithUpstreamID(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + if err := repo.AddApp(existingIdentity("notes", "Notes", "/tmp/vendor1.AppImage"), true); err != nil { + t.Fatal(err) + } + + id, _, err := ResolveManagedAppID("Notes", "com.vendor2.Notes", "/tmp/vendor2.AppImage", incomingIdentity("com.vendor2.Notes", "Notes", "/tmp/vendor2.AppImage")) + if err != nil { + t.Fatalf("ResolveManagedAppID returned error: %v", err) + } + if id != "notes-com.vendor2.Notes" { + t.Fatalf("id = %q, want notes-com.vendor2.Notes", id) + } +} + +func TestResolveManagedAppIDDisambiguatesWithHash(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + if err := repo.AddApp(existingIdentity("notes", "Notes", "/tmp/vendor1.AppImage"), true); err != nil { + t.Fatal(err) + } + if err := repo.AddApp(existingIdentity("notes-com.vendor2.Notes", "Notes", "/tmp/other-vendor2.AppImage"), true); err != nil { + t.Fatal(err) + } + + seed := "/tmp/vendor2.AppImage" + id, _, err := ResolveManagedAppID("Notes", "com.vendor2.Notes", seed, incomingIdentity("com.vendor2.Notes", "Notes", seed)) + if err != nil { + t.Fatalf("ResolveManagedAppID returned error: %v", err) + } + + want := "notes-com.vendor2.Notes-" + shortIdentityHash("com.vendor2.Notes", seed) + if id != want { + t.Fatalf("id = %q, want %q", id, want) + } +} + +func TestResolveManagedAppIDReusesSameEquivalentID(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + src := filepath.Join(tmp, "desktop.AppImage") + if err := repo.AddApp(existingIdentity("clickup", "ClickUp", src), true); err != nil { + t.Fatal(err) + } + + id, replacement, err := ResolveManagedAppID("ClickUp", "desktop", src, incomingIdentity("desktop", "ClickUp", src)) + if err != nil { + t.Fatalf("ResolveManagedAppID returned error: %v", err) + } + if id != "clickup" { + t.Fatalf("id = %q, want clickup", id) + } + if replacement != nil { + t.Fatalf("replacement = %#v, want nil", replacement) + } +} + +func TestResolveManagedAppIDReturnsEquivalentReplacementForOldID(t *testing.T) { + tmp := t.TempDir() + setupIntegrationConfigForTest(t, tmp) + + src := filepath.Join(tmp, "desktop.AppImage") + if err := repo.AddApp(existingIdentity("desktop", "ClickUp", src), true); err != nil { + t.Fatal(err) + } + + id, replacement, err := ResolveManagedAppID("ClickUp", "desktop", src, incomingIdentity("desktop", "ClickUp", src)) + if err != nil { + t.Fatalf("ResolveManagedAppID returned error: %v", err) + } + if id != "clickup" { + t.Fatalf("id = %q, want clickup", id) + } + if replacement == nil || replacement.ID != "desktop" { + t.Fatalf("replacement = %#v, want desktop app", replacement) + } +} + +func incomingIdentity(id, name, src string) *models.App { + return &models.App{ + ID: id, + Name: name, + Source: localFileSource(src), + } +} + +func existingIdentity(id, name, src string) *models.App { + return &models.App{ + ID: id, + Name: name, + Source: localFileSource(src), + } +} + +func localFileSource(src string) models.Source { + return models.Source{ + Kind: models.SourceLocalFile, + LocalFile: &models.LocalFileSource{ + OriginalPath: src, + }, + } +}