From 9aebec17e54b734c58469467e8e7a2a84ee7edea Mon Sep 17 00:00:00 2001 From: daijro Date: Wed, 15 Jul 2026 09:41:41 -0500 Subject: [PATCH] Fixes for version pinning in GUI - Replace icons with pins rather than checkboxes - Add a tooltip that says "Pin version" - Fix "Active" status bubble UX issues --- pythonlib/camoufox/gui/backend.py | 144 ++++++++++++++++++++++++++-- pythonlib/camoufox/gui/qml/main.qml | 106 +++++++++++++++----- 2 files changed, 216 insertions(+), 34 deletions(-) diff --git a/pythonlib/camoufox/gui/backend.py b/pythonlib/camoufox/gui/backend.py index dabe0b9..ac364d0 100644 --- a/pythonlib/camoufox/gui/backend.py +++ b/pythonlib/camoufox/gui/backend.py @@ -48,10 +48,12 @@ class Worker(QThread): class DownloadWorker(Worker): - def __init__(self, repo_config, version): + def __init__(self, repo_config, version, activate_after_install=False): super().__init__() self.repo_config = repo_config self.version = version + self.activate_after_install = activate_after_install + self.relative_path = None def run(self): try: @@ -81,7 +83,7 @@ class DownloadWorker(Worker): if sys.platform != 'win32': os.system(f'chmod -R 755 {shlex.quote(str(path))}') - set_active(f"browsers/{repo_name}/{folder}") + self.relative_path = f"browsers/{repo_name}/{folder}" self.done.emit(True, f"Installed v{self.version.version.full_string}") except Exception as e: msg = str(e) @@ -310,6 +312,7 @@ class Backend(QObject): debugChanged = Signal() currentRepoChanged = Signal() channelPrompt = Signal(int, str, str) + installedPrompt = Signal(int, str, str, bool, str) def __init__(self): super().__init__() @@ -650,6 +653,45 @@ class Backend(QObject): self._refresh() self.infoChanged.emit() + @Slot(int) + def unpinVersion(self, index): + item = self._version_model.get(index) + if not item or not item.is_pinned: + return + + cfg = load_config() + cfg.pop('pinned', None) + cfg.pop('pinned_sha', None) + save_config(cfg) + + self._refresh() + self.infoChanged.emit() + + @Slot(int) + def followVersionChannel(self, index): + item = self._version_model.get(index) + if not item or not item.version_data or not item.installed_data: + return + + ctype = "prerelease" if item.is_prerelease else "stable" + repo_name = self._current_repo.name + cfg = load_config() + cfg['channel'] = repo_name if ctype == "stable" else f"{repo_name}/{ctype}" + cfg.pop('pinned', None) + cfg.pop('pinned_sha', None) + cfg.update( + { + 'active_repo': repo_name, + 'active_build': item.version_data.version.build, + 'active_version': item.version_data.version.version, + } + ) + save_config(cfg) + set_active(item.installed_data.relative_path) + + self._refresh() + self.infoChanged.emit() + @Slot(int) def setFollowedChannel(self, index): _, keys, _ = self._build_channels() @@ -702,31 +744,50 @@ class Backend(QObject): break self._refresh() - self.infoChanged.emit() for idx, item in enumerate(self._version_model._items): if item.is_header: continue if item.is_prerelease == is_pre: - self._selected = idx - self.selectionChanged.emit() if item.installed_data: set_active(item.installed_data.relative_path) + self._refresh() + self._selected = idx + self.selectionChanged.emit() break + self.infoChanged.emit() + @Slot() def installSelected(self): item = self._version_model.get(self._selected) if item and not item.is_header and not item.is_installed: - self._run_worker(DownloadWorker(self._current_repo, item.version_data), self._on_done) + worker = DownloadWorker( + self._current_repo, + item.version_data, + activate_after_install=item.is_active, + ) + self._run_worker(worker, self._on_done) @Slot() def uninstallSelected(self): item = self._version_model.get(self._selected) if not item or not item.is_installed or not item.installed_data: return + installed = item.installed_data + was_active = item.is_active try: - remove_version(item.installed_data.path) + remove_version(installed.path) + if was_active: + cfg = load_config() + cfg.update( + { + 'active_repo': self._current_repo.name, + 'active_build': installed.version.build, + 'active_version': installed.version.version, + } + ) + save_config(cfg) self._set_status(f"Uninstalled {item.display}", "#2ecc71") self._refresh() self.infoChanged.emit() @@ -965,6 +1026,20 @@ class Backend(QObject): return v.sha256 == latest_sha.get(v.version.full_string) repo_key = get_repo_name(self._current_repo.repo) + channel = cfg.get('channel') or get_default_channel() + _, active_channel = (channel.split('/', 1) + ['stable'])[:2] + + def _is_active(v, inst): + if inst and inst.is_active: + return True + return ( + str(cfg.get('active_repo') or '').lower() == repo_key + and cfg.get('active_build') == v.version.build + and cfg.get('active_version') == v.version.version + and v.is_prerelease == (active_channel == 'prerelease') + and v.sha256 == latest_sha.get(v.version.full_string) + ) + installed_list = [iv for iv in list_installed() if iv.repo_name == repo_key] row_inst, extras = classify_installs(versions, installed_list) inst_by_id = {id(v): inst for v, inst in zip(versions, row_inst)} @@ -995,7 +1070,7 @@ class Backend(QObject): f"v{v.version.version}", v.version.build, is_prerelease=is_prerelease, - is_active=bool(inst and inst.is_active), + is_active=_is_active(v, inst), is_pinned=_is_pinned(v), is_installed=bool(inst), version_data=v, @@ -1063,13 +1138,66 @@ class Backend(QObject): worker.start() def _on_done(self, ok, msg): + worker = self._worker self._busy = False self._progress = -1 self.busyChanged.emit() self._set_status(msg if ok else f"Error: {msg}", "#2ecc71" if ok else "#e74c3c") if ok: + if ( + isinstance(worker, DownloadWorker) + and worker.activate_after_install + and worker.relative_path + ): + set_active(worker.relative_path) + self._channel_data = None self._refresh() + + if isinstance(worker, DownloadWorker): + target_idx = -1 + for idx, item in enumerate(self._version_model._items): + version = item.version_data + if not version: + continue + if ( + version.version.full_string == worker.version.version.full_string + and version.sha256 == worker.version.sha256 + ): + target_idx = idx + break + + if target_idx >= 0: + self._selected = target_idx + self.selectionChanged.emit() + + if not worker.activate_after_install: + versions = get_cached_versions(worker.repo_config.name) + latest = next( + ( + version + for version in versions + if version.is_prerelease == worker.version.is_prerelease + ), + None, + ) + is_latest = bool( + latest + and latest.version.full_string == worker.version.version.full_string + and latest.sha256 == worker.version.sha256 + ) + channel = worker.repo_config.name + if worker.version.is_prerelease: + channel += " (Prerelease)" + item = self._version_model.get(target_idx) + self.installedPrompt.emit( + target_idx, + item.display, + item.build, + is_latest, + channel, + ) + self.infoChanged.emit() self._load_spoof_from_cache() self.debugChanged.emit() diff --git a/pythonlib/camoufox/gui/qml/main.qml b/pythonlib/camoufox/gui/qml/main.qml index ec39a45..44c2225 100644 --- a/pythonlib/camoufox/gui/qml/main.qml +++ b/pythonlib/camoufox/gui/qml/main.qml @@ -152,24 +152,42 @@ ApplicationWindow { } } - component Check: Rectangle { - property bool on: false + component PinButton: Item { + id: pinButton + property bool pinned: false + property string pinToolTip: "Pin version" + property string unpinToolTip: "Unpin version" + signal clicked - width: s3 - height: s3 - radius: s1 - color: on ? c.accent : "transparent" - border.color: on ? c.accent : c.muted - border.width: 1 + width: s4 + height: s4 - Text { + Icon { anchors.centerIn: parent - text: "\uE73E" - color: c.bg - font.family: fontIcon - font.pixelSize: Math.round(8 * scale) - visible: parent.on + icon: "\uE840" + color: pinButton.pinned ? c.accent : c.muted + opacity: pinButton.enabled ? 1 : 0.4 } + + Icon { + anchors.centerIn: parent + icon: "\uE842" + color: c.accent + opacity: pinButton.enabled ? 1 : 0.4 + visible: pinButton.pinned + } + + MouseArea { + id: pinMa + anchors.fill: parent + hoverEnabled: true + cursorShape: pinButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (pinButton.enabled) pinButton.clicked() + } + + ToolTip.visible: pinMa.containsMouse && pinButton.enabled + ToolTip.text: pinButton.pinned ? pinButton.unpinToolTip : pinButton.pinToolTip + ToolTip.delay: 500 } component Input: Rectangle { @@ -335,14 +353,16 @@ ApplicationWindow { property string dlgAct: "" property string dlgVer: "" property string dlgBuild: "" + property string dlgChannel: "" property int dlgIdx: -1 - function showDlg(act, idx, ver, build) { + function showDlg(act, idx, ver, build, channel) { backend.selectVersion(idx) dlgAct = act dlgIdx = idx dlgVer = ver dlgBuild = build + dlgChannel = channel || "" } Rectangle { @@ -377,7 +397,9 @@ ApplicationWindow { Bold { text: (dlgAct === "install" || dlgAct === "promptInstall") ? "Install" - : dlgAct === "uninstall" ? "Uninstall" : "Set Active" + : dlgAct === "uninstall" ? "Uninstall" + : dlgAct === "promptPin" ? "Pin Version" + : dlgAct === "promptFollow" ? "Follow Channel" : "Set Active" font.pixelSize: textMd } @@ -386,6 +408,10 @@ ApplicationWindow { wrapMode: Text.Wrap text: dlgAct === "promptInstall" ? "Camoufox " + dlgVer + "-" + dlgBuild + " is now active but not installed. Install it now?" + : dlgAct === "promptPin" + ? "Camoufox " + dlgVer + "-" + dlgBuild + " is installed but not active. Would you like to pin this version?" + : dlgAct === "promptFollow" + ? "Camoufox " + dlgVer + "-" + dlgBuild + " is installed but not active. Would you like to follow " + dlgChannel + " for updates?" : (dlgAct === "install" ? "Install" : dlgAct === "uninstall" ? "Remove" : "Set") + " Camoufox " + dlgVer + "-" + dlgBuild + (dlgAct === "install" ? "?" : dlgAct === "uninstall" ? " from disk?" : " as active?") @@ -406,13 +432,25 @@ ApplicationWindow { spacing: s2 Btn { - text: dlgAct === "promptInstall" ? "Skip" : "Cancel" + text: (dlgAct === "promptInstall" || dlgAct === "promptPin" || dlgAct === "promptFollow") ? "Skip" : "Cancel" onClicked: dlgAct = "" } + Btn { + visible: dlgAct === "promptFollow" + text: "Pin Version" + accent: c.accent + onClicked: { + backend.setActive(dlgIdx) + dlgAct = "" + } + } + Btn { text: (dlgAct === "install" || dlgAct === "promptInstall") ? "Install" - : dlgAct === "uninstall" ? "Uninstall" : "Set Active" + : dlgAct === "uninstall" ? "Uninstall" + : dlgAct === "promptPin" ? "Pin Version" + : dlgAct === "promptFollow" ? "Follow Channel" : "Set Active" accent: dlgAct === "uninstall" ? c.err : (dlgAct === "install" || dlgAct === "promptInstall") ? c.ok : c.accent onClicked: { @@ -420,6 +458,10 @@ ApplicationWindow { backend.installSelected() } else if (dlgAct === "uninstall") { backend.uninstallSelected() + } else if (dlgAct === "promptPin") { + backend.setActive(dlgIdx) + } else if (dlgAct === "promptFollow") { + backend.followVersionChannel(dlgIdx) } else if (dlgAct === "channel") { backend.confirmFollowChannel() if (backend.canInstall) { @@ -449,6 +491,9 @@ ApplicationWindow { function onChannelPrompt(idx, display, build) { showDlg("channel", idx, display, build) } + function onInstalledPrompt(idx, display, build, latest, channel) { + showDlg(latest ? "promptFollow" : "promptPin", idx, display, build, channel) + } } // Layout @@ -601,12 +646,18 @@ ApplicationWindow { Row { spacing: s2 - Check { on: backend.followedChannel === backend.channelKeys[index]; anchors.verticalCenter: parent.verticalCenter } + PinButton { + pinned: backend.followedChannel === backend.channelKeys[index] + pinToolTip: "Follow channel" + unpinToolTip: "Following channel" + anchors.verticalCenter: parent.verticalCenter + onClicked: backend.setFollowedChannel(index) + } T { text: (backend.followedChannel === backend.channelKeys[index] ? "Following " : "Follow ") + modelData; anchors.verticalCenter: parent.verticalCenter } } Muted { - leftPadding: s3 + s2 + leftPadding: s4 + s2 text: backend.channelLatest[index] ? ("Latest: " + backend.channelLatest[index]) : "(sync first)" font.pixelSize: Math.round(10 * scale) } @@ -752,12 +803,15 @@ ApplicationWindow { spacing: s3 visible: !model.isHeader - Check { - on: model.isPinned - MouseArea { - anchors.fill: parent - cursorShape: model.note === "" ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (model.note === "") showDlg("setActive", index, model.display, model.build) + PinButton { + pinned: model.isPinned + enabled: model.note === "" + onClicked: { + if (model.isPinned) { + backend.unpinVersion(index) + } else { + showDlg("setActive", index, model.display, model.build) + } } }