diff --git a/build/TianShanOS.bin b/build/TianShanOS.bin index a838615..c29b2e6 100644 Binary files a/build/TianShanOS.bin and b/build/TianShanOS.bin differ diff --git a/build/bootloader/bootloader.bin b/build/bootloader/bootloader.bin index 2437942..9240141 100644 Binary files a/build/bootloader/bootloader.bin and b/build/bootloader/bootloader.bin differ diff --git a/build/firmware-manifest.json b/build/firmware-manifest.json index 4d313cc..9e7036f 100644 --- a/build/firmware-manifest.json +++ b/build/firmware-manifest.json @@ -1,17 +1,17 @@ { "version": "0.6.2", - "build_version": "0.6.2+028e6330.10132311", + "build_version": "0.6.2+fedbf9cc.10140010", "idf_version": "5.5.2", - "source_base_commit": "028e63309255045957e86fcb73cc328cf5d95997", - "source_inputs_sha256": "a0c0f9b6b138c17f4cedeb3d902135882c9e28204987ec6338f7ece0459aac0e", + "source_base_commit": "fedbf9cc3f8b86b8a57c1e8a2f536080dbad1561", + "source_inputs_sha256": "c90243b39cc3a8ae8b32a134897e92509301b4fe56beeba0d4b8860a57de5ff0", "files": { "TianShanOS.bin": { "bytes": 2226000, - "sha256": "f6e9f7ca1c22dd6aeaabfa6a36c0c38511ef013dbde999596c150dcbc20e382f" + "sha256": "1d44568f570252f9b653292c4c2e8fc83e42f45ee1c2962a37de0487101b5278" }, "www.bin": { "bytes": 3145728, - "sha256": "3fbbd6a641be069593e00f3cebf4034feefb0fc5322f41c3145accbeb2d996b5" + "sha256": "f11956e6545c6fb789ccb0a99757e04f8beb886ed3083829100b16af6dfcc8d7" }, "ota_data_initial.bin": { "bytes": 8192, @@ -23,7 +23,7 @@ }, "bootloader/bootloader.bin": { "bytes": 22496, - "sha256": "3f2254ead6037716cf2c7d2b6ac9ed8414b87779ea64ee52c63282274fbf0f24" + "sha256": "4fa4a1b8b644d2291bbf38109e3197450c4d754a152548a9262de24a39417d6d" }, "partition_table/partition-table.bin": { "bytes": 3072, diff --git a/build/www.bin b/build/www.bin index 7091518..b8c32b4 100644 Binary files a/build/www.bin and b/build/www.bin differ diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index ababb77..47abf9c 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -13004,6 +13004,17 @@ async function loadOtaPage() {

${t('ota.firmwareUpgrade')}

+ + +
@@ -13057,16 +13068,6 @@ async function loadOtaPage() {
- -
@@ -13330,7 +13331,7 @@ async function refreshOtaProgress() { stateEl.textContent = stepText + (stateMap[state] || state); if (state !== 'idle') { - progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', ''); + progressSection.style.display = 'block'; // 更新进度条 document.getElementById('ota-progress-bar').style.width = percent + '%'; @@ -13629,7 +13630,7 @@ async function otaFromUrl() { // 立即显示进度区域,提供即时反馈 const progressSection = document.getElementById('ota-progress-section'); - progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', ''); + progressSection.style.display = 'block'; document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.step1Connecting') : '[1/2] 正在连接服务器...'; document.getElementById('ota-progress-bar').style.width = '0%'; document.getElementById('ota-progress-percent').textContent = '0%'; @@ -13695,7 +13696,7 @@ async function otaFromFile() { // 立即显示进度区域 const progressSection = document.getElementById('ota-progress-section'); - progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', ''); + progressSection.style.display = 'block'; const stepText = includeWww ? '[1/2] ' : ''; document.getElementById('ota-state-text').textContent = stepText + (typeof t === 'function' ? t('otaPage.readingFile') : '正在读取文件...'); document.getElementById('ota-progress-bar').style.width = '0%'; @@ -13797,9 +13798,11 @@ async function abortOta() { if (upgrade) finishOtaUpgrade(upgrade); otaStep = 'idle'; document.getElementById('ota-progress-section').style.display = 'none'; - await refreshOtaInfo(); + // Finish cleanup before refreshing: the refresh can outlive this + // attempt while the user starts another upgrade. clearInterval(refreshInterval); refreshInterval = null; + await refreshOtaInfo(); } else { if (upgrade) upgrade.phase = previousPhase; reportFailure({ op: 'ota.abort', result, write: true }); @@ -14057,7 +14060,7 @@ async function upgradeFromServer() { // 立即显示进度区域,给用户即时反馈 const progressSection = document.getElementById('ota-progress-section'); - progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', ''); + progressSection.style.display = 'block'; document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('ui.preparingUpgrade') : 'Preparing upgrade...'; document.getElementById('ota-progress-bar').style.width = '0%'; document.getElementById('ota-progress-percent').textContent = ''; diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index 89fb19b..912b766 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -1081,6 +1081,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { downloading: 'Downloading', installing: 'Installing', progress: 'Progress', + upgradeProgress: 'Upgrade Progress', upgradeSuccess: 'Upgrade successful, rebooting...', upgradeFailed: 'Upgrade Failed', rollback: 'Rollback', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 0dfb307..2383d1e 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -1061,6 +1061,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { downloading: '下载中', installing: '安装中', progress: '进度', + upgradeProgress: '升级进度', upgradeSuccess: '升级成功,即将重启...', upgradeFailed: '升级失败', rollback: '回滚', diff --git a/tests/prompts/ota.browser.test.cjs b/tests/prompts/ota.browser.test.cjs index f51aa36..711a574 100644 --- a/tests/prompts/ota.browser.test.cjs +++ b/tests/prompts/ota.browser.test.cjs @@ -100,7 +100,70 @@ async function returnToOtaPage(page) { await page.waitForFunction(() => document.getElementById('ota-current-version')?.textContent.includes('0.6.')); } +async function expectIndependentProgress(page, abortVisible) { + assert.equal(await page.locator('#ota-progress-section').isVisible(), true); + assert.equal(await page.locator('#ota-abort-btn').isVisible(), abortVisible); + assert.deepEqual(await page.locator('#ota-progress-section').evaluate(section => ({ + card: section.classList.contains('card'), + parent: section.parentElement.className, + previous: section.previousElementSibling.tagName, + folded: !!section.closest('details'), + title: section.querySelector('h2').textContent === t('ota.upgradeProgress') + })), {card: true, parent: 'ota-col', previous: 'H1', folded: false, title: true}); + for (const width of [1440, 390]) { + await page.setViewportSize({width, height: 1000}); + const card = await page.locator('#ota-progress-section').boundingBox(); + const bar = await page.locator('#ota-progress-section .bar').boundingBox(); + const fill = await page.locator('#ota-progress-bar').boundingBox(); + const percent = await page.locator('#ota-progress-percent').boundingBox(); + assert(card.x >= 0 && card.x + card.width <= width); + assert(bar.width > 0 && bar.x >= card.x && bar.x + bar.width <= card.x + card.width); + assert(fill.width >= 0 && fill.width <= bar.width); + assert(percent.x >= card.x && percent.x + percent.width <= card.x + card.width); + } +} + for (const language of ['zh-CN', 'en-US']) { + test(`${language}: idle and check-update states keep the independent progress card hidden`, async () => { + await otaPage(language, 'manual-idle', async ({page, calls}) => { + assert.equal(await page.locator('.ota-col > .card').count(), 4); + assert.equal(await page.locator('#ota-progress-section').isVisible(), false); + await page.locator('[onclick="checkForUpdates()"]').click(); + await page.locator('[onclick="upgradeFromServer()"]').waitFor(); + assert.equal(await page.locator('#ota-progress-section').isVisible(), false); + assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false); + assert.equal(calls.filter(call => /ota\/(firmware|upgrade_url|upgrade_file)/.test(call.endpoint)).length, 0); + }); + }); + + for (const source of ['URL', 'SD card']) for (const includeWww of [true, false]) { + test(`${language}: ${source} ${includeWww ? 'both images' : 'firmware-only'} progress stays visible with manual controls folded`, async () => { + await otaPage(language, 'manual-progress-card', async ({page, calls, state}) => { + await manualInput(page, source, includeWww); + const start = source === 'URL' ? 'otaFromUrl()' : 'otaFromFile()'; + await page.locator(`[onclick="${start}"]`).click(); + await page.waitForFunction(() => otaUpgrade?.phase === 'app'); + await page.locator('#ota-manual > summary').click(); + await page.evaluate(() => refreshOtaProgress()); + assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false); + await expectIndependentProgress(page, true); + state.app = 'pending_reboot'; + await page.evaluate(() => refreshOtaProgress()); + if (includeWww) { + await expectIndependentProgress(page, true); + state.www = 'completed'; + state.wwwBusy = false; + await page.evaluate(() => refreshOtaProgress()); + } + await expectIndependentProgress(page, false); + const wwwStarts = calls.filter(call => call.endpoint === 'ota/www/start' || call.endpoint === 'ota/www/start_sdcard'); + assert.equal(wwwStarts.length, includeWww ? 1 : 0); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1); + assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false); + }); + }); + } + test(`${language}: repeated SD WWW retries preserve the source until the successful write`, async () => { await otaPage(language, 'manual-retry', async ({page, calls, state}) => { await manualInput(page, 'SD card', true); @@ -212,9 +275,54 @@ for (const language of ['zh-CN', 'en-US']) { }); }); + for (const includeWww of [true, false]) { + test(`${language}: delayed abort refresh leaves the next SD ${includeWww ? 'both-image' : 'firmware-only'} polling active`, async () => { + await otaPage(language, 'manual-delayed-abort', async ({page, calls, state, gate, reached}) => { + await manualInput(page, 'URL', false); + await page.locator('[onclick="otaFromUrl()"]').click(); + // Hold the real metadata request made after acknowledging abort. + await page.route('**/api/v1/ota/server/get', async route => { + reached.resolve(); + await gate.promise; + await route.fulfill({json: {code: 0, data: {url: 'https://ota.fixture.invalid'}}}); + }); + await page.evaluate(() => { + const originalAbort = abortOta; + abortOta = () => (window.otaTestAbort = originalAbort()); + }); + await page.locator('#ota-abort-btn').click(); + await kit.confirmSheet(page); + await reached.promise; + await page.waitForFunction(() => otaUpgrade.phase === 'done'); + await manualInput(page, 'SD card', includeWww); + await page.locator('[onclick="otaFromFile()"]').click(); + await page.waitForFunction(() => otaUpgrade.phase === 'app' && refreshInterval !== null); + const nextTimer = await page.evaluate(() => refreshInterval); + gate.resolve(); + await page.evaluate(() => window.otaTestAbort); + assert.equal(await page.evaluate(() => refreshInterval), nextTimer); + const wwwReply = includeWww ? page.waitForResponse(response => response.url().endsWith('/api/v1/ota/www/start_sdcard')) : null; + state.app = 'pending_reboot'; + if (includeWww) { + await wwwReply; + await page.waitForFunction(() => otaUpgrade.phase === 'www'); + assert.equal(calls.filter(call => call.endpoint === 'ota/www/start_sdcard').length, 1); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); + state.www = 'completed'; + state.wwwBusy = false; + } + await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('ui.allUpgradeComplete')); + assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1); + assert.equal(calls.filter(call => call.endpoint === 'ota/www/start_sdcard').length, includeWww ? 1 : 0); + }); + }); + } + test(`${language}: real OTA page keeps one owner while WWW download is slow`, async () => { await otaPage(language, 'download-paused', async ({page, calls, reached, gate, state}) => { await reached.promise; + assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false); + await expectIndependentProgress(page, false); await page.waitForTimeout(1500); assert.equal(calls.filter(call => call.endpoint === 'ota/www/start').length, 0); assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0); diff --git a/tests/prompts/ota.test.cjs b/tests/prompts/ota.test.cjs index 29c1170..32d07df 100644 --- a/tests/prompts/ota.test.cjs +++ b/tests/prompts/ota.test.cjs @@ -399,6 +399,47 @@ test('manual abort releases the upgrade and allows the next attempt', async () = assert.equal(d.count('system.reboot'), 1); }); +for (const includeWww of [true, false]) { + test(`a delayed abort refresh cannot stop the next ${includeWww ? 'two-image' : 'firmware-only'} upgrade`, async () => { + const d = await device('zh-CN', includeWww); + const refresh = deferred(), reached = deferred(); + d.h.ctx.abortRefresh = () => { reached.resolve(); return refresh.promise; }; + d.h.run('confirmAction = async () => true; refreshOtaInfo = abortRefresh'); + await d.h.run('otaFromUrl()'); + const oldTimer = d.h.run('refreshInterval'); + const abort = d.h.run('abortOta()'); + await reached.promise; + assert.equal(d.h.timers.has(oldTimer), false); + await d.h.run('otaFromFile()'); + const nextTimer = d.h.run('refreshInterval'); + assert(d.h.timers.has(nextTimer)); + refresh.resolve(); + await abort; + assert.equal(d.h.run('refreshInterval'), nextTimer); + assert(d.h.timers.has(nextTimer)); + d.state.app = 'pending_reboot'; + await d.h.advance(1000); + if (includeWww) { + assert.equal(d.count('ota.www.start_sdcard'), 1); + assert.equal(d.count('system.reboot'), 0); + d.state.www = 'completed'; + d.state.wwwBusy = false; + await d.h.advance(1000); + } else assert.equal(d.count('ota.www.start_sdcard'), 0); + assert.equal(d.count('system.reboot'), 1); + }); +} + +test('abort without a local attempt still stops the existing page polling', async () => { + const d = await device(); + d.h.run('confirmAction = async () => true; refreshOtaInfo = async () => {}; refreshInterval = setInterval(refreshOtaProgress, 1000)'); + const timer = d.h.run('refreshInterval'); + await d.h.run('abortOta()'); + assert.equal(d.count('ota.abort'), 1); + assert.equal(d.h.timers.has(timer), false); + assert.equal(d.h.run('refreshInterval'), null); +}); + test('WWW completion cannot reboot while the WWW writer is still running', async () => { const d = await device(); await d.h.run('otaFromUrl()');