feat(update): add Release/Nightly channel switching

- Add POST /api/lm/switch-channel endpoint with git init / ZIP fallback
- Add _backup_git/_restore_git helpers with safe rollback
- Version-info endpoint now returns has_git flag for auto-detection
- Check-updates always returns releases (changelog) regardless of channel
- Nightly mode shows 'N commits behind main' with commit hash and date
- View on GitHub link points to /commits/main in nightly mode
- Channel toggle UI with pill-style buttons in update modal
- Confirmation dialog with Esc / backdrop-dismiss support
- Channel derived from has_git on every page load, no localStorage
- i18n: 11 new keys translated across 9 non-English locales
- CSS: unified card-style sections in _base.css
- Tests: 8 new tests covering switch-channel, nightly response, init_git_repo
This commit is contained in:
Will Miao
2026-07-27 20:27:05 +08:00
parent 986128076e
commit 66d1c96783
16 changed files with 990 additions and 96 deletions

View File

@@ -1752,6 +1752,12 @@
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.", "checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
"showNotifications": "Update-Benachrichtigungen anzeigen", "showNotifications": "Update-Benachrichtigungen anzeigen",
"latestBadge": "Neueste", "latestBadge": "Neueste",
"latestMain": "Main-Branch",
"channel": "Update-Kanal",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Update wird vorbereitet...", "preparing": "Update wird vorbereitet...",
"installing": "Update wird installiert...", "installing": "Update wird installiert...",
@@ -1772,6 +1778,15 @@
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.", "warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
"enable": "Nightly Updates aktivieren" "enable": "Nightly Updates aktivieren"
}, },
"channelSwitch": {
"nightlyTitle": "Zu Nightly-Kanal wechseln",
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
"releaseTitle": "Zu Release-Kanal wechseln",
"releaseMessage": "Der Wechsel zu Release entfernt das Git-Repository und installiert die neueste stabile Version. Zukünftige Updates verwenden nur stabile Versionen.",
"switching": "Wechsle zu {channel}-Kanal...",
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
"failed": "Kanalwechsel fehlgeschlagen"
},
"banners": { "banners": {
"recent": "Neueste Mitteilungen", "recent": "Neueste Mitteilungen",
"empty": "Keine aktuellen Banner verfügbar.", "empty": "Keine aktuellen Banner verfügbar.",
@@ -2235,4 +2250,4 @@
"retry": "Wiederholen" "retry": "Wiederholen"
} }
} }
} }

View File

@@ -1752,6 +1752,12 @@
"checkingMessage": "Please wait while we check for the latest version.", "checkingMessage": "Please wait while we check for the latest version.",
"showNotifications": "Show update notifications", "showNotifications": "Show update notifications",
"latestBadge": "Latest", "latestBadge": "Latest",
"latestMain": "Latest main",
"channel": "Update Channel",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Preparing update...", "preparing": "Preparing update...",
"installing": "Installing update...", "installing": "Installing update...",
@@ -1772,6 +1778,15 @@
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.", "warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
"enable": "Enable Nightly Updates" "enable": "Enable Nightly Updates"
}, },
"channelSwitch": {
"nightlyTitle": "Switch to Nightly Channel",
"nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.",
"releaseTitle": "Switch to Release Channel",
"releaseMessage": "Switching to Release will remove the Git repository and install the latest stable release. Future updates will use stable releases only.",
"switching": "Switching to {channel} channel...",
"completed": "Successfully switched to {channel} channel",
"failed": "Failed to switch channel"
},
"banners": { "banners": {
"recent": "Recent messages", "recent": "Recent messages",
"empty": "No recent banners yet.", "empty": "No recent banners yet.",

View File

@@ -1751,7 +1751,13 @@
"checkingUpdates": "Comprobando actualizaciones...", "checkingUpdates": "Comprobando actualizaciones...",
"checkingMessage": "Por favor espera mientras comprobamos la última versión.", "checkingMessage": "Por favor espera mientras comprobamos la última versión.",
"showNotifications": "Mostrar notificaciones de actualización", "showNotifications": "Mostrar notificaciones de actualización",
"latestBadge": "Último", "latestBadge": "Última",
"latestMain": "Rama main",
"channel": "Canal de actualizacion",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Preparando actualización...", "preparing": "Preparando actualización...",
"installing": "Instalando actualización...", "installing": "Instalando actualización...",
@@ -1772,6 +1778,15 @@
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.", "warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
"enable": "Habilitar actualizaciones nocturnas" "enable": "Habilitar actualizaciones nocturnas"
}, },
"channelSwitch": {
"nightlyTitle": "Cambiar a canal Nightly",
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
"releaseTitle": "Cambiar a canal Release",
"releaseMessage": "Cambiar a Release eliminara el repositorio Git e instalara la ultima version estable. Las futuras actualizaciones usaran solo versiones estables.",
"switching": "Cambiando a canal {channel}...",
"completed": "Cambio a canal {channel} exitoso",
"failed": "Error al cambiar de canal"
},
"banners": { "banners": {
"recent": "Notificaciones recientes", "recent": "Notificaciones recientes",
"empty": "No hay banners recientes.", "empty": "No hay banners recientes.",
@@ -2235,4 +2250,4 @@
"retry": "Reintentar" "retry": "Reintentar"
} }
} }
} }

View File

@@ -1751,7 +1751,13 @@
"checkingUpdates": "Vérification des mises à jour...", "checkingUpdates": "Vérification des mises à jour...",
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.", "checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
"showNotifications": "Afficher les notifications de mise à jour", "showNotifications": "Afficher les notifications de mise à jour",
"latestBadge": "Dernier", "latestBadge": "Dernière",
"latestMain": "Branche main",
"channel": "Canal de mise a jour",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Préparation de la mise à jour...", "preparing": "Préparation de la mise à jour...",
"installing": "Installation de la mise à jour...", "installing": "Installation de la mise à jour...",
@@ -1772,6 +1778,15 @@
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.", "warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
"enable": "Activer les mises à jour nightly" "enable": "Activer les mises à jour nightly"
}, },
"channelSwitch": {
"nightlyTitle": "Passer au canal Nightly",
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
"releaseTitle": "Passer au canal Release",
"releaseMessage": "Passer a Release supprimera le depot Git et installera la derniere version stable. Les futures mises a jour utiliseront uniquement des versions stables.",
"switching": "Passage au canal {channel}...",
"completed": "Basculement vers le canal {channel} reussi",
"failed": "Echec du changement de canal"
},
"banners": { "banners": {
"recent": "Messages récents", "recent": "Messages récents",
"empty": "Aucune bannière récente.", "empty": "Aucune bannière récente.",
@@ -2235,4 +2250,4 @@
"retry": "Réessayer" "retry": "Réessayer"
} }
} }
} }

View File

@@ -1751,7 +1751,13 @@
"checkingUpdates": "בודק עדכונים...", "checkingUpdates": "בודק עדכונים...",
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.", "checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
"showNotifications": "הצג התראות עדכון", "showNotifications": "הצג התראות עדכון",
"latestBadge": "עדכן", "latestBadge": "אחרון",
"latestMain": "ענף main",
"channel": "ערוץ עדכון",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "מכין עדכון...", "preparing": "מכין עדכון...",
"installing": "מתקין עדכון...", "installing": "מתקין עדכון...",
@@ -1772,6 +1778,15 @@
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.", "warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
"enable": "הפעל עדכונים ליליים" "enable": "הפעל עדכונים ליליים"
}, },
"channelSwitch": {
"nightlyTitle": "מעבר לערוץ Nightly",
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
"releaseTitle": "מעבר לערוץ Release",
"releaseMessage": "מעבר ל-Release יסיר את מאגר ה-Git ויתקין את הגרסה היציבה האחרונה. עדכונים עתידיים ישתמשו בגרסאות יציבות בלבד.",
"switching": "מעבר לערוץ {channel}...",
"completed": "המעבר לערוץ {channel} הושלם",
"failed": "החלפת ערוץ נכשלה"
},
"banners": { "banners": {
"recent": "הודעות אחרונות", "recent": "הודעות אחרונות",
"empty": "אין כרגע באנרים אחרונים.", "empty": "אין כרגע באנרים אחרונים.",
@@ -2235,4 +2250,4 @@
"retry": "נסה שוב" "retry": "נסה שוב"
} }
} }
} }

View File

@@ -1752,6 +1752,12 @@
"checkingMessage": "最新バージョンを確認しています。お待ちください。", "checkingMessage": "最新バージョンを確認しています。お待ちください。",
"showNotifications": "更新通知を表示", "showNotifications": "更新通知を表示",
"latestBadge": "最新", "latestBadge": "最新",
"latestMain": "Main ブランチ",
"channel": "更新チャンネル",
"channels": {
"release": "リリース",
"nightly": "ナイトリー"
},
"updateProgress": { "updateProgress": {
"preparing": "更新を準備中...", "preparing": "更新を準備中...",
"installing": "更新をインストール中...", "installing": "更新をインストール中...",
@@ -1772,6 +1778,15 @@
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。", "warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
"enable": "ナイトリー更新を有効にする" "enable": "ナイトリー更新を有効にする"
}, },
"channelSwitch": {
"nightlyTitle": "ナイトリーチャンネルに切り替え",
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
"releaseTitle": "リリースチャンネルに切り替え",
"releaseMessage": "リリースに切り替えると、Gitリポジトリが削除され、最新の安定版がインストールされます。以降の更新は安定版のみが使用されます。",
"switching": "{channel} チャンネルに切り替え中...",
"completed": "{channel} チャンネルに切り替えました",
"failed": "チャンネルの切り替えに失敗しました"
},
"banners": { "banners": {
"recent": "最近の通知", "recent": "最近の通知",
"empty": "最近のバナーはありません。", "empty": "最近のバナーはありません。",
@@ -2235,4 +2250,4 @@
"retry": "再試行" "retry": "再試行"
} }
} }
} }

View File

@@ -1752,6 +1752,12 @@
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.", "checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
"showNotifications": "업데이트 알림 표시", "showNotifications": "업데이트 알림 표시",
"latestBadge": "최신", "latestBadge": "최신",
"latestMain": "Main 브랜치",
"channel": "업데이트 채널",
"channels": {
"release": "릴리스",
"nightly": "나이틀리"
},
"updateProgress": { "updateProgress": {
"preparing": "업데이트 준비 중...", "preparing": "업데이트 준비 중...",
"installing": "업데이트 설치 중...", "installing": "업데이트 설치 중...",
@@ -1772,6 +1778,15 @@
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.", "warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
"enable": "나이틀리 업데이트 활성화" "enable": "나이틀리 업데이트 활성화"
}, },
"channelSwitch": {
"nightlyTitle": "나이틀리 채널로 전환",
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
"releaseTitle": "릴리스 채널로 전환",
"releaseMessage": "릴리스로 전환하면 Git 저장소가 제거되고 최신 안정 버전이 설치됩니다. 이후 업데이트는 안정 버전만 사용됩니다.",
"switching": "{channel} 채널로 전환 중...",
"completed": "{channel} 채널로 전환 완료",
"failed": "채널 전환 실패"
},
"banners": { "banners": {
"recent": "최근 알림", "recent": "최근 알림",
"empty": "최근 배너가 없습니다.", "empty": "최근 배너가 없습니다.",
@@ -2235,4 +2250,4 @@
"retry": "다시 시도" "retry": "다시 시도"
} }
} }
} }

View File

@@ -1751,7 +1751,13 @@
"checkingUpdates": "Проверка обновлений...", "checkingUpdates": "Проверка обновлений...",
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.", "checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
"showNotifications": "Показывать уведомления об обновлениях", "showNotifications": "Показывать уведомления об обновлениях",
"latestBadge": "Последний", "latestBadge": "Последняя",
"latestMain": "Ветка main",
"channel": "Канал обновлений",
"channels": {
"release": "Релиз",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Подготовка обновления...", "preparing": "Подготовка обновления...",
"installing": "Установка обновления...", "installing": "Установка обновления...",
@@ -1772,6 +1778,15 @@
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.", "warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
"enable": "Включить ночные обновления" "enable": "Включить ночные обновления"
}, },
"channelSwitch": {
"nightlyTitle": "Переключиться на Nightly",
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
"releaseTitle": "Переключиться на Release",
"releaseMessage": "Переключение на Release удалит Git-репозиторий и установит последнюю стабильную версию. Будущие обновления будут использовать только стабильные версии.",
"switching": "Переключение на канал {channel}...",
"completed": "Успешно переключено на канал {channel}",
"failed": "Не удалось переключить канал"
},
"banners": { "banners": {
"recent": "Недавние уведомления", "recent": "Недавние уведомления",
"empty": "Недавних баннеров нет.", "empty": "Недавних баннеров нет.",
@@ -2235,4 +2250,4 @@
"retry": "Повторить" "retry": "Повторить"
} }
} }
} }

View File

@@ -1752,6 +1752,12 @@
"checkingMessage": "请稍候,正在检查最新版本。", "checkingMessage": "请稍候,正在检查最新版本。",
"showNotifications": "显示更新通知", "showNotifications": "显示更新通知",
"latestBadge": "最新", "latestBadge": "最新",
"latestMain": "Main 分支",
"channel": "更新频道",
"channels": {
"release": "稳定版",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "正在准备更新...", "preparing": "正在准备更新...",
"installing": "正在安装更新...", "installing": "正在安装更新...",
@@ -1772,6 +1778,15 @@
"warning": "警告Nightly 版本可能包含实验性功能,可能不稳定。", "warning": "警告Nightly 版本可能包含实验性功能,可能不稳定。",
"enable": "启用 Nightly 更新" "enable": "启用 Nightly 更新"
}, },
"channelSwitch": {
"nightlyTitle": "切换到 Nightly",
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
"releaseTitle": "切换到稳定版",
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
"switching": "正在切换到 {channel} 频道...",
"completed": "已切换到 {channel} 频道",
"failed": "切换频道失败"
},
"banners": { "banners": {
"recent": "最近的通知", "recent": "最近的通知",
"empty": "暂无最近的横幅通知。", "empty": "暂无最近的横幅通知。",
@@ -2235,4 +2250,4 @@
"retry": "重试" "retry": "重试"
} }
} }
} }

View File

@@ -1752,6 +1752,12 @@
"checkingMessage": "請稍候,正在檢查最新版本。", "checkingMessage": "請稍候,正在檢查最新版本。",
"showNotifications": "顯示更新通知", "showNotifications": "顯示更新通知",
"latestBadge": "最新", "latestBadge": "最新",
"latestMain": "Main 分支",
"channel": "更新頻道",
"channels": {
"release": "稳定版",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "正在準備更新...", "preparing": "正在準備更新...",
"installing": "正在安裝更新...", "installing": "正在安裝更新...",
@@ -1772,6 +1778,15 @@
"warning": "警告Nightly 版本可能包含實驗性功能且可能不穩定。", "warning": "警告Nightly 版本可能包含實驗性功能且可能不穩定。",
"enable": "啟用 Nightly 更新" "enable": "啟用 Nightly 更新"
}, },
"channelSwitch": {
"nightlyTitle": "切换到 Nightly",
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
"releaseTitle": "切换到稳定版",
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
"switching": "正在切換到 {channel} 頻道...",
"completed": "已切換到 {channel} 頻道",
"failed": "切換頻道失敗"
},
"banners": { "banners": {
"recent": "最新通知", "recent": "最新通知",
"empty": "目前沒有最近的橫幅通知。", "empty": "目前沒有最近的橫幅通知。",
@@ -2235,4 +2250,4 @@
"retry": "重試" "retry": "重試"
} }
} }
} }

View File

@@ -47,6 +47,7 @@ class UpdateRoutes:
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates) app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info) app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update) app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
app.router.add_post('/api/lm/switch-channel', UpdateRoutes.switch_channel)
@staticmethod @staticmethod
async def check_updates(request): async def check_updates(request):
@@ -65,10 +66,17 @@ class UpdateRoutes:
# Fetch remote version from GitHub # Fetch remote version from GitHub
if nightly: if nightly:
remote_version, changelog = await UpdateRoutes._get_nightly_version() local_hash = git_info.get('short_hash', '')
releases = None nightly_version, releases_result = await asyncio.gather(
UpdateRoutes._get_nightly_version(local_hash),
UpdateRoutes._get_remote_version()
)
remote_version, _, behind_by, commit_date = nightly_version
_, changelog, releases = releases_result
else: else:
remote_version, changelog, releases = await UpdateRoutes._get_remote_version() remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
behind_by = 0
commit_date = ''
# Compare versions # Compare versions
if nightly: if nightly:
@@ -81,6 +89,10 @@ class UpdateRoutes:
remote_version.replace('v', '') remote_version.replace('v', '')
) )
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
response_data = { response_data = {
'success': True, 'success': True,
'current_version': local_version, 'current_version': local_version,
@@ -88,13 +100,13 @@ class UpdateRoutes:
'update_available': update_available, 'update_available': update_available,
'changelog': changelog, 'changelog': changelog,
'git_info': git_info, 'git_info': git_info,
'nightly': nightly 'nightly': nightly,
'has_git': has_git,
'releases': releases,
'behind_by': behind_by,
'commit_date': commit_date
} }
# Include releases list for stable mode
if releases is not None:
response_data['releases'] = releases
return web.json_response(response_data) return web.json_response(response_data)
except NETWORK_EXCEPTIONS as e: except NETWORK_EXCEPTIONS as e:
@@ -126,9 +138,14 @@ class UpdateRoutes:
# Format: version-short_hash # Format: version-short_hash
version_string = f"{local_version}-{short_hash}" version_string = f"{local_version}-{short_hash}"
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'version': version_string 'version': version_string,
'has_git': has_git
}) })
except Exception as e: except Exception as e:
@@ -190,6 +207,162 @@ class UpdateRoutes:
'error': str(e) 'error': str(e)
}) })
@staticmethod
async def switch_channel(request):
"""
Switch between release and nightly update channels.
Release → Nightly: Initialize a Git repository (from ZIP/CM stable mode)
Nightly → Release: Remove .git, download latest release ZIP, write .tracking
"""
try:
body = await request.json() if request.has_body else {}
channel = body.get('channel', '')
if channel not in ('release', 'nightly'):
return web.json_response({
'success': False,
'error': f'Invalid channel: {channel}. Must be "release" or "nightly".'
})
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
settings_path = ensure_settings_file(logger)
settings_backup = None
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
settings_backup = f.read()
logger.info("Backed up settings.json before channel switch")
git_folder = os.path.join(plugin_root, '.git')
if channel == 'nightly':
git_backup = None
if os.path.exists(git_folder):
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
success = False
new_version = ''
try:
if os.path.exists(git_folder):
success, new_version = await UpdateRoutes._perform_git_update(
plugin_root, nightly=True
)
else:
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
finally:
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
else:
git_backup = None
if os.path.exists(git_folder):
git_backup = UpdateRoutes._backup_git(git_folder, 'release')
success = False
new_version = ''
try:
if os.path.exists(git_folder):
shutil.rmtree(git_folder)
tracking_file = os.path.join(plugin_root, '.tracking')
if os.path.exists(tracking_file):
os.remove(tracking_file)
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
finally:
UpdateRoutes._restore_git(git_backup, git_folder, success, 'release')
if settings_backup and success:
with open(settings_path, 'w', encoding='utf-8') as f:
f.write(settings_backup)
logger.info("Restored settings.json after channel switch")
if success:
return web.json_response({
'success': True,
'channel': channel,
'new_version': new_version,
'message': f'Switched to {channel} channel'
})
else:
return web.json_response({
'success': False,
'error': f'Failed to switch to {channel} channel'
})
except Exception as e:
logger.error("Failed to switch channel: %s", e, exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
})
@staticmethod
def _init_git_repo(plugin_root: str) -> tuple[bool, str]:
"""
Initialize a Git repository in a ZIP-installed plugin folder.
Clones the remote history and checks out main branch.
"""
try:
import git
except ImportError:
logger.error(
"GitPython is not available: cannot initialize git repo. "
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
)
return False, ""
clean_excludes = _clean_excludes()
try:
repo = git.Repo.init(plugin_root)
origin = repo.create_remote(
'origin',
'https://github.com/willmiao/ComfyUI-Lora-Manager.git'
)
origin.fetch()
repo.create_head('main', origin.refs.main)
repo.git.checkout('main', '--force')
repo.git.reset('--hard')
repo.git.clean('-fd', *clean_excludes)
tracking_file = os.path.join(plugin_root, '.tracking')
if os.path.exists(tracking_file):
os.remove(tracking_file)
logger.info("Removed .tracking file (now in git mode)")
new_version = f"main-{repo.head.commit.hexsha[:7]}"
logger.info("Initialized git repo on main branch: %s", new_version)
return True, new_version
except Exception as e:
logger.error("Failed to initialize git repo: %s", e, exc_info=True)
return False, ""
@staticmethod
def _backup_git(git_folder, label):
try:
backup_dir = tempfile.mkdtemp()
backup = os.path.join(backup_dir, '.git')
shutil.copytree(git_folder, backup)
logger.info("Backed up .git before switching to %s", label)
return backup
except Exception as e:
logger.error("Failed to backup .git before %s switch: %s", label, e)
return None
@staticmethod
def _restore_git(git_backup, git_folder, success, label):
if git_backup and not success:
try:
if os.path.exists(git_folder):
shutil.rmtree(git_folder)
shutil.copytree(git_backup, git_folder)
logger.info("Restored .git after failed %s switch", label)
except Exception as e:
logger.error("Failed to restore .git after %s switch: %s", label, e)
if git_backup:
shutil.rmtree(os.path.dirname(git_backup), ignore_errors=True)
@staticmethod @staticmethod
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]: async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
""" """
@@ -295,7 +468,8 @@ class UpdateRoutes:
except Exception as e: except Exception as e:
logger.error(f"ZIP update failed: {e}", exc_info=True) logger.error(f"ZIP update failed: {e}", exc_info=True)
return False, "" return False, ""
@staticmethod
def _clean_plugin_folder(plugin_root, skip_files=None): def _clean_plugin_folder(plugin_root, skip_files=None):
skip_files = skip_files or [] skip_files = skip_files or []
for item in os.listdir(plugin_root): for item in os.listdir(plugin_root):
@@ -308,41 +482,51 @@ class UpdateRoutes:
os.remove(path) os.remove(path)
@staticmethod @staticmethod
async def _get_nightly_version() -> tuple[str, List[str]]: async def _get_nightly_version(local_hash: str = "") -> tuple[str, List[str], int, str]:
"""
Fetch latest commit from main branch
"""
repo_owner = "willmiao" repo_owner = "willmiao"
repo_name = "ComfyUI-Lora-Manager" repo_name = "ComfyUI-Lora-Manager"
# Use GitHub API to fetch the latest commit from main branch
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main" github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
try: try:
downloader = await get_downloader() downloader = await get_downloader()
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'}) success, data = await downloader.make_request(
'GET', github_url,
custom_headers={'Accept': 'application/vnd.github+json'}
)
if not success: if not success:
logger.warning(f"Failed to fetch GitHub commit: {data}") logger.warning("Failed to fetch GitHub commit: %s", data)
return "main", [] return "main", [], 0, ""
commit_sha = data.get('sha', '')[:7] # Short hash commit_sha = data.get('sha', '')[:7]
commit_message = data.get('commit', {}).get('message', '') commit_message = data.get('commit', {}).get('message', '')
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10]
# Format as "main-{short_hash}"
version = f"main-{commit_sha}" version = f"main-{commit_sha}"
# Use commit message as changelog
changelog = [commit_message] if commit_message else [] changelog = [commit_message] if commit_message else []
return version, changelog behind_by = 0
if local_hash and local_hash not in ('unknown', 'stable'):
compare_url = (
f"https://api.github.com/repos/{repo_owner}/{repo_name}"
f"/compare/{local_hash}...main"
)
c_ok, c_data = await downloader.make_request(
'GET', compare_url,
custom_headers={'Accept': 'application/vnd.github+json'}
)
if c_ok:
behind_by = c_data.get('behind_by', 0)
return version, changelog, behind_by, commit_date
except NETWORK_EXCEPTIONS as e: except NETWORK_EXCEPTIONS as e:
logger.warning("Unable to reach GitHub for nightly version: %s", e) logger.warning("Unable to reach GitHub for nightly version: %s", e)
return "main", [] return "main", [], 0, ""
except Exception as e: except Exception as e:
logger.error(f"Error fetching nightly version: {e}", exc_info=True) logger.error("Error fetching nightly version: %s", e, exc_info=True)
return "main", [] return "main", [], 0, ""
@staticmethod @staticmethod
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool: def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:

View File

@@ -151,6 +151,7 @@ body.modal-open {
.support-section, .support-section,
.changelog-section, .changelog-section,
.update-info, .update-info,
.update-channels,
.info-item, .info-item,
.path-preview { .path-preview {
background: var(--surface-subtle); background: var(--surface-subtle);

View File

@@ -93,15 +93,13 @@
.update-content { .update-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-2);
} }
.update-info { .update-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-radius: var(--border-radius-sm);
padding: var(--space-3);
} }
.update-info .version-info { .update-info .version-info {
@@ -175,7 +173,6 @@
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-2); padding: var(--space-2);
margin: var(--space-2) 0;
} }
[data-theme="dark"] .update-progress { [data-theme="dark"] .update-progress {
@@ -233,11 +230,6 @@
} }
/* Changelog section */ /* Changelog section */
.changelog-section {
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.changelog-section h3 { .changelog-section h3 {
margin-top: 0; margin-top: 0;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -349,6 +341,131 @@
text-decoration: underline; text-decoration: underline;
} }
/* Channel Toggle */
.update-channels {
}
.channels-label {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 8px;
}
.channel-toggle {
display: flex;
gap: 0;
background: var(--lora-surface);
border-radius: 8px;
padding: 3px;
width: fit-content;
}
.channel-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
}
.channel-btn:hover {
color: var(--text-primary, #ddd);
background: rgba(255, 255, 255, 0.04);
}
.channel-btn.active {
background: var(--lora-accent, #4285F4);
color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.channel-btn.active i {
color: #fff;
}
.channel-btn i {
font-size: 0.85em;
}
/* Channel Switch Confirmation Overlay */
.channel-switch-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(2px);
}
.channel-switch-dialog {
background: var(--lora-surface);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 28px 32px;
max-width: 420px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.channel-switch-dialog h3 {
margin: 0 0 12px;
font-size: 1.1em;
color: var(--text-primary, #eee);
}
.channel-switch-dialog p {
margin: 0 0 24px;
font-size: 0.9em;
color: var(--text-secondary, #aaa);
line-height: 1.6;
}
.channel-switch-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.channel-switch-cancel {
padding: 8px 18px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #aaa);
cursor: pointer;
font-size: 0.9em;
}
.channel-switch-cancel:hover {
background: rgba(255, 255, 255, 0.04);
}
.channel-switch-confirm {
padding: 8px 18px;
border: none;
border-radius: 6px;
background: var(--lora-accent, #4285F4);
color: #fff;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
}
.channel-switch-confirm:hover {
opacity: 0.9;
}
/* Update preferences section */ /* Update preferences section */
.update-preferences { .update-preferences {
border-top: 1px solid var(--lora-border); border-top: 1px solid var(--lora-border);

View File

@@ -24,7 +24,9 @@ export class UpdateService {
this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true); this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true);
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0'); this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
this.isUpdating = false; this.isUpdating = false;
this.nightlyMode = getStorageItem('nightly_updates', false); this.channelMode = null;
this.hasGit = false;
this.progressKeepVisible = false;
this.currentVersionInfo = null; this.currentVersionInfo = null;
this.versionMismatch = false; this.versionMismatch = false;
this.activeNotificationTab = 'updates'; this.activeNotificationTab = 'updates';
@@ -49,43 +51,161 @@ export class UpdateService {
updateBtn.addEventListener('click', () => this.performUpdate()); updateBtn.addEventListener('click', () => this.performUpdate());
} }
// Register event listener for nightly update toggle this.wireChannelButtons();
const nightlyCheckbox = document.getElementById('nightlyUpdateToggle');
if (nightlyCheckbox) {
nightlyCheckbox.checked = this.nightlyMode;
nightlyCheckbox.addEventListener('change', (e) => {
this.nightlyMode = e.target.checked;
setStorageItem('nightly_updates', e.target.checked);
this.updateNightlyWarning();
this.updateModalContent();
// Re-check for updates when switching channels
this.manualCheckForUpdates();
});
this.updateNightlyWarning();
}
this.setupNotificationCenter(); this.setupNotificationCenter();
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated); window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
this.updateTabBadges(); this.updateTabBadges();
// Perform update check if needed // Perform update check if needed
this.checkForUpdates().then(() => { this.checkVersionInfo().then(() => {
// Ensure badges are updated after checking if (this.channelMode === null) {
this.updateBadgeVisibility(); this.channelMode = this.hasGit ? 'nightly' : 'release';
}
this.checkForUpdates().then(() => {
this.updateBadgeVisibility();
});
}); });
// Immediately update modal content with current values (even if from default)
this.updateModalContent(); this.updateModalContent();
// Check version info for mismatch after loading basic info
this.checkVersionInfo();
} }
updateNightlyWarning() { wireChannelButtons() {
const warning = document.getElementById('nightlyWarning'); const releaseBtn = document.getElementById('channelRelease');
if (warning) { const nightlyBtn = document.getElementById('channelNightly');
warning.style.display = this.nightlyMode ? 'flex' : 'none'; if (releaseBtn) {
releaseBtn.addEventListener('click', () => this.switchChannel('release'));
} }
if (nightlyBtn) {
nightlyBtn.addEventListener('click', () => this.switchChannel('nightly'));
}
}
async switchChannel(channel) {
if (channel === this.channelMode) {
return;
}
if (this.isUpdating) {
return;
}
if (!this.hasGit && channel === 'nightly') {
const confirmed = await this._confirmChannelSwitch(
'update.channelSwitch.nightlyTitle',
'update.channelSwitch.nightlyMessage'
);
if (!confirmed) return;
}
if (this.hasGit && channel === 'release') {
const confirmed = await this._confirmChannelSwitch(
'update.channelSwitch.releaseTitle',
'update.channelSwitch.releaseMessage'
);
if (!confirmed) return;
}
try {
this.isUpdating = true;
this.showUpdateProgress(true);
this.updateProgress(10, translate('update.channelSwitch.switching', { channel }));
const response = await fetch('/api/lm/switch-channel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel })
});
const data = await response.json();
if (data.success) {
this.channelMode = channel;
await this.checkForUpdates({ force: true });
this.updateModalContent();
this.updateChannelUI();
this._showSwitchCompleteMessage(data.new_version);
this.progressKeepVisible = true;
} else {
throw new Error(data.error || translate('update.channelSwitch.failed'));
}
} catch (error) {
console.error('Channel switch failed:', error);
this.updateProgress(0, translate('update.channelSwitch.failed'));
} finally {
if (this.progressKeepVisible) {
this.isUpdating = false;
this.progressKeepVisible = false;
} else {
setTimeout(() => {
this.showUpdateProgress(false);
this.isUpdating = false;
}, 2000);
}
}
}
updateChannelUI() {
const releaseBtn = document.getElementById('channelRelease');
const nightlyBtn = document.getElementById('channelNightly');
if (releaseBtn) {
releaseBtn.classList.toggle('active', this.channelMode === 'release');
}
if (nightlyBtn) {
nightlyBtn.classList.toggle('active', this.channelMode === 'nightly');
}
}
async _confirmChannelSwitch(titleKey, messageKey) {
return new Promise((resolve) => {
const title = translate(titleKey);
const message = translate(messageKey);
const cancelText = translate('common.cancel');
const confirmText = translate('common.confirm');
const overlay = document.createElement('div');
overlay.className = 'channel-switch-overlay';
overlay.innerHTML = `
<div class="channel-switch-dialog">
<h3>${title}</h3>
<p>${message}</p>
<div class="channel-switch-actions">
<button class="secondary-btn channel-switch-cancel">${cancelText}</button>
<button class="primary-btn channel-switch-confirm">${confirmText}</button>
</div>
</div>
`;
const dismiss = (result) => {
document.removeEventListener('keydown', onKeydown);
overlay.remove();
resolve(result);
};
const onKeydown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
dismiss(false);
}
};
document.addEventListener('keydown', onKeydown, { capture: true });
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
dismiss(false);
}
});
overlay.querySelector('.channel-switch-cancel').addEventListener('click', () => {
dismiss(false);
});
overlay.querySelector('.channel-switch-confirm').addEventListener('click', () => {
dismiss(true);
});
document.body.appendChild(overlay);
});
} }
setupNotificationCenter() { setupNotificationCenter() {
@@ -373,7 +493,8 @@ export class UpdateService {
try { try {
// Call backend API to check for updates with nightly flag // Call backend API to check for updates with nightly flag
const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`); const nightly = this.channelMode === 'nightly';
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
@@ -381,17 +502,19 @@ export class UpdateService {
this.latestVersion = data.latest_version || "v0.0.0"; this.latestVersion = data.latest_version || "v0.0.0";
this.updateInfo = data; this.updateInfo = data;
this.gitInfo = data.git_info || this.gitInfo; this.gitInfo = data.git_info || this.gitInfo;
this.hasGit = data.has_git || false;
// Explicitly set update availability based on version comparison if (this.channelMode === null) {
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion); this.channelMode = this.hasGit ? 'nightly' : 'release';
}
// Update last check time
this.updateAvailable = data.update_available;
this.lastCheckTime = now; this.lastCheckTime = now;
setStorageItem('last_update_check', now.toString()); setStorageItem('last_update_check', now.toString());
// Update UI
this.updateBadgeVisibility(); this.updateBadgeVisibility();
this.updateModalContent(); this.updateModalContent();
this.updateChannelUI();
console.log("Update check complete:", { console.log("Update check complete:", {
currentVersion: this.currentVersion, currentVersion: this.currentVersion,
@@ -482,8 +605,27 @@ export class UpdateService {
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion; if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
const newVersionLabel = modal.querySelector('.new-version .label');
if (newVersionLabel) {
newVersionLabel.textContent = (this.updateInfo?.nightly)
? `${translate('update.latestMain')}:`
: `${translate('update.newVersion')}:`;
}
if (newVersionEl) { if (newVersionEl) {
newVersionEl.textContent = this.latestVersion; if (this.updateInfo?.nightly) {
const behind = this.updateInfo.behind_by || 0;
const hash = this.latestVersion.replace('main-', '');
const date = this.updateInfo.commit_date || '';
const datePart = date ? ` · ${date}` : '';
if (behind > 0) {
newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${hash}${datePart})`;
} else {
newVersionEl.textContent = `Up to date (${hash}${datePart})`;
}
} else {
newVersionEl.textContent = this.latestVersion;
}
} }
// Update update button state // Update update button state
@@ -599,8 +741,12 @@ export class UpdateService {
// Update GitHub link to point to the specific release if available // Update GitHub link to point to the specific release if available
const githubLink = modal.querySelector('.update-link'); const githubLink = modal.querySelector('.update-link');
if (githubLink && this.latestVersion) { if (githubLink && this.latestVersion) {
const versionTag = this.latestVersion.replace(/^v/, ''); if (this.updateInfo?.nightly) {
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`; githubLink.href = 'https://github.com/willmiao/ComfyUI-Lora-Manager/commits/main';
} else {
const versionTag = this.latestVersion.replace(/^v/, '');
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
}
} }
} }
@@ -623,7 +769,7 @@ export class UpdateService {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
nightly: this.nightlyMode nightly: this.channelMode === 'nightly'
}) })
}); });
@@ -698,7 +844,26 @@ export class UpdateService {
progressText.textContent = text; progressText.textContent = text;
} }
} }
_showSwitchCompleteMessage(version) {
this.showUpdateProgress(true);
this.updateProgress(100, '');
const progressText = document.getElementById('updateProgressText');
if (progressText) {
progressText.innerHTML = `
<div style="text-align: center; color: var(--lora-success);">
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
${translate('update.completion.successMessage', { version })}
<br><br>
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
${translate('update.completion.restartMessage')}<br>
${translate('update.completion.reloadMessage')}
</div>
</div>
`;
}
}
showUpdateCompleteMessage(newVersion) { showUpdateCompleteMessage(newVersion) {
const modal = document.getElementById('updateModal'); const modal = document.getElementById('updateModal');
if (!modal) return; if (!modal) return;
@@ -771,6 +936,7 @@ export class UpdateService {
// Update the modal content immediately with current data // Update the modal content immediately with current data
this.updateModalContent(); this.updateModalContent();
this.updateChannelUI();
this.renderRecentBanners(); this.renderRecentBanners();
// Show the modal with current data // Show the modal with current data
@@ -801,8 +967,8 @@ export class UpdateService {
if (data.success) { if (data.success) {
this.currentVersionInfo = data.version; this.currentVersionInfo = data.version;
this.hasGit = data.has_git || false;
// Check if version matches stored version
this.versionMismatch = !isVersionMatch(this.currentVersionInfo); this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
if (this.versionMismatch) { if (this.versionMismatch) {

View File

@@ -19,6 +19,20 @@
<div class="notification-panels"> <div class="notification-panels">
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates"> <div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
<div class="update-content"> <div class="update-content">
<!-- Channel Selector -->
<div class="update-channels" id="updateChannels">
<div class="channels-label">{{ t('update.channel') }}</div>
<div class="channel-toggle">
<button type="button" class="channel-btn" data-channel="release" id="channelRelease">
<i class="fas fa-tag"></i> {{ t('update.channels.release') }}
</button>
<button type="button" class="channel-btn" data-channel="nightly" id="channelNightly">
<i class="fas fa-moon"></i> {{ t('update.channels.nightly') }}
</button>
</div>
</div>
<div class="update-info"> <div class="update-info">
<div class="version-info"> <div class="version-info">
<div class="current-version"> <div class="current-version">

View File

@@ -1,10 +1,33 @@
import logging import logging
import os
import shutil
from aiohttp import ClientError from aiohttp import ClientError
from aiohttp import web
import pytest import pytest
from py.routes import update_routes from py.routes import update_routes
def _fake_request(body=None, query_params=None):
from multidict import MultiDict
q = MultiDict(query_params or {})
req = type("Req", (), {
"has_body": body is not None,
"match_info": {},
"rel_url": type("U", (), {"query": q})(),
"query": q,
"app": {},
})()
async def _json():
return body or {}
req.json = _json
return req
class OfflineDownloader: class OfflineDownloader:
async def make_request(self, *_, **__): async def make_request(self, *_, **__):
return False, "Cannot connect to host" return False, "Cannot connect to host"
@@ -53,10 +76,12 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo
caplog.set_level(logging.WARNING) caplog.set_level(logging.WARNING)
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader())) monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader()))
version, changelog = await update_routes.UpdateRoutes._get_nightly_version() version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version()
assert version == "main" assert version == "main"
assert changelog == [] assert changelog == []
assert behind_by == 0
assert commit_date == ""
assert "Unable to reach GitHub for nightly version" in caplog.text assert "Unable to reach GitHub for nightly version" in caplog.text
assert "Traceback" not in caplog.text assert "Traceback" not in caplog.text
@@ -236,3 +261,240 @@ async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_pa
clean_args = clean_calls[0][1] clean_args = clean_calls[0][1]
for name in update_routes._PRESERVE_DIRS: for name in update_routes._PRESERVE_DIRS:
assert name in clean_args, f"{name} missing from git clean excludes (stable)" assert name in clean_args, f"{name} missing from git clean excludes (stable)"
def test_init_git_repo_creates_valid_repo(tmp_path, monkeypatch):
if not shutil.which("git"):
pytest.skip("git executable not found")
plugin_root = tmp_path / "plugin"
plugin_root.mkdir()
(plugin_root / ".tracking").write_text("pyproject.toml")
(plugin_root / "settings.json").write_text('{"some": "value"}')
try:
success, version = update_routes.UpdateRoutes._init_git_repo(str(plugin_root))
except Exception as e:
pytest.skip(f"Network unavailable for git fetch: {e}")
assert success is True
assert version.startswith("main-")
assert len(version) > len("main-")
assert (plugin_root / ".git").is_dir()
assert not (plugin_root / ".tracking").exists()
assert (plugin_root / "settings.json").exists()
assert (plugin_root / "pyproject.toml").exists()
@pytest.mark.asyncio
async def test_switch_channel_invalid_channel_returns_error():
req = _fake_request({"channel": "bad_channel"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert not data["success"]
assert "Invalid channel" in data["error"]
@pytest.mark.asyncio
async def test_switch_channel_to_nightly_without_git_inits_repo(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
monkeypatch.setattr(
update_routes.UpdateRoutes,
"_init_git_repo",
staticmethod(lambda plugin_root: (True, "main-fedcba9")),
)
req = _fake_request({"channel": "nightly"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "nightly"
assert data["new_version"] == "main-fedcba9"
@pytest.mark.asyncio
async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
(tmp_path / ".git").mkdir()
async def _fake_git_update(*args, **kwargs):
return True, "main-1111111"
monkeypatch.setattr(
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
)
req = _fake_request({"channel": "nightly"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "nightly"
assert data["new_version"] == "main-1111111"
@pytest.mark.asyncio
async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
(tmp_path / ".git").mkdir()
async def _fake_zip(*args, **kwargs):
return True, "v9.9.9"
monkeypatch.setattr(
update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip
)
req = _fake_request({"channel": "release"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "release"
assert data["new_version"] == "v9.9.9"
@pytest.mark.asyncio
async def test_switch_channel_to_release_without_git_still_downloads_zip(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
async def _fake_zip(*args, **kwargs):
return True, "v2.0.0"
monkeypatch.setattr(
update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip
)
req = _fake_request({"channel": "release"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "release"
assert data["new_version"] == "v2.0.0"
class _NightlyDownloader:
"""Returns a fake main-branch commit AND a compare response."""
commit_sha = "7777777"
commit_msg = "test: add nightly feature"
commit_date = "2026-07-27T12:00:00Z"
behind_by = 5
async def make_request(self, method, url, **kwargs):
if "/compare/" in url:
return True, {"behind_by": self.behind_by}
return True, {
"sha": self.commit_sha,
"commit": {
"message": self.commit_msg,
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_get_nightly_version_parses_behind_by(monkeypatch):
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_NightlyDownloader()))
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
local_hash="abc1234"
)
assert version == "main-7777777"
assert behind_by == 5
assert commit_date == "2026-07-27"
assert len(changelog) == 1
assert changelog[0] == "test: add nightly feature"
class _CheckUpdatesDownloader:
"""Fake downloader returning both a release list and a nightly commit + compare."""
commit_sha = "8888888"
commit_date = "2026-07-28T00:00:00Z"
async def make_request(self, method, url, **kwargs):
if "/releases" in url:
return True, [
{
"tag_name": "v3.0.0",
"body": "- Feature A\n- Feature B",
"published_at": "2026-07-20T00:00:00Z",
}
]
if "/compare/" in url:
return True, {"behind_by": 3}
return True, {
"sha": self.commit_sha + "0" * 33,
"commit": {
"message": "latest commit",
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_check_updates_nightly_response_includes_behind_and_date(monkeypatch, tmp_path):
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_CheckUpdatesDownloader()))
monkeypatch.setattr(
update_routes.UpdateRoutes,
"_get_local_version",
staticmethod(lambda: "v1.0.0"),
)
monkeypatch.setattr(
update_routes.UpdateRoutes,
"_get_git_info",
staticmethod(lambda: {
"commit_hash": "abc1234",
"short_hash": "abc1234",
"branch": "main",
"commit_date": "2026-01-01",
}),
)
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
(tmp_path / ".git").mkdir()
req = _fake_request(query_params={"nightly": "true"})
resp = await update_routes.UpdateRoutes.check_updates(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["nightly"] is True
assert data["has_git"] is True
assert data["behind_by"] == 3
assert data["commit_date"] == "2026-07-28"
assert data["latest_version"] == "main-8888888"
assert isinstance(data["releases"], list)
assert len(data["releases"]) == 1
assert data["releases"][0]["version"] == "v3.0.0"
def _raw_body(response):
import json
return json.loads(response._body.decode())