diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 27f7931d9..6ee3a667e 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -106,6 +106,8 @@ func init() { ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { printIPCHelp() }) + pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins") + pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them") } var debugSrvCmd = &cobra.Command{ @@ -184,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{ } var pluginsUpdateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a plugin by ID", - Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.", - Args: cobra.ExactArgs(1), + Use: "update [plugin-id]", + Short: "Update a plugin by ID, or all plugins", + Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.", + Args: func(cmd *cobra.Command, args []string) error { + updateAll, _ := cmd.Flags().GetBool("all") + if updateAll { + if len(args) > 0 { + return fmt.Errorf("cannot specify plugin ID when using --all/-a") + } + return nil + } + if len(args) != 1 { + return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a") + } + return nil + }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp @@ -195,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{ return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp }, Run: func(cmd *cobra.Command, args []string) { + checkOnly, _ := cmd.Flags().GetBool("check") + updateAll, _ := cmd.Flags().GetBool("all") + if checkOnly { + if updateAll { + if err := checkAllPluginsCLI(); err != nil { + log.Fatalf("Error checking updates: %v", err) + } + return + } + if err := checkPluginCLI(args[0]); err != nil { + log.Fatalf("Error checking update: %v", err) + } + return + } + if updateAll { + if err := updateAllPluginsCLI(); err != nil { + log.Fatalf("Error updating plugins: %v", err) + } + return + } if err := updatePluginCLI(args[0]); err != nil { log.Fatalf("Error updating plugin: %v", err) } @@ -343,7 +377,11 @@ func listInstalledPlugins() error { fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames)) for _, id := range installedNames { if plugin, ok := pluginMap[id]; ok { - fmt.Printf(" %s\n", plugin.Name) + hasUpdateStr := "" + if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates { + hasUpdateStr = " (update available)" + } + fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr) fmt.Printf(" ID: %s\n", plugin.ID) fmt.Printf(" Category: %s\n", plugin.Category) fmt.Printf(" Author: %s\n", plugin.Author) @@ -523,6 +561,160 @@ func updatePluginCLI(idOrName string) error { return nil } +func updateAllPluginsCLI() error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + installed, err := manager.ListInstalled() + if err != nil { + return fmt.Errorf("failed to list installed plugins: %w", err) + } + + pluginList, _ := registry.List() + + var errs []error + for _, pluginID := range installed { + plugin := plugins.FindByIDOrName(pluginID, pluginList) + if plugin != nil { + fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Update(*plugin); err != nil { + if strings.Contains(err.Error(), "cannot update system plugin") { + fmt.Printf("Skipping system plugin: %s\n", plugin.Name) + } else { + errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err)) + } + } else { + fmt.Printf("Plugin updated successfully: %s\n", plugin.Name) + } + } else { + fmt.Printf("Updating plugin: %s\n", pluginID) + if err := manager.UpdateByIDOrName(pluginID); err != nil { + if strings.Contains(err.Error(), "cannot update system plugin") { + fmt.Printf("Skipping system plugin: %s\n", pluginID) + } else { + errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err)) + } + } else { + fmt.Printf("Plugin updated successfully: %s\n", pluginID) + } + } + } + + if len(errs) > 0 { + for _, err := range errs { + fmt.Fprintf(os.Stderr, "%v\n", err) + } + return fmt.Errorf("failed to update some plugins") + } + + return nil +} + +func checkPluginCLI(idOrName string) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(idOrName, pluginList) + + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + if !installed { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + hasUpdates, err := manager.HasUpdates(plugin.ID, *plugin) + if err != nil { + return fmt.Errorf("failed to check updates: %w", err) + } + + if hasUpdates { + fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + } else { + fmt.Printf("Plugin is up to date: %s\n", plugin.Name) + } + return nil + } + + dummyPlugin := plugins.Plugin{ID: idOrName} + hasUpdates, err := manager.HasUpdates(idOrName, dummyPlugin) + if err != nil { + return fmt.Errorf("failed to check updates: %w", err) + } + + if hasUpdates { + fmt.Printf("Update available for plugin: %s\n", idOrName) + } else { + fmt.Printf("Plugin is up to date: %s\n", idOrName) + } + return nil +} + +func checkAllPluginsCLI() error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + installed, err := manager.ListInstalled() + if err != nil { + return fmt.Errorf("failed to list installed plugins: %w", err) + } + + pluginList, _ := registry.List() + + var count int + for _, pluginID := range installed { + plugin := plugins.FindByIDOrName(pluginID, pluginList) + var hasUpdates bool + var name string + + if plugin != nil { + name = plugin.Name + hasUpdates, _ = manager.HasUpdates(pluginID, *plugin) + } else { + name = pluginID + dummyPlugin := plugins.Plugin{ID: pluginID} + hasUpdates, _ = manager.HasUpdates(pluginID, dummyPlugin) + } + + if hasUpdates { + fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID) + count++ + } + } + + if count > 0 { + fmt.Printf("\nFound %d plugin(s) with available updates.\n", count) + } else { + fmt.Println("All plugins are up to date.") + } + + return nil +} + func getCommonCommands() []*cobra.Command { return []*cobra.Command{ versionCmd, diff --git a/core/internal/server/plugins/list_installed.go b/core/internal/server/plugins/list_installed.go index b771b0e68..c7a96c93f 100644 --- a/core/internal/server/plugins/list_installed.go +++ b/core/internal/server/plugins/list_installed.go @@ -3,10 +3,13 @@ package plugins import ( "fmt" "net" + "os" + "path/filepath" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + git "github.com/go-git/go-git/v6" ) func HandleListInstalled(conn net.Conn, req models.Request) { @@ -47,6 +50,8 @@ func HandleListInstalled(conn net.Conn, req models.Request) { hasUpdate = hasUpdates } + diffURL := getGitDiffURL(manager.GetPluginsDir(), plugin.ID, plugin.Repo) + result = append(result, PluginInfo{ ID: plugin.ID, Name: plugin.Name, @@ -61,6 +66,7 @@ func HandleListInstalled(conn net.Conn, req models.Request) { FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"), HasUpdate: hasUpdate, RequiresDMS: plugin.RequiresDMS, + DiffURL: diffURL, }) } else { result = append(result, PluginInfo{ @@ -75,3 +81,60 @@ func HandleListInstalled(conn net.Conn, req models.Request) { models.Respond(conn, req.ID, result) } + +func getGitDiffURL(pluginsDir string, pluginID string, repoURL string) string { + if repoURL == "" { + return "" + } + + repoURL = strings.TrimSuffix(repoURL, ".git") + + // Standalone path + pluginPath := filepath.Join(pluginsDir, pluginID) + metaPath := pluginPath + ".meta" + + // If metadata file exists, it's a monorepo + if _, err := os.Stat(metaPath); err == nil { + reposDir := filepath.Join(pluginsDir, ".repos") + parts := strings.Split(repoURL, "/") + repoName := parts[len(parts)-1] + pluginPath = filepath.Join(reposDir, repoName) + } + + repo, err := git.PlainOpen(pluginPath) + if err != nil { + return repoURL + } + + head, err := repo.Head() + if err != nil { + return repoURL + } + localHash := head.Hash().String() + + remote, err := repo.Remote("origin") + if err != nil { + return repoURL + } + + refs, err := remote.List(&git.ListOptions{}) + if err != nil { + return repoURL + } + + var remoteHead string + for _, ref := range refs { + if ref.Name().IsBranch() { + if ref.Name().Short() == "main" || ref.Name().Short() == "master" { + remoteHead = ref.Hash().String() + break + } + } + } + + if remoteHead != "" && localHash != "" && localHash != remoteHead { + return fmt.Sprintf("%s/compare/%s...%s", repoURL, localHash[:7], remoteHead[:7]) + } + + return repoURL +} diff --git a/core/internal/server/plugins/types.go b/core/internal/server/plugins/types.go index 8290f72ad..5ecaf984f 100644 --- a/core/internal/server/plugins/types.go +++ b/core/internal/server/plugins/types.go @@ -17,6 +17,7 @@ type PluginInfo struct { Note string `json:"note,omitempty"` HasUpdate bool `json:"hasUpdate,omitempty"` RequiresDMS string `json:"requires_dms,omitempty"` + DiffURL string `json:"diffUrl,omitempty"` } type SuccessResult struct { diff --git a/quickshell/Modules/Settings/PluginUpdatesDialog.qml b/quickshell/Modules/Settings/PluginUpdatesDialog.qml new file mode 100644 index 000000000..94b1b3556 --- /dev/null +++ b/quickshell/Modules/Settings/PluginUpdatesDialog.qml @@ -0,0 +1,299 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Common +import qs.Widgets +import qs.Services + +FloatingWindow { + id: root + + property bool disablePopupTransparency: true + property var updatesList: [] + property bool isUpdating: false + property string currentUpdatingPlugin: "" + property var parentModal: null + parentWindow: parentModal + + title: I18n.tr("Plugin Updates") + implicitWidth: 520 + implicitHeight: 400 + minimumSize: Qt.size(480, 300) + maximumSize: Qt.size(600, 600) + color: Theme.surfaceContainer + visible: false + + function show(list) { + updatesList = list || []; + visible = true; + } + + function hide() { + if (!isUpdating) { + visible = false; + } + } + + function updateSingle(plugin) { + if (isUpdating) return; + isUpdating = true; + currentUpdatingPlugin = plugin.name; + + DMSService.update(plugin.name, response => { + isUpdating = false; + currentUpdatingPlugin = ""; + if (response.error) { + ToastService.showError(I18n.tr("Failed to update %1: %2").arg(plugin.name).arg(response.error)); + } else { + ToastService.showInfo(I18n.tr("Plugin updated: %1").arg(plugin.name)); + PluginService.forceRescanPlugin(plugin.id); + DMSService.listInstalled(); + } + }); + } + + function updateAll() { + if (isUpdating) return; + isUpdating = true; + + var list = updatesList.slice(); + var idx = 0; + + function updateNext() { + if (idx >= list.length) { + isUpdating = false; + currentUpdatingPlugin = ""; + ToastService.showInfo(I18n.tr("All plugins updated successfully")); + DMSService.listInstalled(); + root.hide(); + return; + } + + var plugin = list[idx]; + currentUpdatingPlugin = plugin.name; + + DMSService.update(plugin.name, response => { + if (response.error) { + ToastService.showError(I18n.tr("Failed to update %1: %2").arg(plugin.name).arg(response.error)); + } else { + PluginService.forceRescanPlugin(plugin.id); + } + idx++; + updateNext(); + }); + } + + updateNext(); + } + + FocusScope { + anchors.fill: parent + focus: true + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + root.hide(); + event.accepted = true; + } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + // Header + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "download" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Available Updates") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: parent.width - parent.spacing * 2 - Theme.iconSize - parent.children[1].implicitWidth - closeBtn.width + height: 1 + } + + DankActionButton { + id: closeBtn + iconName: "close" + iconSize: Theme.iconSize - 2 + iconColor: Theme.outline + anchors.verticalCenter: parent.verticalCenter + enabled: !root.isUpdating + onClicked: root.hide() + } + } + + // Spinner / Loading state + Item { + width: parent.width + height: isUpdating ? 40 : 0 + visible: isUpdating + clip: true + + Behavior on height { + NumberAnimation { duration: Theme.shortDuration } + } + + Row { + anchors.centerIn: parent + spacing: Theme.spacingM + + DankSpinner { + running: root.isUpdating + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: root.currentUpdatingPlugin ? I18n.tr("Updating %1...").arg(root.currentUpdatingPlugin) : I18n.tr("Updating plugins...") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Scrollable Content + DankFlickable { + width: parent.width + height: parent.height - parent.spacing * 3 - parent.children[0].height - (isUpdating ? 40 : 0) - bottomRow.height - Theme.spacingL + clip: true + contentHeight: listCol.implicitHeight + + Column { + id: listCol + width: parent.width + spacing: Theme.spacingM + + Repeater { + model: root.updatesList + + delegate: StyledRect { + width: parent.width + height: 64 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.width: 0 + + Row { + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingM + + DankIcon { + name: modelData.icon || "extension" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + width: parent.width - Theme.iconSize - Theme.spacingM - actionButtonsRow.width - Theme.spacingM + + StyledText { + text: modelData.name || "" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: modelData.author ? I18n.tr("By %1").arg(modelData.author) : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + + Row { + id: actionButtonsRow + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankButton { + text: I18n.tr("Diff") + iconName: "open_in_new" + visible: !!modelData.diffUrl || !!modelData.repo + backgroundColor: Theme.surfaceContainerHighest + textColor: Theme.surfaceText + onClicked: { + Qt.openUrlExternally(modelData.diffUrl || modelData.repo); + } + } + + DankButton { + text: I18n.tr("Update") + iconName: "download" + enabled: !root.isUpdating + onClicked: { + root.updateSingle(modelData); + } + } + } + } + } + } + + StyledText { + width: parent.width + text: I18n.tr("No updates available.") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + visible: root.updatesList.length === 0 + } + } + } + + // Bottom bar + Row { + id: bottomRow + anchors.right: parent.right + spacing: Theme.spacingM + + DankButton { + text: I18n.tr("Cancel") + iconName: "close" + enabled: !root.isUpdating + backgroundColor: Theme.surfaceContainerHighest + textColor: Theme.surfaceText + onClicked: root.hide() + } + + DankButton { + text: I18n.tr("Update All") + iconName: "download" + enabled: !root.isUpdating && root.updatesList.length > 0 + onClicked: root.updateAll() + } + } + } + } + + FloatingWindowControls { + id: windowControls + targetWindow: root + } +} diff --git a/quickshell/Modules/Settings/PluginsTab.qml b/quickshell/Modules/Settings/PluginsTab.qml index a5e1445dd..1699209d5 100644 --- a/quickshell/Modules/Settings/PluginsTab.qml +++ b/quickshell/Modules/Settings/PluginsTab.qml @@ -20,6 +20,11 @@ FocusScope { property string searchQuery: "" property var filteredPlugins: [] + readonly property var pluginsWithUpdates: { + if (!DMSService.installedPlugins) return []; + return DMSService.installedPlugins.filter(p => p.hasUpdate === true); + } + function updateFilteredPlugins() { var query = searchQuery.toLowerCase(); filteredPlugins = PluginService.availablePluginsList.filter(plugin => { @@ -249,6 +254,15 @@ FocusScope { } } + DankButton { + text: I18n.tr("Update All") + iconName: "download" + enabled: DMSService.dmsAvailable && pluginsTab.pluginsWithUpdates.length > 0 + onClicked: { + showPluginUpdatesDialog(); + } + } + DankButton { text: PluginService.pluginDirectoryExists ? I18n.tr("Open Dir") : I18n.tr("Create Dir") iconName: PluginService.pluginDirectoryExists ? "folder_open" : "create_new_folder" @@ -533,4 +547,23 @@ FocusScope { if (pluginBrowserLoader.item) pluginBrowserLoader.item.show(); } + + LazyLoader { + id: pluginUpdatesDialogLoader + active: false + + PluginUpdatesDialog { + id: pluginUpdatesDialogItem + + Component.onCompleted: { + pluginUpdatesDialogItem.parentModal = pluginsTab.parentModal; + } + } + } + + function showPluginUpdatesDialog() { + pluginUpdatesDialogLoader.active = true; + if (pluginUpdatesDialogLoader.item) + pluginUpdatesDialogLoader.item.show(pluginsTab.pluginsWithUpdates); + } }