From 567965595505c2a760b98700dc3c7dfda3ab7c07 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 25 Aug 2026 17:39:54 +0000 Subject: [PATCH] feat: auto-updater (#10614) --- .github/workflows/ci.yml | 29 ++ .../components/settings/app/UpdateSection.tsx | 90 +--- ui/desktop/src/i18n/messages/de.json | 18 - ui/desktop/src/i18n/messages/en.json | 18 - ui/desktop/src/i18n/messages/es.json | 18 - ui/desktop/src/i18n/messages/fr.json | 18 - ui/desktop/src/i18n/messages/hi.json | 18 - ui/desktop/src/i18n/messages/id.json | 18 - ui/desktop/src/i18n/messages/it.json | 18 - ui/desktop/src/i18n/messages/ja.json | 18 - ui/desktop/src/i18n/messages/ko.json | 18 - ui/desktop/src/i18n/messages/ms.json | 18 - ui/desktop/src/i18n/messages/pt.json | 18 - ui/desktop/src/i18n/messages/ru.json | 18 - ui/desktop/src/i18n/messages/tr.json | 18 - ui/desktop/src/i18n/messages/vi.json | 18 - ui/desktop/src/i18n/messages/zh-CN.json | 18 - ui/desktop/src/i18n/messages/zh-TW.json | 18 - ui/desktop/src/utils/analytics.ts | 4 +- ui/desktop/src/utils/autoUpdater.ts | 77 +-- .../src/utils/githubUpdater.install.test.ts | 286 +++++++++++ .../src/utils/githubUpdater.target.test.ts | 119 +++++ ui/desktop/src/utils/githubUpdater.ts | 469 +++++++++++++++++- 23 files changed, 928 insertions(+), 434 deletions(-) create mode 100644 ui/desktop/src/utils/githubUpdater.install.test.ts create mode 100644 ui/desktop/src/utils/githubUpdater.target.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1b83ff0f..55a170fbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,3 +316,32 @@ jobs: - name: Run Tests run: source ../../bin/activate-hermit && pnpm run test:run working-directory: ui/desktop + + desktop-updater-install: + name: Test Desktop Updater Install (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: changes + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.10.0 + + - name: Install pnpm + run: npm install -g pnpm@10.30.3 + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + working-directory: ui/desktop + + - name: Run Updater Install Test + run: pnpm exec vitest run src/utils/githubUpdater.install.test.ts + working-directory: ui/desktop diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx index d69045e7c..daec80764 100644 --- a/ui/desktop/src/components/settings/app/UpdateSection.tsx +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -84,31 +84,6 @@ const i18n = defineMessages({ defaultMessage: 'Goose will download the update in the background and install it the next time you quit or restart.', }, - manualInstallNote: { - id: 'updateSection.manualInstallNote', - defaultMessage: "After download, you'll need to manually install the update.", - }, - autoInstallNote: { - id: 'updateSection.autoInstallNote', - defaultMessage: 'No manual install is needed.', - }, - readyInstallManual: { - id: 'updateSection.readyInstallManual', - defaultMessage: '✓ Update is ready! Click "Install & Restart" for installation instructions.', - }, - manualInstallRequired: { - id: 'updateSection.manualInstallRequired', - defaultMessage: 'Manual installation required for this update method.', - }, - readyInstallAuto: { - id: 'updateSection.readyInstallAuto', - defaultMessage: - "✓ Update is ready. Restart Goose to finish installing it, or quit when you're done.", - }, - installNowHint: { - id: 'updateSection.installNowHint', - defaultMessage: 'Click "Install & Restart" to update now.', - }, }); type UpdateStatus = @@ -139,18 +114,15 @@ export default function UpdateSection() { currentVersion: '', }); const [progress, setProgress] = useState(0); - const [isUsingGitHubFallback, setIsUsingGitHubFallback] = useState(false); const [disableAutoDownload, setDisableAutoDownload] = useState(false); const [autoDownloadForcedByEnv, setAutoDownloadForcedByEnv] = useState(false); const progressTimeoutRef = React.useRef | null>(null); - const lastProgressRef = React.useRef(0); // Track last progress to prevent backward jumps + const lastProgressRef = React.useRef(0); useEffect(() => { - // Get current version on mount const currentVersion = window.electron.getVersion(); setUpdateInfo((prev) => ({ ...prev, currentVersion })); - // Check if there's already an update state from the auto-check window.electron.getUpdateState().then((state) => { if (state) { setUpdateInfo((prev) => ({ @@ -161,22 +133,15 @@ export default function UpdateSection() { } }); - // Check if using GitHub fallback - window.electron.isUsingGitHubFallback().then((isGitHub) => { - setIsUsingGitHubFallback(isGitHub); - }); - window.electron.getSetting('disableAutoDownload').then((stored) => { setDisableAutoDownload(!!stored); }); window.electron.getAutoDownloadDisabled().then((effective) => { window.electron.getSetting('disableAutoDownload').then((stored) => { - // If effective is true but user setting is false, env var is forcing it setAutoDownloadForcedByEnv(effective && !stored); }); }); - // Listen for updater events window.electron.onUpdaterEvent((event) => { switch (event.event) { case 'checking-for-update': @@ -190,10 +155,6 @@ export default function UpdateSection() { latestVersion: (event.data as UpdateEventData)?.version, isUpdateAvailable: true, })); - // Check if GitHub fallback is being used - window.electron.isUsingGitHubFallback().then((isGitHub) => { - setIsUsingGitHubFallback(isGitHub); - }); break; case 'update-not-available': @@ -207,23 +168,19 @@ export default function UpdateSection() { case 'download-progress': { setUpdateStatus('downloading'); - // Get the new progress value (ensure it's a valid number) const rawPercent = (event.data as UpdateEventData)?.percent; const newProgress = typeof rawPercent === 'number' ? Math.round(rawPercent) : 0; - // Only update if progress increased (prevents backward jumps from out-of-order events) if (newProgress > lastProgressRef.current) { lastProgressRef.current = newProgress; - // Cancel any pending update if (progressTimeoutRef.current) { clearTimeout(progressTimeoutRef.current); } - // Use a small delay to batch rapid updates progressTimeoutRef.current = setTimeout(() => { setProgress(newProgress); - }, 50); // 50ms delay for smoother batching + }, 50); } break; } @@ -254,7 +211,7 @@ export default function UpdateSection() { const checkForUpdates = async () => { setUpdateStatus('checking'); setProgress(0); - lastProgressRef.current = 0; // Reset progress tracking for new download + lastProgressRef.current = 0; try { const result = await window.electron.checkForUpdates(); @@ -263,12 +220,10 @@ export default function UpdateSection() { throw new Error(result.error); } - // If we successfully checked and no update is available, show success if (!result.error && updateInfo.isUpdateAvailable === false) { setUpdateStatus('success'); setTimeout(() => setUpdateStatus('idle'), 3000); } - // The actual status will be handled by the updater events } catch (error) { console.error('Error checking for updates:', error); setUpdateInfo((prev) => ({ @@ -418,7 +373,6 @@ export default function UpdateSection() { )} - {/* Update information */} {updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
{autoDownloadEffectivelyDisabled ? ( @@ -426,48 +380,12 @@ export default function UpdateSection() { {intl.formatMessage(i18n.autoDownloadDisabledNote)}

) : ( - <> -

{intl.formatMessage(i18n.autoDownload)}

- {isUsingGitHubFallback ? ( -

- {intl.formatMessage(i18n.manualInstallNote)} -

- ) : ( -

- {intl.formatMessage(i18n.autoInstallNote)} -

- )} - - )} -
- )} - - {updateStatus === 'ready' && ( -
- {isUsingGitHubFallback ? ( - <> -

- {intl.formatMessage(i18n.readyInstallManual)} -

-

- {intl.formatMessage(i18n.manualInstallRequired)} -

- - ) : ( - <> -

- {intl.formatMessage(i18n.readyInstallAuto)} -

-

- {intl.formatMessage(i18n.installNowHint)} -

- +

{intl.formatMessage(i18n.autoDownload)}

)}
)} - {/* Auto-download toggle */}
{autoDownloadForcedByEnv ? (

diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index e7226b5ce..98ad98270 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -4530,9 +4530,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Automatischer Download ist deaktiviert. Klicken Sie auf \"Jetzt herunterladen\", um manuell herunterzuladen." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Keine manuelle Installation erforderlich." - }, "updateSection.checkForUpdates": { "defaultMessage": "Nach Updates suchen" }, @@ -4563,27 +4560,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Installieren & Neustarten" }, - "updateSection.installNowHint": { - "defaultMessage": "Klicken Sie auf \"Installieren & Neustarten\", um jetzt zu aktualisieren." - }, "updateSection.latestVersion": { "defaultMessage": "Sie verwenden die neueste Version!" }, "updateSection.loading": { "defaultMessage": "Wird geladen..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Nach dem Download müssen Sie das Update manuell installieren." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Für diese Update-Methode ist eine manuelle Installation erforderlich." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Update ist bereit. Starten Sie Goose neu, um die Installation abzuschließen, oder beenden Sie es, wenn Sie fertig sind." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Update ist bereit! Klicken Sie auf \"Installieren & Neustarten\", um die Installationsanweisungen zu erhalten." - }, "updateSection.upToDate": { "defaultMessage": "(aktuell)" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index d15be0468..c65086c94 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -4577,9 +4577,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Automatic download is disabled. Click \"Download Now\" to download manually." }, - "updateSection.autoInstallNote": { - "defaultMessage": "No manual install is needed." - }, "updateSection.checkForUpdates": { "defaultMessage": "Check for Updates" }, @@ -4610,27 +4607,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Install & Restart" }, - "updateSection.installNowHint": { - "defaultMessage": "Click \"Install & Restart\" to update now." - }, "updateSection.latestVersion": { "defaultMessage": "You are running the latest version!" }, "updateSection.loading": { "defaultMessage": "Loading..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "After download, you'll need to manually install the update." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Manual installation required for this update method." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Update is ready. Restart Goose to finish installing it, or quit when you're done." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Update is ready! Click \"Install & Restart\" for installation instructions." - }, "updateSection.upToDate": { "defaultMessage": "(up to date)" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index dc97e7183..fbef6a1c8 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "La descarga automática está desactivada. Haz clic en «Descargar ahora» para descargar manualmente." }, - "updateSection.autoInstallNote": { - "defaultMessage": "La actualización se instalará automáticamente cuando cierres la app." - }, "updateSection.checkForUpdates": { "defaultMessage": "Buscar actualizaciones" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Instalar y reiniciar" }, - "updateSection.installNowHint": { - "defaultMessage": "O haz clic en \"Instalar y reiniciar\" para actualizar ahora." - }, "updateSection.latestVersion": { "defaultMessage": "¡Estás usando la última versión!" }, "updateSection.loading": { "defaultMessage": "Cargando..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Después de la descarga, tendrás que instalar la actualización manualmente." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Este método de actualización requiere instalación manual." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ ¡La actualización está lista! Se instalará cuando cierres Goose." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ ¡La actualización está lista! Haz clic en \"Instalar y reiniciar\" para ver las instrucciones de instalación." - }, "updateSection.upToDate": { "defaultMessage": "(actualizado)" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 0e6d14964..7a287a8d1 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Le téléchargement automatique est désactivé. Cliquez sur « Télécharger maintenant » pour télécharger manuellement." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Aucune installation manuelle n'est nécessaire." - }, "updateSection.checkForUpdates": { "defaultMessage": "Rechercher des mises à jour" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Installer et redémarrer" }, - "updateSection.installNowHint": { - "defaultMessage": "Cliquez sur « Installer et redémarrer » pour mettre à jour maintenant." - }, "updateSection.latestVersion": { "defaultMessage": "Vous utilisez la dernière version !" }, "updateSection.loading": { "defaultMessage": "Chargement..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Après le téléchargement, vous devrez installer la mise à jour manuellement." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Installation manuelle requise pour cette méthode de mise à jour." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ La mise à jour est prête. Redémarrez Goose pour terminer son installation, ou quittez lorsque vous avez terminé." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ La mise à jour est prête ! Cliquez sur « Installer et redémarrer » pour obtenir les instructions d'installation." - }, "updateSection.upToDate": { "defaultMessage": "(à jour)" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 49223dad0..f17d47bbc 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "स्वचालित डाउनलोड अक्षम है। मैन्युअल रूप से डाउनलोड करने के लिए \"अभी डाउनलोड करें\" पर क्लिक करें।" }, - "updateSection.autoInstallNote": { - "defaultMessage": "जब आप ऐप छोड़ देंगे तो अपडेट अपने आप इंस्टॉल हो जाएगा।" - }, "updateSection.checkForUpdates": { "defaultMessage": "अद्यतनों के लिए जाँच करें" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "स्थापित करें और पुनः प्रारंभ करें" }, - "updateSection.installNowHint": { - "defaultMessage": "या अभी अपडेट करने के लिए \"इंस्टॉल करें और पुनरारंभ करें\" पर क्लिक करें।" - }, "updateSection.latestVersion": { "defaultMessage": "आप नवीनतम संस्करण चला रहे हैं!" }, "updateSection.loading": { "defaultMessage": "लोड हो रहा है..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "डाउनलोड करने के बाद, आपको अपडेट को मैन्युअल रूप से इंस्टॉल करना होगा।" - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "इस अद्यतन विधि के लिए मैन्युअल स्थापना आवश्यक है." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ अपडेट तैयार है! जब आप Goose छोड़ देंगे तो यह इंस्टॉल हो जाएगा।" - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ अपडेट तैयार है! इंस्टॉलेशन निर्देशों के लिए \"इंस्टॉल करें और पुनरारंभ करें\" पर क्लिक करें।" - }, "updateSection.upToDate": { "defaultMessage": "(अप टू डेट)" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index f78e784ee..d519ba389 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Pengunduhan otomatis dinonaktifkan. Klik \"Download Now\" untuk mengunduh secara manual." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Tidak diperlukan instalasi manual." - }, "updateSection.checkForUpdates": { "defaultMessage": "Periksa Pembaruan" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Instal & Mulai Ulang" }, - "updateSection.installNowHint": { - "defaultMessage": "Klik \"Install & Restart\" untuk memperbarui sekarang." - }, "updateSection.latestVersion": { "defaultMessage": "Anda menjalankan versi terbaru!" }, "updateSection.loading": { "defaultMessage": "Memuat..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Setelah pengunduhan, Anda perlu menginstal pembaruan secara manual." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Instalasi manual diperlukan untuk metode pembaruan ini." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Pembaruan sudah siap. Mulai ulang Goose untuk menyelesaikan pemasangan, atau keluar saat Anda selesai." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Pembaruan sudah siap! Klik \"Install & Restart\" untuk petunjuk pemasangan." - }, "updateSection.upToDate": { "defaultMessage": "(terbaru)" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index b1f51d2cb..23234854c 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Il download automatico è disabilitato. Fai clic su \"Scarica ora\" per scaricare manualmente." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Non è necessaria alcuna installazione manuale." - }, "updateSection.checkForUpdates": { "defaultMessage": "Controlla aggiornamenti" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Installa e riavvia" }, - "updateSection.installNowHint": { - "defaultMessage": "Fai clic su \"Installa e riavvia\" per aggiornare ora." - }, "updateSection.latestVersion": { "defaultMessage": "Stai usando la versione più recente!" }, "updateSection.loading": { "defaultMessage": "Caricamento..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Dopo il download, dovrai installare manualmente l'aggiornamento." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Installazione manuale richiesta per questo metodo di aggiornamento." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ L'aggiornamento è pronto. Riavvia Goose per completarne l'installazione, oppure chiudilo quando hai finito." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ L'aggiornamento è pronto! Fai clic su \"Installa e riavvia\" per le istruzioni di installazione." - }, "updateSection.upToDate": { "defaultMessage": "(aggiornato)" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index c81af0811..0afd9114b 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "自動ダウンロードは無効です。手動でダウンロードするには「今すぐダウンロード」をクリックしてください。" }, - "updateSection.autoInstallNote": { - "defaultMessage": "アプリを終了すると、アップデートが自動的にインストールされます。" - }, "updateSection.checkForUpdates": { "defaultMessage": "アップデートを確認" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "インストールして再起動" }, - "updateSection.installNowHint": { - "defaultMessage": "今すぐアップデートするには「インストールして再起動」をクリックしてください。" - }, "updateSection.latestVersion": { "defaultMessage": "最新バージョンを使用しています!" }, "updateSection.loading": { "defaultMessage": "読み込み中..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "ダウンロード後、アップデートを手動でインストールする必要があります。" - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "このアップデート方法では、手動インストールが必要です。" - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ アップデートの準備ができました!Gooseを終了するとインストールされます。" - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ アップデートの準備ができました!インストール手順を表示するには「インストールして再起動」をクリックしてください。" - }, "updateSection.upToDate": { "defaultMessage": "(最新)" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 95823b529..256a10fca 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "자동 다운로드가 비활성화되어 있습니다. 수동으로 다운로드하려면 \"지금 다운로드\"를 클릭하세요." }, - "updateSection.autoInstallNote": { - "defaultMessage": "수동 설치가 필요하지 않습니다." - }, "updateSection.checkForUpdates": { "defaultMessage": "업데이트 확인" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "설치 및 다시 시작" }, - "updateSection.installNowHint": { - "defaultMessage": "지금 업데이트하려면 \"설치 및 다시 시작\"을 클릭하세요." - }, "updateSection.latestVersion": { "defaultMessage": "최신 버전을 사용 중입니다!" }, "updateSection.loading": { "defaultMessage": "로드 중..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "다운로드한 후에는 업데이트를 수동으로 설치해야 합니다." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "이 업데이트 방법에는 수동 설치가 필요합니다." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ 업데이트가 준비되었습니다. 설치를 완료하려면 goose를 다시 시작하거나, 작업이 끝나면 종료하세요." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ 업데이트가 준비되었습니다! 설치 지침을 보려면 \"설치 및 다시 시작\"을 클릭하세요." - }, "updateSection.upToDate": { "defaultMessage": "(최신 상태)" }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index b70c86795..f7ae26acf 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Muat turun automatik dilumpuhkan. Klik \"Muat Turun Sekarang\" untuk memuat turun secara manual." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Tiada pemasangan manual diperlukan." - }, "updateSection.checkForUpdates": { "defaultMessage": "Semak Kemas Kini" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Pasang & Mula Semula" }, - "updateSection.installNowHint": { - "defaultMessage": "Klik \"Pasang & Mula Semula\" untuk mengemas kini sekarang." - }, "updateSection.latestVersion": { "defaultMessage": "Anda sedang menjalankan versi terkini!" }, "updateSection.loading": { "defaultMessage": "Memuatkan..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Selepas muat turun, anda perlu memasang kemas kini secara manual." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Pemasangan manual diperlukan untuk kaedah kemas kini ini." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Kemas kini sudah sedia. Mula semula Goose untuk menyelesaikan pemasangannya, atau keluar apabila anda selesai." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Kemas kini sudah sedia! Klik \"Pasang & Mula Semula\" untuk arahan pemasangan." - }, "updateSection.upToDate": { "defaultMessage": "(terkini)" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index 6e93dc0ec..69d82b8ae 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "A transferência automática está desativada. Clique em \"Transferir Agora\" para transferir manualmente." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Não é necessária instalação manual." - }, "updateSection.checkForUpdates": { "defaultMessage": "Verificar Atualizações" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Instalar e Reiniciar" }, - "updateSection.installNowHint": { - "defaultMessage": "Clique em \"Instalar e Reiniciar\" para atualizar agora." - }, "updateSection.latestVersion": { "defaultMessage": "Está a executar a versão mais recente!" }, "updateSection.loading": { "defaultMessage": "A carregar..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Após a transferência, terá de instalar a atualização manualmente." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Instalação manual necessária para este método de atualização." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ A atualização está pronta. Reinicie o Goose para concluir a instalação, ou saia quando terminar." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ A atualização está pronta! Clique em \"Instalar e Reiniciar\" para ver as instruções de instalação." - }, "updateSection.upToDate": { "defaultMessage": "(atualizado)" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 505933e3d..411496d7c 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Автоматическая загрузка отключена. Нажмите «Скачать сейчас», чтобы загрузить вручную." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Обновление будет установлено автоматически при выходе из приложения." - }, "updateSection.checkForUpdates": { "defaultMessage": "Проверить обновления" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Установить и перезапустить" }, - "updateSection.installNowHint": { - "defaultMessage": "Или нажмите «Установить и перезапустить», чтобы обновиться сейчас." - }, "updateSection.latestVersion": { "defaultMessage": "У вас установлена последняя версия!" }, "updateSection.loading": { "defaultMessage": "Загрузка..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "После скачивания нужно будет установить обновление вручную." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Для этого способа обновления требуется ручная установка." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Обновление готово! Оно будет установлено при выходе из Goose." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Обновление готово! Нажмите «Установить и перезапустить», чтобы получить инструкции по установке." - }, "updateSection.upToDate": { "defaultMessage": "(актуально)" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index cdef446f8..526641b8d 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Otomatik indirme devre dışı. Manuel olarak indirmek için \"Şimdi İndir\"e tıklayın." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Uygulamadan çıktığınızda güncelleme otomatik olarak yüklenecektir." - }, "updateSection.checkForUpdates": { "defaultMessage": "Güncellemeleri Kontrol Et" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Yükle ve Yeniden Başlat" }, - "updateSection.installNowHint": { - "defaultMessage": "Veya şimdi güncellemek için \"Yükle ve Yeniden Başlat\"ı tıklayın." - }, "updateSection.latestVersion": { "defaultMessage": "En son sürümü çalıştırıyorsunuz!" }, "updateSection.loading": { "defaultMessage": "Yükleniyor..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "İndirdikten sonra güncellemeyi manuel olarak yüklemeniz gerekecektir." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Bu güncelleme yöntemi için manuel kurulum gereklidir." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Güncelleme hazır! Goose'den çıktığınızda yüklenecektir." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Güncelleme hazır! Kurulum talimatları için \"Yükle ve Yeniden Başlat\"a tıklayın." - }, "updateSection.upToDate": { "defaultMessage": "(güncel)" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index a447b8fff..39ca8c11e 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "Tải xuống tự động đã bị tắt. Nhấp vào \"Tải ngay\" để tải xuống theo cách thủ công." }, - "updateSection.autoInstallNote": { - "defaultMessage": "Không cần cài đặt thủ công." - }, "updateSection.checkForUpdates": { "defaultMessage": "Kiểm tra bản cập nhật" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "Cài đặt & khởi động lại" }, - "updateSection.installNowHint": { - "defaultMessage": "Nhấp vào \"Cài đặt & khởi động lại\" để cập nhật ngay." - }, "updateSection.latestVersion": { "defaultMessage": "Bạn đang chạy phiên bản mới nhất!" }, "updateSection.loading": { "defaultMessage": "Đang tải..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "Sau khi tải xuống, bạn sẽ cần cài đặt bản cập nhật theo cách thủ công." - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "Cần cài đặt thủ công cho phương thức cập nhật này." - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ Bản cập nhật đã sẵn sàng. Khởi động lại Goose để hoàn tất cài đặt, hoặc thoát khi bạn đã xong." - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ Bản cập nhật đã sẵn sàng! Nhấp vào \"Cài đặt & khởi động lại\" để xem hướng dẫn cài đặt." - }, "updateSection.upToDate": { "defaultMessage": "(đã cập nhật)" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index c631c76f0..2dcb7ae1c 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "自动下载已禁用。点击\"立即下载\"以手动下载。" }, - "updateSection.autoInstallNote": { - "defaultMessage": "退出应用时将自动安装更新。" - }, "updateSection.checkForUpdates": { "defaultMessage": "检查更新" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "安装并重启" }, - "updateSection.installNowHint": { - "defaultMessage": "或点击“安装并重启”立即更新。" - }, "updateSection.latestVersion": { "defaultMessage": "你已是最新版本!" }, "updateSection.loading": { "defaultMessage": "加载中…" }, - "updateSection.manualInstallNote": { - "defaultMessage": "下载完成后,你需要手动安装更新。" - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "此更新方式需要手动安装。" - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ 更新已就绪!退出 Goose 时将自动安装。" - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ 更新已就绪!点击“安装并重启”查看安装说明。" - }, "updateSection.upToDate": { "defaultMessage": "(已是最新)" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index 7f3d88ea2..803e7307f 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -4531,9 +4531,6 @@ "updateSection.autoDownloadDisabledNote": { "defaultMessage": "已停用自動下載。請點選「立即下載」以手動下載。" }, - "updateSection.autoInstallNote": { - "defaultMessage": "無需手動安裝。" - }, "updateSection.checkForUpdates": { "defaultMessage": "檢查更新" }, @@ -4564,27 +4561,12 @@ "updateSection.installAndRestart": { "defaultMessage": "安裝並重新啟動" }, - "updateSection.installNowHint": { - "defaultMessage": "請點選「安裝並重新啟動」以立即更新。" - }, "updateSection.latestVersion": { "defaultMessage": "您執行的是最新版本!" }, "updateSection.loading": { "defaultMessage": "載入中..." }, - "updateSection.manualInstallNote": { - "defaultMessage": "下載後,您需要手動安裝更新。" - }, - "updateSection.manualInstallRequired": { - "defaultMessage": "此更新方式需要手動安裝。" - }, - "updateSection.readyInstallAuto": { - "defaultMessage": "✓ 更新已就緒。重新啟動 Goose 即可完成安裝,或在您完成後結束。" - }, - "updateSection.readyInstallManual": { - "defaultMessage": "✓ 更新已就緒!請點選「安裝並重新啟動」以取得安裝指示。" - }, "updateSection.upToDate": { "defaultMessage": "(為最新版本)" }, diff --git a/ui/desktop/src/utils/analytics.ts b/ui/desktop/src/utils/analytics.ts index d421c3135..ab25e1def 100644 --- a/ui/desktop/src/utils/analytics.ts +++ b/ui/desktop/src/utils/analytics.ts @@ -192,7 +192,7 @@ export type AnalyticsEvent = properties: { version: string; method: 'electron-updater' | 'github-fallback'; - action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only'; + action: 'quit_and_install' | 'auto_swap_and_relaunch'; }; }; // NOTE: slash_command_used is tracked by the backend (posthog.rs) with command_type info @@ -650,7 +650,7 @@ export function trackUpdateDownloadCompleted( export function trackUpdateInstallInitiated( version: string, method: UpdateMethod, - action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only' + action: 'quit_and_install' | 'auto_swap_and_relaunch' ): void { trackEvent({ name: 'update_install_initiated', diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index abb1c2d25..b9147ab5f 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -4,9 +4,7 @@ import { ipcMain, nativeImage, Tray, - shell, app, - dialog, Menu, MenuItemConstructorOptions, Notification, @@ -272,60 +270,33 @@ export function registerUpdateIpcHandlers() { ipcMain.handle('install-update', async () => { if (isUsingGitHubFallback) { - // For GitHub fallback, we need to handle the installation differently log.info('Installing update from GitHub fallback...'); - try { - // Use the stored extracted path if available, otherwise download path - const updatePath = githubUpdateInfo.extractedPath || githubUpdateInfo.downloadPath; - - if (!updatePath) { - throw new Error('Update file path not found. Please download the update first.'); - } - - // Check if the update path exists - try { - await fs.access(updatePath); - } catch { - throw new Error('Update file not found. Please download the update first.'); - } - - // Improved dialog with clearer instructions - const dialogResult = (await dialog.showMessageBox({ - type: 'info', - title: 'Update Ready to Install', - message: `Version ${githubUpdateInfo.latestVersion} is ready to install.`, - detail: `The update has been downloaded and extracted. To complete the installation:\n\n1. Click "Open Folder" to view the new Goose.app\n2. Quit Goose (this app will close)\n3. Drag the new Goose.app to your Applications folder\n4. Replace the existing app when prompted\n\nThe update will be available the next time you launch Goose.`, - buttons: ['Open Folder & Quit', 'Open Folder Only', 'Cancel'], - defaultId: 0, - cancelId: 2, - })) as unknown as { response: number }; - - if (dialogResult.response === 0) { - trackUpdateInstallInitiated( - githubUpdateInfo.latestVersion || 'unknown', - 'github-fallback', - 'open_folder_and_quit' - ); - // Open folder and quit app for easy replacement - shell.showItemInFolder(updatePath); - setTimeout(() => { - app.quit(); - }, 1500); // Give user time to see the folder open - } else if (dialogResult.response === 1) { - trackUpdateInstallInitiated( - githubUpdateInfo.latestVersion || 'unknown', - 'github-fallback', - 'open_folder_only' - ); - // Just open folder, don't quit - shell.showItemInFolder(updatePath); - } - // response === 2 is Cancel, no tracking needed - } catch (error) { - log.error('Error installing GitHub update:', error); - throw error; + const downloadPath = githubUpdateInfo.downloadPath; + if (!downloadPath) { + throw new Error('Update file path not found. Please download the update first.'); } + + try { + await fs.access(downloadPath); + } catch { + throw new Error('Update file not found. Please download the update first.'); + } + + trackUpdateInstallInitiated( + githubUpdateInfo.latestVersion || 'unknown', + 'github-fallback', + 'auto_swap_and_relaunch' + ); + + const result = await githubUpdater.installUpdate(downloadPath); + if (!result.success) { + log.error('Error installing GitHub update:', result.error); + throw new Error(result.error || 'Failed to install update'); + } + + log.info('Quitting app so the update swap can complete...'); + setTimeout(() => app.quit(), 0); } else { // Use electron-updater's built-in install trackUpdateInstallInitiated( diff --git a/ui/desktop/src/utils/githubUpdater.install.test.ts b/ui/desktop/src/utils/githubUpdater.install.test.ts new file mode 100644 index 000000000..e028fffff --- /dev/null +++ b/ui/desktop/src/utils/githubUpdater.install.test.ts @@ -0,0 +1,286 @@ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { launchSwapScript, prepareUpdateInstall } from './githubUpdater'; + +const tempDirs: string[] = []; + +function run(command: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: 'ignore', windowsHide: true }); + child.on('error', reject); + child.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`)) + ); + }); +} + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), prefix)); + tempDirs.push(dir); + return dir; +} + +async function writeLauncher(dir: string, markerPath: string): Promise { + if (process.platform === 'win32') { + const launcher = path.join(dir, 'Goose.cmd'); + await fs.writeFile(launcher, `@echo off\r\necho relaunched> "${markerPath}"\r\n`); + return launcher; + } + + const launcher = path.join(dir, 'Goose'); + await fs.writeFile(launcher, `#!/bin/sh\necho relaunched > "${markerPath}"\n`, { mode: 0o755 }); + return launcher; +} + +function executableRelativePath(): string { + if (process.platform === 'darwin') { + return path.join('Contents', 'MacOS', 'Goose'); + } + return process.platform === 'win32' ? 'Goose.cmd' : 'Goose'; +} + +async function makePayload(root: string, version: string, markerPath: string): Promise { + if (process.platform === 'darwin') { + const bundle = path.join(root, 'Goose.app'); + const macOsDir = path.join(bundle, 'Contents', 'MacOS'); + await fs.mkdir(macOsDir, { recursive: true }); + await fs.writeFile( + path.join(bundle, 'Contents', 'Info.plist'), + ` + + +CFBundleExecutableGoose +CFBundleIdentifierdev.goose.updater.test.${version} +CFBundlePackageTypeAPPL + +` + ); + await writeLauncher(macOsDir, markerPath); + await fs.writeFile(path.join(bundle, 'version.txt'), version); + return bundle; + } + + const payload = path.join(root, 'Goose'); + await fs.mkdir(payload, { recursive: true }); + await writeLauncher(payload, markerPath); + await fs.writeFile(path.join(payload, 'version.txt'), version); + return payload; +} + +// A structurally valid payload directory that was packaged without the application executable. +async function makeEmptyPayload(root: string): Promise { + const payload = path.join(root, process.platform === 'darwin' ? 'Goose.app' : 'Goose'); + await fs.mkdir(payload, { recursive: true }); + await fs.writeFile(path.join(payload, 'README.txt'), 'no executable here'); + return payload; +} + +async function zip(payloadPath: string, archivePath: string): Promise { + const parent = path.dirname(payloadPath); + const name = path.basename(payloadPath); + + if (process.platform === 'darwin') { + await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', name, archivePath], parent); + } else if (process.platform === 'win32') { + await run( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Compress-Archive -Path '${payloadPath}' -DestinationPath '${archivePath}' -Force`, + ], + parent + ); + } else { + await run('zip', ['-r', '-q', archivePath, name], parent); + } +} + +async function waitFor(check: () => Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return false; +} + +async function exists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +async function listTree(dir: string, prefix = ''): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null); + if (!entries) { + return `${prefix}(missing)\n`; + } + + let out = ''; + for (const entry of entries) { + out += `${prefix}${entry.name}${entry.isDirectory() ? '/' : ''}\n`; + if (entry.isDirectory() && prefix.length < 4) { + out += await listTree(path.join(dir, entry.name), `${prefix} `); + } + } + return out; +} + +// The swap script deletes its staging directory when it finishes, so its log is kept beside +// that directory and is the only record of why a background script gave up. +async function diagnostics(stagingDir: string, installRoot: string): Promise { + const logText = await fs + .readFile(`${stagingDir}-install.log`, 'utf8') + .catch(() => '(no install.log)'); + return [ + '', + '--- install.log ---', + logText, + '--- install dir ---', + await listTree(installRoot), + ].join('\n'); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('prepareUpdateInstall', () => { + it('waits for the app to exit, swaps in the new version, and relaunches it', async () => { + const workspace = await makeTempDir('goose-update-test-'); + const stagingDir = path.join(workspace, 'staging'); + const payloadSource = path.join(workspace, 'payload'); + const installRoot = path.join(workspace, 'install'); + const markerPath = path.join(workspace, 'relaunched.txt'); + await fs.mkdir(stagingDir, { recursive: true }); + await fs.mkdir(payloadSource, { recursive: true }); + await fs.mkdir(installRoot, { recursive: true }); + + const newPayload = await makePayload(payloadSource, '2.0.0', markerPath); + const archivePath = path.join(stagingDir, 'Goose-2.0.0.zip'); + await zip(newPayload, archivePath); + + const installedRoot = await makePayload(installRoot, '1.0.0', markerPath); + const versionFile = path.join(installedRoot, 'version.txt'); + + const relaunchPath = + process.platform === 'darwin' + ? installedRoot + : path.join(installedRoot, executableRelativePath()); + + const runningApp = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 120000)'], { + stdio: 'ignore', + windowsHide: true, + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + + const swap = await prepareUpdateInstall({ + archivePath, + targetPath: installedRoot, + relaunchPath, + executableRelativePath: executableRelativePath(), + pid: runningApp.pid!, + }); + + launchSwapScript(swap); + + const unrelatedFile = path.join(installRoot, 'unrelated-user-file.txt'); + await fs.writeFile(unrelatedFile, 'keep me'); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + expect(await fs.readFile(versionFile, 'utf8')).toBe('1.0.0'); + expect(await exists(markerPath)).toBe(false); + + runningApp.kill(); + await new Promise((resolve) => runningApp.once('exit', resolve)); + + const swapped = await waitFor( + async () => (await fs.readFile(versionFile, 'utf8').catch(() => '')) === '2.0.0', + 60000 + ); + expect(swapped, await diagnostics(stagingDir, installRoot)).toBe(true); + expect(await waitFor(() => exists(markerPath), 60000)).toBe(true); + expect(await waitFor(async () => !(await exists(stagingDir)), 60000)).toBe(true); + expect(await fs.readFile(unrelatedFile, 'utf8')).toBe('keep me'); + expect(await exists(`${installedRoot}.goose-previous`)).toBe(false); + }, 150000); + + it('restores the previous install when the new payload cannot be copied', async () => { + const workspace = await makeTempDir('goose-update-rollback-'); + const stagingDir = path.join(workspace, 'staging'); + const payloadSource = path.join(workspace, 'payload'); + const installRoot = path.join(workspace, 'install'); + const markerPath = path.join(workspace, 'relaunched.txt'); + await fs.mkdir(stagingDir, { recursive: true }); + await fs.mkdir(payloadSource, { recursive: true }); + await fs.mkdir(installRoot, { recursive: true }); + + const newPayload = await makePayload(payloadSource, '2.0.0', markerPath); + const archivePath = path.join(stagingDir, 'Goose-2.0.0.zip'); + await zip(newPayload, archivePath); + + const installedRoot = await makePayload(installRoot, '1.0.0', markerPath); + const versionFile = path.join(installedRoot, 'version.txt'); + + const relaunchPath = + process.platform === 'darwin' + ? installedRoot + : path.join(installedRoot, executableRelativePath()); + + const exitedApp = spawn(process.execPath, ['-e', ''], { stdio: 'ignore', windowsHide: true }); + await new Promise((resolve) => exitedApp.once('exit', resolve)); + + const swap = await prepareUpdateInstall({ + archivePath, + targetPath: installedRoot, + relaunchPath, + executableRelativePath: executableRelativePath(), + pid: exitedApp.pid!, + }); + + // Deleting the extracted payload makes the copy step fail, exercising the rollback path. + await fs.rm(path.join(stagingDir, 'extracted'), { recursive: true, force: true }); + + launchSwapScript(swap); + + // The restored app is relaunched at the end of the swap, so the marker proves the script + // ran to completion rather than merely that the rollback has not happened yet. + const relaunched = await waitFor(() => exists(markerPath), 30000); + expect(relaunched, await diagnostics(stagingDir, installRoot)).toBe(true); + expect(await exists(`${installedRoot}.goose-previous`)).toBe(false); + expect(await fs.readFile(versionFile, 'utf8')).toBe('1.0.0'); + expect(await exists(path.join(installedRoot, executableRelativePath()))).toBe(true); + }, 60000); + + it('refuses to install a payload that is missing its executable', async () => { + const workspace = await makeTempDir('goose-update-invalid-'); + const stagingDir = path.join(workspace, 'staging'); + const payloadSource = path.join(workspace, 'payload'); + await fs.mkdir(stagingDir, { recursive: true }); + await fs.mkdir(payloadSource, { recursive: true }); + + const emptyPayload = await makeEmptyPayload(payloadSource); + const archivePath = path.join(stagingDir, 'Goose-2.0.0.zip'); + await zip(emptyPayload, archivePath); + + await expect( + prepareUpdateInstall({ + archivePath, + targetPath: path.join(workspace, 'install', 'Goose'), + relaunchPath: path.join(workspace, 'install', 'Goose'), + executableRelativePath: executableRelativePath(), + pid: process.pid, + }) + ).rejects.toThrow(/missing its executable/); + }, 60000); +}); diff --git a/ui/desktop/src/utils/githubUpdater.target.test.ts b/ui/desktop/src/utils/githubUpdater.target.test.ts new file mode 100644 index 000000000..83e08f379 --- /dev/null +++ b/ui/desktop/src/utils/githubUpdater.target.test.ts @@ -0,0 +1,119 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { resolveInstallTarget } from './githubUpdater'; + +const tempDirs: string[] = []; +const originalPlatform = process.platform; + +function setPlatform(platform: typeof process.platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), 'goose-target-test-')); + tempDirs.push(dir); + return dir; +} + +async function makeInstallDir(root: string, name: string): Promise { + const installDir = path.join(root, name); + await fs.mkdir(path.join(installDir, 'resources'), { recursive: true }); + await fs.mkdir(path.join(installDir, 'locales'), { recursive: true }); + await fs.writeFile(path.join(installDir, 'resources', 'app.asar'), 'asar'); + await fs.writeFile(path.join(installDir, 'locales', 'en-US.pak'), 'pak'); + const exePath = path.join(installDir, originalPlatform === 'win32' ? 'Goose.exe' : 'goose'); + await fs.writeFile(exePath, 'binary'); + return exePath; +} + +afterEach(async () => { + setPlatform(originalPlatform); + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('resolveInstallTarget', () => { + it('resolves the .app bundle on macOS', async () => { + setPlatform('darwin'); + const exePath = '/Applications/Goose.app/Contents/MacOS/Goose'; + + await expect(resolveInstallTarget(exePath)).resolves.toEqual({ + targetPath: '/Applications/Goose.app', + relaunchPath: '/Applications/Goose.app', + executableRelativePath: path.join('Contents', 'MacOS', 'Goose'), + }); + }); + + it('rejects a macOS executable that is not inside a bundle', async () => { + setPlatform('darwin'); + + await expect(resolveInstallTarget('/usr/local/bin/goose')).rejects.toThrow( + /Could not locate running .app bundle/ + ); + }); + + it('accepts a packaged install directory on other platforms', async () => { + setPlatform('linux'); + const root = await makeTempDir(); + const exePath = await makeInstallDir(root, 'goose-linux-x64'); + + await expect(resolveInstallTarget(exePath)).resolves.toEqual({ + targetPath: path.dirname(exePath), + relaunchPath: exePath, + executableRelativePath: path.basename(exePath), + }); + }); + + it('refuses to update when the executable parent is not a packaged app directory', async () => { + setPlatform('linux'); + const root = await makeTempDir(); + const exePath = path.join(root, 'goose'); + await fs.writeFile(exePath, 'binary'); + await fs.writeFile(path.join(root, 'tax-return.pdf'), 'important'); + + await expect(resolveInstallTarget(exePath)).rejects.toThrow( + /does not look like an app install directory/ + ); + }); + + it('refuses to update when the install directory is a shared directory', async () => { + setPlatform('linux'); + const root = await makeTempDir(); + const exePath = await makeInstallDir(root, 'Downloads'); + + await expect(resolveInstallTarget(exePath)).rejects.toThrow(/is a shared directory/); + }); + + it('refuses to update a plausibly named directory shared with unrelated files', async () => { + setPlatform('linux'); + const root = await makeTempDir(); + const exePath = await makeInstallDir(root, 'Stuff'); + await fs.writeFile(path.join(path.dirname(exePath), 'tax-return.pdf'), 'important'); + + await expect(resolveInstallTarget(exePath)).rejects.toThrow(/is not dedicated to the app/); + }); + + it('refuses to update when the install directory is missing Electron runtime directories', async () => { + setPlatform('linux'); + const root = await makeTempDir(); + const installDir = path.join(root, 'goose-linux-x64'); + await fs.mkdir(path.join(installDir, 'resources'), { recursive: true }); + await fs.writeFile(path.join(installDir, 'resources', 'app.asar'), 'asar'); + const exePath = path.join(installDir, 'goose'); + await fs.writeFile(exePath, 'binary'); + + await expect(resolveInstallTarget(exePath)).rejects.toThrow( + /does not look like an app install directory/ + ); + }); + + it('refuses to update when the install directory is the home directory', async () => { + setPlatform('linux'); + const home = path.resolve(os.homedir()); + + await expect(resolveInstallTarget(path.join(home, 'goose'))).rejects.toThrow( + /Refusing to auto-update/ + ); + }); +}); diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 34d09b324..99c93aa35 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -1,5 +1,6 @@ import { app } from 'electron'; import { compareVersions } from 'compare-versions'; +import { spawn } from 'child_process'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; @@ -26,6 +27,432 @@ interface UpdateCheckResult { error?: string; } +interface InstallTarget { + targetPath: string; + relaunchPath: string; + // Used to confirm the extracted payload really is an app before the backup is deleted. + executableRelativePath: string; +} + +interface SwapCommand { + command: string; + args: string[]; +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'ignore', windowsHide: true }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} exited with code ${code}`)); + } + }); + }); +} + +function powershellQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function extractArchive(archivePath: string, destDir: string): Promise { + if (process.platform === 'darwin') { + await runCommand('ditto', ['-x', '-k', archivePath, destDir]); + } else if (process.platform === 'win32') { + await runCommand('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Expand-Archive -LiteralPath ${powershellQuote(archivePath)} -DestinationPath ${powershellQuote(destDir)} -Force`, + ]); + } else { + await runCommand('unzip', ['-q', '-o', archivePath, '-d', destDir]); + } +} + +async function resolvePayloadPath(extractDir: string): Promise { + let current = extractDir; + + for (let depth = 0; depth < 3; depth += 1) { + const entries = (await fs.readdir(current, { withFileTypes: true })).filter( + (entry) => !entry.name.startsWith('.') && entry.name !== '__MACOSX' + ); + + const appBundle = entries.find((entry) => entry.isDirectory() && entry.name.endsWith('.app')); + if (appBundle) { + return path.join(current, appBundle.name); + } + + if (entries.length === 1 && entries[0].isDirectory()) { + current = path.join(current, entries[0].name); + continue; + } + + return current; + } + + return current; +} + +// Electron ships these alongside the executable in every packaged build, so an install +// root always contains them. Their absence means the directory is not an install root. +const REQUIRED_INSTALL_DIRECTORIES = ['locales', 'resources']; + +// Everything a packaged Electron app is allowed to place next to its executable. Anything +// else means the directory holds unrelated files and cannot be replaced wholesale. +const ELECTRON_RUNTIME_DIRECTORIES = new Set(['locales', 'resources', 'swiftshader']); + +const ELECTRON_RUNTIME_FILES = new Set([ + 'chrome-sandbox', + 'chrome_crashpad_handler', + 'icudtl.dat', + 'libvulkan.so.1', + 'license', + 'licenses.chromium.html', + 'version', +]); + +const ELECTRON_RUNTIME_EXTENSIONS = new Set([ + '.bin', + '.dat', + '.dll', + '.exe', + '.html', + '.json', + '.node', + '.pak', + '.so', + '.txt', +]); + +// Directories users commonly unpack portable builds into. Replacing one of these +// wholesale would delete unrelated files, so an install there is never swapped. +const SHARED_DIRECTORY_NAMES = new Set([ + 'applications', + 'appdata', + 'bin', + 'desktop', + 'documents', + 'downloads', + 'dropbox', + 'etc', + 'home', + 'local', + 'music', + 'onedrive', + 'opt', + 'pictures', + 'program files', + 'program files (x86)', + 'programdata', + 'roaming', + 'temp', + 'tmp', + 'usr', + 'users', + 'var', + 'videos', +]); + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +function isSharedDirectory(dir: string): boolean { + if (dir === path.parse(dir).root) { + return true; + } + if (dir === path.resolve(os.homedir()) || dir === path.resolve(os.tmpdir())) { + return true; + } + return SHARED_DIRECTORY_NAMES.has(path.basename(dir).toLowerCase()); +} + +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory(); + } catch { + return false; + } +} + +// Packaged Electron apps always ship resources/app.asar (or an unpacked resources/app) +// alongside the locales directory, which distinguishes an install root from an arbitrary folder. +async function isPackagedAppDirectory(dir: string): Promise { + for (const required of REQUIRED_INSTALL_DIRECTORIES) { + if (!(await isDirectory(path.join(dir, required)))) { + return false; + } + } + + const resources = path.join(dir, 'resources'); + return ( + (await pathExists(path.join(resources, 'app.asar'))) || + (await pathExists(path.join(resources, 'app'))) + ); +} + +// A basename blacklist cannot prove a directory is safe to replace, so require that every +// entry belongs to a packaged Electron app. Anything else means the directory is shared +// with unrelated files that a wholesale swap would delete. +async function findUnexpectedInstallEntries(dir: string, exeName: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + return entries + .filter((entry) => { + const name = entry.name.toLowerCase(); + if (name.startsWith('.') || name === exeName.toLowerCase()) { + return false; + } + if (entry.isDirectory()) { + return !ELECTRON_RUNTIME_DIRECTORIES.has(name); + } + return ( + !ELECTRON_RUNTIME_FILES.has(name) && !ELECTRON_RUNTIME_EXTENSIONS.has(path.extname(name)) + ); + }) + .map((entry) => entry.name); +} + +export async function resolveInstallTarget(exePath: string): Promise { + const resolvedExePath = path.resolve(exePath); + + if (process.platform === 'darwin') { + const appPath = path.resolve(resolvedExePath, '..', '..', '..'); + if (!appPath.endsWith('.app')) { + throw new Error(`Could not locate running .app bundle from ${resolvedExePath}`); + } + return { + targetPath: appPath, + relaunchPath: appPath, + executableRelativePath: path.relative(appPath, resolvedExePath), + }; + } + + const installDir = path.dirname(resolvedExePath); + + if (!(await isPackagedAppDirectory(installDir))) { + throw new Error( + `Refusing to auto-update: ${installDir} does not look like an app install directory` + ); + } + + if (isSharedDirectory(installDir)) { + throw new Error(`Refusing to auto-update: ${installDir} is a shared directory`); + } + + const unexpected = await findUnexpectedInstallEntries(installDir, path.basename(resolvedExePath)); + if (unexpected.length > 0) { + throw new Error( + `Refusing to auto-update: ${installDir} is not dedicated to the app (found ${unexpected + .slice(0, 5) + .join(', ')})` + ); + } + + return { + targetPath: installDir, + relaunchPath: resolvedExePath, + executableRelativePath: path.basename(resolvedExePath), + }; +} + +async function writeSwapScript(options: { + stagingDir: string; + payloadPath: string; + targetPath: string; + relaunchPath: string; + executableRelativePath: string; + pid: number; +}): Promise { + const { stagingDir, payloadPath, targetPath, relaunchPath, executableRelativePath, pid } = + options; + // The script deletes its staging directory once it finishes, so the log lives beside that + // directory to survive cleanup and stay available when diagnosing a failed update. + const logPath = `${stagingDir}-install.log`; + // The previous install is moved aside rather than deleted so a failed copy can be rolled back. + // It stays beside the target so the move is a same-filesystem rename instead of a full copy. + const backupPath = `${targetPath}.goose-previous`; + + if (process.platform === 'win32') { + const scriptPath = path.join(stagingDir, 'swap-and-relaunch.ps1'); + // Copy-Item nests the source inside an existing destination directory, so the payload + // contents are copied into a freshly created target instead of the payload directory itself. + // Get-ChildItem enumerates them via -LiteralPath so paths containing glob metacharacters + // are not expanded, and -Force keeps hidden entries. + const installedExe = powershellQuote(path.join(targetPath, executableRelativePath)); + const script = [ + `$ErrorActionPreference = 'Continue'`, + // Start-Transcript silently produces no file when it is unavailable, so the log is written + // directly to keep a failing detached script diagnosable. + `function Write-Log($message) { try { Add-Content -LiteralPath ${powershellQuote(logPath)} -Value $message } catch {} }`, + `Write-Log "swap starting for pid ${pid}"`, + // A process object reports HasExited once the app is gone, which distinguishes a live app + // from the handle that lingers briefly after exit. + `$attempt = 0`, + `while ($attempt -lt 120) {`, + ` $proc = Get-Process -Id ${pid} -ErrorAction SilentlyContinue`, + ` if (-not $proc -or $proc.HasExited) { break }`, + ` Start-Sleep -Milliseconds 500`, + ` $attempt = $attempt + 1`, + `}`, + // Replacing an install while it runs corrupts it, so a stalled quit aborts the swap. + `$proc = Get-Process -Id ${pid} -ErrorAction SilentlyContinue`, + `if ($proc -and -not $proc.HasExited) {`, + ` Write-Log 'app is still running; aborting update'`, + ` exit 1`, + `}`, + `Write-Log 'app has exited; swapping install'`, + `Remove-Item -LiteralPath ${powershellQuote(backupPath)} -Recurse -Force -ErrorAction SilentlyContinue`, + `Move-Item -LiteralPath ${powershellQuote(targetPath)} -Destination ${powershellQuote(backupPath)} -Force`, + `if (Test-Path -LiteralPath ${powershellQuote(targetPath)}) { throw 'Could not move previous install aside' }`, + `try {`, + ` New-Item -ItemType Directory -Path ${powershellQuote(targetPath)} -Force -ErrorAction Stop | Out-Null`, + ` $payloadEntries = (Get-ChildItem -LiteralPath ${powershellQuote(payloadPath)} -Force).FullName`, + ` Copy-Item -LiteralPath $payloadEntries -Destination ${powershellQuote(targetPath)} -Recurse -Force -ErrorAction Stop`, + // A valid archive can still be packaged without the executable, so the backup is only + // discarded once the copied payload is confirmed to be a runnable install. + ` if (-not (Test-Path -LiteralPath ${installedExe})) { throw 'Updated install is missing its executable' }`, + ` Remove-Item -LiteralPath ${powershellQuote(backupPath)} -Recurse -Force -ErrorAction SilentlyContinue`, + `} catch {`, + ` Remove-Item -LiteralPath ${powershellQuote(targetPath)} -Recurse -Force -ErrorAction SilentlyContinue`, + ` Move-Item -LiteralPath ${powershellQuote(backupPath)} -Destination ${powershellQuote(targetPath)} -Force`, + `}`, + `Start-Process -FilePath ${powershellQuote(relaunchPath)}`, + `try { Stop-Transcript | Out-Null } catch {}`, + `Remove-Item -LiteralPath ${powershellQuote(stagingDir)} -Recurse -Force -ErrorAction SilentlyContinue`, + '', + ].join('\r\n'); + + await fs.writeFile(scriptPath, script); + return { + command: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], + }; + } + + const scriptPath = path.join(stagingDir, 'swap-and-relaunch.sh'); + const quotedPayload = shellQuote(payloadPath); + const quotedTarget = shellQuote(targetPath); + const quotedBackup = shellQuote(backupPath); + const quotedRelaunch = shellQuote(relaunchPath); + const quotedInstalledExe = shellQuote(path.join(targetPath, executableRelativePath)); + const copyCommand = + process.platform === 'darwin' + ? `ditto ${quotedPayload} ${quotedTarget}` + : `cp -a ${quotedPayload} ${quotedTarget}`; + const relaunch = + process.platform === 'darwin' + ? [`xattr -dr com.apple.quarantine ${quotedTarget} || true`, `open ${quotedRelaunch}`] + : [`${quotedRelaunch} >/dev/null 2>&1 &`]; + + const script = [ + '#!/bin/sh', + 'set -e', + `exec >> ${shellQuote(logPath)} 2>&1`, + 'attempt=0', + 'while [ "$attempt" -lt 120 ]; do', + ` kill -0 ${pid} 2>/dev/null || break`, + ' sleep 0.5', + ' attempt=$((attempt + 1))', + 'done', + // Touching a live bundle corrupts the running app, so a stalled shutdown aborts the swap. + `if kill -0 ${pid} 2>/dev/null; then`, + ' echo "App is still running; aborting update"', + ' exit 1', + 'fi', + `rm -rf ${quotedBackup}`, + `mv ${quotedTarget} ${quotedBackup}`, + // A valid archive can still be packaged without the executable, so the backup is only + // discarded once the copied payload is confirmed to be a runnable install. + `if ${copyCommand} && [ -x ${quotedInstalledExe} ]; then`, + ` rm -rf ${quotedBackup}`, + 'else', + ` rm -rf ${quotedTarget}`, + ` mv ${quotedBackup} ${quotedTarget}`, + 'fi', + ...relaunch, + `rm -rf ${shellQuote(stagingDir)}`, + '', + ].join('\n'); + + await fs.writeFile(scriptPath, script, { mode: 0o755 }); + return { command: '/bin/sh', args: [scriptPath] }; +} + +// Node's detached flag becomes DETACHED_PROCESS on Windows, which leaves the child with no +// console, and powershell.exe exits before its first statement without one. windowsHide still +// allocates a console without showing a window, and Windows keeps a child alive after its +// parent exits, so the swap outlives the quit without detaching. POSIX still detaches so the +// script survives the app's process group going away. +export function launchSwapScript(swap: SwapCommand): void { + const child = spawn(swap.command, swap.args, { + detached: process.platform !== 'win32', + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); +} + +// A ZIP can be valid yet packaged without the expected application, which would let the swap +// replace a working install with an unrunnable one. Checking before the backup is deleted keeps +// the failure recoverable. +async function assertPayloadIsRunnable( + payloadPath: string, + executableRelativePath: string +): Promise { + const executable = path.join(payloadPath, executableRelativePath); + if (!(await pathExists(executable))) { + throw new Error( + `Update payload is missing its executable (expected ${executableRelativePath} in ${path.basename(payloadPath)})` + ); + } + + if (process.platform === 'darwin' && !payloadPath.endsWith('.app')) { + throw new Error(`Update payload is not an .app bundle: ${payloadPath}`); + } +} + +export async function prepareUpdateInstall(options: { + archivePath: string; + targetPath: string; + relaunchPath: string; + executableRelativePath: string; + pid: number; +}): Promise { + const stagingDir = path.dirname(options.archivePath); + const extractDir = path.join(stagingDir, 'extracted'); + + await fs.rm(extractDir, { recursive: true, force: true }); + await fs.mkdir(extractDir, { recursive: true }); + await extractArchive(options.archivePath, extractDir); + + const payloadPath = await resolvePayloadPath(extractDir); + log.info(`GitHubUpdater: Update payload: ${payloadPath}`); + + await assertPayloadIsRunnable(payloadPath, options.executableRelativePath); + + return writeSwapScript({ + stagingDir, + payloadPath, + targetPath: options.targetPath, + relaunchPath: options.relaunchPath, + executableRelativePath: options.executableRelativePath, + pid: options.pid, + }); +} + export class GitHubUpdater { private readonly owner = process.env.GITHUB_OWNER || 'aaif-goose'; private readonly repo = process.env.GITHUB_REPO || 'goose'; @@ -252,10 +679,10 @@ export class GitHubUpdater { const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); log.info(`GitHubUpdater: Buffer created - ${buffer.length} bytes`); - // Save to Downloads directory - const downloadsDir = path.join(os.homedir(), 'Downloads'); + const stagingDir = path.join(os.tmpdir(), `goose-update-${latestVersion}-${Date.now()}`); + await fs.mkdir(stagingDir, { recursive: true }); const fileName = `${this.bundleName}-${latestVersion}.zip`; - const downloadPath = path.join(downloadsDir, fileName); + const downloadPath = path.join(stagingDir, fileName); log.info(`GitHubUpdater: Writing file to ${downloadPath}...`); await fs.writeFile(downloadPath, buffer); @@ -264,8 +691,7 @@ export class GitHubUpdater { log.info(`=== GitHubUpdater: DOWNLOAD COMPLETE in ${totalDuration}ms ===`); log.info(`GitHubUpdater: File saved to ${downloadPath}`); - // Return success - user will handle extraction manually - return { success: true, downloadPath, extractedPath: downloadsDir }; + return { success: true, downloadPath, extractedPath: stagingDir }; } catch (error) { const duration = Date.now() - downloadStartTime; log.error(`=== GitHubUpdater: DOWNLOAD FAILED after ${duration}ms ===`); @@ -281,6 +707,39 @@ export class GitHubUpdater { }; } } + + async installUpdate(downloadPath: string): Promise<{ success: boolean; error?: string }> { + try { + log.info('=== GitHubUpdater: STARTING AUTOMATIC INSTALL ==='); + log.info(`GitHubUpdater: Download path: ${downloadPath}`); + + await fs.access(downloadPath); + + const { targetPath, relaunchPath, executableRelativePath } = await resolveInstallTarget( + app.getPath('exe') + ); + log.info(`GitHubUpdater: Install target: ${targetPath}`); + + const swap = await prepareUpdateInstall({ + archivePath: downloadPath, + targetPath, + relaunchPath, + executableRelativePath, + pid: process.pid, + }); + + launchSwapScript(swap); + + log.info('=== GitHubUpdater: SWAP SCRIPT LAUNCHED, app will quit ==='); + return { success: true }; + } catch (error) { + log.error('GitHubUpdater: Error installing update:', error); + return { + success: false, + error: errorMessage(error, 'Unknown error'), + }; + } + } } // Create singleton instance