diff --git a/backend/FwLite/FwLiteMaui.Tests/UpdateDownloadProxyTests.cs b/backend/FwLite/FwLiteMaui.Tests/UpdateDownloadProxyTests.cs new file mode 100644 index 0000000000..316a247624 --- /dev/null +++ b/backend/FwLite/FwLiteMaui.Tests/UpdateDownloadProxyTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace FwLiteMaui.Tests; + +#if WINDOWS +public class UpdateDownloadProxyTests +{ + // Deterministic payload the fake upstream serves, with byte-range support. + private static readonly byte[] Payload = Enumerable.Range(0, 100_000).Select(i => (byte)(i % 251)).ToArray(); + + [Fact] + public async Task ForwardsFullDownloadAndReportsByteTotal() + { + await using var upstream = new FakeUpstream(Payload); + long reported = 0; + await using var proxy = await UpdateDownloadProxy.StartAsync(upstream.Url, NullLogger.Instance, total => reported = total); + + using var client = new HttpClient(); + var body = await client.GetByteArrayAsync(proxy.LocalUri); + + body.Should().Equal(Payload); + reported.Should().Be(Payload.Length); + } + + [Fact] + public async Task ForwardsRangeRequestAsPartialContent() + { + await using var upstream = new FakeUpstream(Payload); + await using var proxy = await UpdateDownloadProxy.StartAsync(upstream.Url, NullLogger.Instance, _ => { }); + + using var client = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, proxy.LocalUri); + request.Headers.Range = new RangeHeaderValue(10, 19); + using var response = await client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.PartialContent); + response.Content.Headers.ContentRange!.From.Should().Be(10); + response.Content.Headers.ContentRange!.To.Should().Be(19); + var body = await response.Content.ReadAsByteArrayAsync(); + body.Should().Equal(Payload[10..20]); + } + + [Fact] + public async Task ReturnsNotFoundForUnknownPath() + { + await using var upstream = new FakeUpstream(Payload); + await using var proxy = await UpdateDownloadProxy.StartAsync(upstream.Url, NullLogger.Instance, _ => { }); + + var wrongPath = new Uri(proxy.LocalUri, "wrong"); + using var client = new HttpClient(); + using var response = await client.GetAsync(wrongPath); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + /// Minimal HTTP origin that serves a byte array with Range/HEAD support. + private sealed class FakeUpstream : IAsyncDisposable + { + private readonly HttpListener _listener = new(); + private readonly byte[] _payload; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _loop; + + public string Url { get; } + + public FakeUpstream(byte[] payload) + { + _payload = payload; + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + + Url = $"http://localhost:{port}/asset"; + _listener.Prefixes.Add($"http://localhost:{port}/"); + _listener.Start(); + _loop = Task.Run(AcceptLoopAsync); + } + + private async Task AcceptLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync(); } + catch { break; } + _ = Task.Run(() => Handle(ctx)); + } + } + + private async Task Handle(HttpListenerContext ctx) + { + try + { + ctx.Response.Headers["Accept-Ranges"] = "bytes"; + var rangeHeader = ctx.Request.Headers["Range"]; + if (!string.IsNullOrEmpty(rangeHeader) && RangeHeaderValue.TryParse(rangeHeader, out var range)) + { + var from = (int)(range.Ranges.First().From ?? 0); + var to = (int)(range.Ranges.First().To ?? _payload.Length - 1); + var length = to - from + 1; + ctx.Response.StatusCode = (int)HttpStatusCode.PartialContent; + ctx.Response.Headers["Content-Range"] = $"bytes {from}-{to}/{_payload.Length}"; + ctx.Response.ContentLength64 = length; + if (ctx.Request.HttpMethod != "HEAD") + await ctx.Response.OutputStream.WriteAsync(_payload.AsMemory(from, length)); + } + else + { + ctx.Response.StatusCode = (int)HttpStatusCode.OK; + ctx.Response.ContentLength64 = _payload.Length; + if (ctx.Request.HttpMethod != "HEAD") + await ctx.Response.OutputStream.WriteAsync(_payload); + } + } + finally + { + ctx.Response.Close(); + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + try { _listener.Stop(); } catch { /* ignore */ } +#pragma warning disable VSTHRD003 // _loop is our own Task.Run started in the ctor + try { await _loop; } catch { /* ignore */ } +#pragma warning restore VSTHRD003 + ((IDisposable)_listener).Dispose(); + _cts.Dispose(); + } + } +} +#endif diff --git a/backend/FwLite/FwLiteMaui/Platforms/Windows/AppUpdateService.cs b/backend/FwLite/FwLiteMaui/Platforms/Windows/AppUpdateService.cs index 4c052791cb..8665e5d2ef 100644 --- a/backend/FwLite/FwLiteMaui/Platforms/Windows/AppUpdateService.cs +++ b/backend/FwLite/FwLiteMaui/Platforms/Windows/AppUpdateService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using Windows.Management.Deployment; using Windows.Networking.Connectivity; @@ -106,8 +107,28 @@ public async Task ApplyUpdate(FwLiteRelease latestRelease) private async Task ApplyUpdate(FwLiteRelease latestRelease, bool quitOnUpdate) { logger.LogInformation("Installing new version: {Version}, Current version: {CurrentVersion}", latestRelease.Version, AppVersion.Version); + ShowUpdateInstallingNotification(latestRelease); + + // Preferred path: download through a loopback proxy so we can report real download + // progress. On any failure fall back to handing PackageManager the GitHub URL directly, + // which is what we did before this change — we must never regress update capability. + try + { + var progress = new DownloadProgressReporter(eventBus, latestRelease); + await using var proxy = await UpdateDownloadProxy.StartAsync(latestRelease.Url, logger, progress.Report); + return await Deploy(proxy.LocalUri, latestRelease, quitOnUpdate); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Proxy update path failed; falling back to direct install"); + return await Deploy(new Uri(latestRelease.Url), latestRelease, quitOnUpdate); + } + } + + private async Task Deploy(Uri packageUri, FwLiteRelease latestRelease, bool quitOnUpdate) + { var packageManager = new PackageManager(); - var asyncOperation = packageManager.AddPackageByUriAsync(new Uri(latestRelease.Url), + var asyncOperation = packageManager.AddPackageByUriAsync(packageUri, new AddPackageOptions() { DeferRegistrationWhenPackagesAreInUse = true, @@ -116,15 +137,13 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease, bool q }); asyncOperation.Progress = (info, progressInfo) => { - NotifyInstallProgress(progressInfo.percentage, latestRelease); if (progressInfo.state == DeploymentProgressState.Queued) { logger.LogInformation("Queued update"); return; } - logger.LogInformation("Downloading update: {ProgressPercentage}%", progressInfo.percentage); + logger.LogInformation("Deploying update: {ProgressPercentage}%", progressInfo.percentage); }; - ShowUpdateInstallingNotification(latestRelease); //note this asyncOperation is not reliable, it's possible the update will install and this will never resolve var updateTask = asyncOperation.AsTask(); @@ -145,9 +164,29 @@ private async Task ApplyUpdate(FwLiteRelease latestRelease, bool q return UpdateResult.Started; } - private void NotifyInstallProgress(uint percentage, FwLiteRelease release) + // Turns the proxy's running byte total into throttled progress events (bytes + speed). + // The JsEventListener channel is small (size 10) so we cap emission at ~4/sec. + private sealed class DownloadProgressReporter(GlobalEventBus eventBus, FwLiteRelease release) { - eventBus.PublishEvent(new AppUpdateProgressEvent(percentage, release)); + private static readonly TimeSpan MinInterval = TimeSpan.FromMilliseconds(250); + private readonly long _startTimestamp = Stopwatch.GetTimestamp(); + private readonly Lock _lock = new(); + private long _lastEmitTimestamp; + + public void Report(long totalBytesDownloaded) + { + var now = Stopwatch.GetTimestamp(); + lock (_lock) + { + if (_lastEmitTimestamp != 0 && Stopwatch.GetElapsedTime(_lastEmitTimestamp, now) < MinInterval) + return; + _lastEmitTimestamp = now; + } + + var elapsedSeconds = Stopwatch.GetElapsedTime(_startTimestamp, now).TotalSeconds; + var bytesPerSecond = elapsedSeconds > 0 ? totalBytesDownloaded / elapsedSeconds : 0; + eventBus.PublishEvent(new AppUpdateProgressEvent(totalBytesDownloaded, bytesPerSecond, release)); + } } public DateTime LastUpdateCheck diff --git a/backend/FwLite/FwLiteMaui/Platforms/Windows/UpdateDownloadProxy.cs b/backend/FwLite/FwLiteMaui/Platforms/Windows/UpdateDownloadProxy.cs new file mode 100644 index 0000000000..4222867311 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Windows/UpdateDownloadProxy.cs @@ -0,0 +1,241 @@ +using System.Buffers; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using Microsoft.Extensions.Logging; + +namespace FwLiteMaui; + +/// +/// A short-lived loopback reverse proxy that sits between the Windows +/// and the GitHub release asset. +/// +/// Windows still owns the download/staging/install (via AddPackageByUriAsync), but by pointing +/// it at this proxy we get to observe every byte it downloads — including the HTTP Range requests +/// it uses for differential updates — so we can report real download progress. It also resolves +/// GitHub's 302 redirect once up front so range requests hit the terminal asset host directly. +/// +public sealed class UpdateDownloadProxy : IAsyncDisposable +{ + private readonly HttpListener _listener; + private readonly HttpClient _upstream; + private readonly string _originalUrl; + private readonly ILogger _logger; + private readonly Action _onBytes; + private readonly CancellationTokenSource _cts = new(); + private readonly Lock _urlLock = new(); + private string _finalUrl; + private Task? _acceptLoop; + private long _bytesServed; + + public Uri LocalUri { get; } + + private UpdateDownloadProxy(HttpListener listener, + HttpClient upstream, + string originalUrl, + string finalUrl, + Uri localUri, + ILogger logger, + Action onBytes) + { + _listener = listener; + _upstream = upstream; + _originalUrl = originalUrl; + _finalUrl = finalUrl; + LocalUri = localUri; + _logger = logger; + _onBytes = onBytes; + } + + /// + /// Starts the proxy. Hand to PackageManager.AddPackageByUriAsync. + /// is invoked with the running total of bytes streamed to Windows. + /// + public static async Task StartAsync(string githubUrl, + ILogger logger, + Action onBytes, + CancellationToken cancellationToken = default) + { + // One keep-alive client for both redirect resolution and streaming; no auto-redirect so + // range requests go straight to the terminal asset host. + var upstream = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false }) + { + Timeout = Timeout.InfiniteTimeSpan + }; + upstream.DefaultRequestHeaders.UserAgent.ParseAdd("Fieldworks-Lite-UpdateProxy"); + + try + { + var finalUrl = await ResolveFinalUrl(upstream, githubUrl, cancellationToken); + + var port = GetFreeLoopbackPort(); + // A random path segment so no other local process can stumble onto the proxy. + var pathSegment = Guid.NewGuid().ToString("N"); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/{pathSegment}/"); + listener.Start(); + + var localUri = new Uri($"http://localhost:{port}/{pathSegment}/download"); + var proxy = new UpdateDownloadProxy(listener, upstream, githubUrl, finalUrl, localUri, logger, onBytes); + proxy._acceptLoop = Task.Run(() => proxy.AcceptLoopAsync(localUri.AbsolutePath)); + logger.LogInformation("Update download proxy listening on {LocalUri}", localUri); + return proxy; + } + catch + { + upstream.Dispose(); + throw; + } + } + + private async Task AcceptLoopAsync(string expectedPath) + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch (Exception) when (_cts.IsCancellationRequested) + { + break; + } + catch (HttpListenerException) + { + break; + } + + // Windows overlaps range requests, so handle each concurrently. + _ = Task.Run(() => HandleRequestAsync(context, expectedPath)); + } + } + + private async Task HandleRequestAsync(HttpListenerContext context, string expectedPath) + { + try + { + if (!string.Equals(context.Request.Url?.AbsolutePath, expectedPath, StringComparison.Ordinal)) + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + await ForwardAsync(context, allowReresolve: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Update proxy request failed"); + try { context.Response.StatusCode = (int)HttpStatusCode.BadGateway; } + catch { /* response may already be committed */ } + } + finally + { + try { context.Response.Close(); } + catch { /* ignore */ } + } + } + + private async Task ForwardAsync(HttpListenerContext context, bool allowReresolve) + { + var method = context.Request.HttpMethod; + using var upstreamRequest = new HttpRequestMessage(new HttpMethod(method), CurrentUrl()); + var rangeHeader = context.Request.Headers["Range"]; + if (!string.IsNullOrEmpty(rangeHeader) && RangeHeaderValue.TryParse(rangeHeader, out var range)) + upstreamRequest.Headers.Range = range; + + using var upstreamResponse = + await _upstream.SendAsync(upstreamRequest, HttpCompletionOption.ResponseHeadersRead, _cts.Token); + + // The signed asset URL can expire; re-resolve from the original GitHub URL and retry once. + if (allowReresolve && upstreamResponse.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + _logger.LogInformation("Upstream returned {Status}; re-resolving asset URL", upstreamResponse.StatusCode); + var refreshed = await ResolveFinalUrl(_upstream, _originalUrl, _cts.Token); + lock (_urlLock) _finalUrl = refreshed; + await ForwardAsync(context, allowReresolve: false); + return; + } + + context.Response.StatusCode = (int)upstreamResponse.StatusCode; + context.Response.KeepAlive = true; + if (upstreamResponse.Content.Headers.ContentLength is { } contentLength) + context.Response.ContentLength64 = contentLength; + if (upstreamResponse.Headers.AcceptRanges.Count > 0) + context.Response.Headers["Accept-Ranges"] = string.Join(",", upstreamResponse.Headers.AcceptRanges); + if (upstreamResponse.Content.Headers.ContentRange is { } contentRange) + context.Response.Headers["Content-Range"] = contentRange.ToString(); + + if (method == "HEAD") return; + + await using var body = await upstreamResponse.Content.ReadAsStreamAsync(_cts.Token); + var buffer = ArrayPool.Shared.Rent(81920); + try + { + int read; + while ((read = await body.ReadAsync(buffer, _cts.Token)) > 0) + { + await context.Response.OutputStream.WriteAsync(buffer.AsMemory(0, read), _cts.Token); + _onBytes(Interlocked.Add(ref _bytesServed, read)); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private string CurrentUrl() + { + lock (_urlLock) return _finalUrl; + } + + private static async Task ResolveFinalUrl(HttpClient client, string url, CancellationToken cancellationToken) + { + for (var hop = 0; hop < 5; hop++) + { + using var request = new HttpRequestMessage(HttpMethod.Head, url); + using var response = await client.SendAsync(request, cancellationToken); + if ((int)response.StatusCode is >= 300 and < 400 && response.Headers.Location is { } location) + { + url = location.IsAbsoluteUri ? location.ToString() : new Uri(new Uri(url), location).ToString(); + continue; + } + + break; + } + + return url; + } + + private static int GetFreeLoopbackPort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + try + { + return ((IPEndPoint)probe.LocalEndpoint).Port; + } + finally + { + probe.Stop(); + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + try { _listener.Stop(); } catch { /* ignore */ } + if (_acceptLoop is not null) + { + // Safe to await: _acceptLoop is our own Task.Run started in StartAsync. +#pragma warning disable VSTHRD003 + try { await _acceptLoop; } catch { /* ignore */ } +#pragma warning restore VSTHRD003 + } + + ((IDisposable)_listener).Dispose(); + _upstream.Dispose(); + _cts.Dispose(); + } +} diff --git a/backend/FwLite/FwLiteShared/Events/AppUpdateProgressEvent.cs b/backend/FwLite/FwLiteShared/Events/AppUpdateProgressEvent.cs index 419aabc511..2bf7bfcdec 100644 --- a/backend/FwLite/FwLiteShared/Events/AppUpdateProgressEvent.cs +++ b/backend/FwLite/FwLiteShared/Events/AppUpdateProgressEvent.cs @@ -2,9 +2,10 @@ namespace FwLiteShared.Events; -public class AppUpdateProgressEvent(uint percentage, FwLiteRelease release) : IFwEvent +public class AppUpdateProgressEvent(long bytesDownloaded, double bytesPerSecond, FwLiteRelease release) : IFwEvent { - public uint Percentage { get; } = percentage; + public long BytesDownloaded { get; } = bytesDownloaded; + public double BytesPerSecond { get; } = bytesPerSecond; public FwLiteRelease Release { get; } = release; public FwEventType Type => FwEventType.AppUpdateProgress; public bool IsGlobal => true; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IAppUpdateProgressEvent.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IAppUpdateProgressEvent.ts index 9287a24369..f21c6df79e 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IAppUpdateProgressEvent.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/IAppUpdateProgressEvent.ts @@ -9,7 +9,8 @@ import type {FwEventType} from './FwEventType'; export interface IAppUpdateProgressEvent extends IFwEvent { - percentage: number; + bytesDownloaded: number; + bytesPerSecond: number; release: IFwLiteRelease; type: FwEventType; isGlobal: boolean; diff --git a/frontend/viewer/src/lib/updates/UpdateDialog.svelte b/frontend/viewer/src/lib/updates/UpdateDialog.svelte index 2165a019dd..e6c3f2f1e5 100644 --- a/frontend/viewer/src/lib/updates/UpdateDialog.svelte +++ b/frontend/viewer/src/lib/updates/UpdateDialog.svelte @@ -20,9 +20,9 @@ let installPromise = $state>(); const eventBus = useEventBus(); - let installProgress = $state(); + let downloadProgress = $state<{bytesDownloaded: number; bytesPerSecond: number}>(); eventBus.onEventType(FwEventType.AppUpdateProgress, event => { - installProgress = event.percentage; + downloadProgress = {bytesDownloaded: event.bytesDownloaded, bytesPerSecond: event.bytesPerSecond}; }); watch(() => open, () => { @@ -33,7 +33,7 @@ }); async function installUpdate(update: IAvailableUpdate) { - installProgress = undefined; + downloadProgress = undefined; installPromise = updateService.applyUpdate(update); try { const updateResult = await installPromise; @@ -68,7 +68,7 @@ {checkPromise} {installPromise} {installUpdate} - {installProgress} /> + {downloadProgress} />
; installPromise?: Promise; installUpdate: (update: IAvailableUpdate) => Promise; - installProgress?: number; + downloadProgress?: {bytesDownloaded: number; bytesPerSecond: number}; } let { checkPromise, installPromise, installUpdate, - installProgress + downloadProgress }: Props = $props(); + function formatBytes(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; + } {#if checkPromise} @@ -47,9 +57,12 @@ {#if installPromise} {#await installPromise} {:then updateResult}
diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 66d42de9d2..ee79711d2b 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -747,6 +747,10 @@ msgstr "Downloaded {0}" msgid "Downloading {0}..." msgstr "Downloading {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "Downloading update..." + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index a21e5bb4c0..e429804727 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -752,6 +752,10 @@ msgstr "Descargado {0}" msgid "Downloading {0}..." msgstr "Descargando {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 49c34eb17f..4f76de3eb2 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -752,6 +752,10 @@ msgstr "Téléchargé {0}" msgid "Downloading {0}..." msgstr "Téléchargement en cours {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 2c503d0f62..c5db67e49a 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -752,6 +752,10 @@ msgstr "Diunduh {0}" msgid "Downloading {0}..." msgstr "Mengunduh {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index d2561a8ee1..d61fdf1f09 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -752,6 +752,10 @@ msgstr "다운로드 {0}" msgid "Downloading {0}..." msgstr "다운로드 {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index cbf8010b54..387ef95348 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -752,6 +752,10 @@ msgstr "Telah memuat turun {0}" msgid "Downloading {0}..." msgstr "Memuat turun {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 196528f983..c9e905c53d 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -752,6 +752,10 @@ msgstr "Kupakuwa {0}" msgid "Downloading {0}..." msgstr "Inapakua {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 385767a47f..ae34ab5caa 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -752,6 +752,10 @@ msgstr "Đã tải xuống {0}" msgid "Downloading {0}..." msgstr "Đang tải xuống {0}..." +#: src/lib/updates/UpdateDialogContent.svelte +msgid "Downloading update..." +msgstr "" + #. Generic progress message #: src/home/Server.svelte msgid "Downloading..." diff --git a/frontend/viewer/src/stories/updates/update-dialog-content.stories.svelte b/frontend/viewer/src/stories/updates/update-dialog-content.stories.svelte index 1d72721045..6abc909989 100644 --- a/frontend/viewer/src/stories/updates/update-dialog-content.stories.svelte +++ b/frontend/viewer/src/stories/updates/update-dialog-content.stories.svelte @@ -100,7 +100,7 @@ {...args} checkPromise={completedCheck(autoUpdate)} installPromise={pendingInstall()} - installProgress={86} + downloadProgress={{bytesDownloaded: 12_900_000, bytesPerSecond: 2_100_000}} />
{/snippet}