Skip to content

Commit 5846d20

Browse files
committed
feat(downloader): report release download progress via listener
Stream the release asset in chunks and notify a DownloadProgressListener with bytes read and total size, so clients can render a real progress bar instead of an indefinite spinner. The existing downloadIfNeeded overload keeps working via a no-op listener. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV
1 parent 68d88ce commit 5846d20

3 files changed

Lines changed: 132 additions & 6 deletions

File tree

src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,24 @@ public Optional<String> installedVersion() {
148148
* @throws IOException если сервер не установлен и его не удалось скачать
149149
*/
150150
public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOException {
151+
return downloadIfNeeded(channel, DownloadProgressListener.NONE);
152+
}
153+
154+
/**
155+
* Скачивает сервер при необходимости и возвращает путь к его исполняемому файлу, сообщая о
156+
* прогрессе скачивания ассета.
157+
*
158+
* <p>Поведение совпадает с {@link #downloadIfNeeded(BslLanguageServerReleaseChannel)}; отличие —
159+
* в передаче {@code progressListener}, который вызывается по мере вычитывания тела ответа
160+
* (только когда действительно происходит скачивание новой версии).
161+
*
162+
* @param channel канал релизов (стабильный / pre-release)
163+
* @param progressListener слушатель прогресса скачивания ассета
164+
* @return путь к исполняемому файлу сервера
165+
* @throws IOException если сервер не установлен и его не удалось скачать
166+
*/
167+
public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel,
168+
DownloadProgressListener progressListener) throws IOException {
151169
var installed = installedVersion().orElse(null);
152170

153171
if (installed != null && !checkIntervalElapsed()) {
@@ -173,7 +191,7 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOE
173191
if (installed == null || compareVersions(latestVersion, installed) > 0) {
174192
LOGGER.info("Downloading BSL Language Server {}", latestVersion);
175193
try {
176-
downloadAndExtract(release, latestVersion);
194+
downloadAndExtract(release, latestVersion, progressListener);
177195
cleanupOtherVersions(latestVersion);
178196
installed = latestVersion;
179197
} catch (IOException e) {
@@ -234,7 +252,8 @@ private GHRelease latestRelease(BslLanguageServerReleaseChannel channel) throws
234252
return release;
235253
}
236254

237-
private void downloadAndExtract(GHRelease release, String version) throws IOException {
255+
private void downloadAndExtract(GHRelease release, String version,
256+
DownloadProgressListener progressListener) throws IOException {
238257
var assetName = assetName();
239258
var downloadUrl = release.listAssets().toList().stream()
240259
.filter(asset -> asset.getName().equals(assetName))
@@ -248,15 +267,16 @@ private void downloadAndExtract(GHRelease release, String version) throws IOExce
248267
var versionDir = installDir.resolve(version);
249268

250269
try {
251-
download(downloadUrl, archive);
270+
download(downloadUrl, archive, progressListener);
252271
deleteRecursively(versionDir);
253272
extract(archive, versionDir);
254273
} finally {
255274
Files.deleteIfExists(archive);
256275
}
257276
}
258277

259-
private void download(String url, Path destination) throws IOException {
278+
private void download(String url, Path destination, DownloadProgressListener progressListener)
279+
throws IOException {
260280
var client = HttpClient.newBuilder()
261281
.followRedirects(HttpClient.Redirect.NORMAL)
262282
.connectTimeout(CONNECT_TIMEOUT)
@@ -267,16 +287,35 @@ private void download(String url, Path destination) throws IOException {
267287
.GET()
268288
.build();
269289
try {
270-
var response = client.send(request, HttpResponse.BodyHandlers.ofFile(destination));
290+
var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
271291
if (response.statusCode() != 200) {
272-
throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode());
292+
try (var ignored = response.body()) {
293+
throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode());
294+
}
273295
}
296+
var totalBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
297+
copyToFile(response.body(), destination, totalBytes, progressListener);
274298
} catch (InterruptedException e) {
275299
Thread.currentThread().interrupt();
276300
throw new IOException("BSL Language Server download was interrupted", e);
277301
}
278302
}
279303

304+
static void copyToFile(InputStream source, Path destination, long totalBytes,
305+
DownloadProgressListener progressListener) throws IOException {
306+
var buffer = new byte[16 * 1024];
307+
long readTotal = 0;
308+
progressListener.onProgress(0, totalBytes);
309+
try (var input = source; var output = Files.newOutputStream(destination)) {
310+
int read;
311+
while ((read = input.read(buffer)) != -1) {
312+
output.write(buffer, 0, read);
313+
readTotal += read;
314+
progressListener.onProgress(readTotal, totalBytes);
315+
}
316+
}
317+
}
318+
280319
private static void extract(Path archive, Path targetDir) throws IOException {
281320
Files.createDirectories(targetDir);
282321
try (var zip = ZipFile.builder().setPath(archive).get()) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* This file is a part of 1c-syntax utils.
3+
*
4+
* Copyright (c) 2018-2026
5+
* Alexey Sosnoviy <labotamy@gmail.com>, Nikita Fedkin <nixel2007@gmail.com> and contributors
6+
*
7+
* SPDX-License-Identifier: LGPL-3.0-or-later
8+
*
9+
* 1c-syntax utils is free software; you can redistribute it and/or
10+
* modify it under the terms of the GNU Lesser General Public
11+
* License as published by the Free Software Foundation; either
12+
* version 3.0 of the License, or (at your option) any later version.
13+
*
14+
* 1c-syntax utils is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17+
* Lesser General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Lesser General Public
20+
* License along with 1c-syntax utils.
21+
*/
22+
package com.github._1c_syntax.utils.downloader;
23+
24+
/**
25+
* Слушатель прогресса скачивания ассета релиза.
26+
*
27+
* <p>Вызывается загрузчиком по мере вычитывания тела ответа: сначала один раз с нулём вычитанных
28+
* байт, затем после каждого прочитанного блока. Позволяет клиенту (например, IntelliJ-плагину)
29+
* рисовать прогресс-бар вместо неопределённого «крутящегося» индикатора.
30+
*
31+
* <p>Реализация может бросить {@link RuntimeException} (в частности, для отмены операции) —
32+
* исключение пробрасывается наружу через вызов загрузки, а частично скачанный файл удаляется.
33+
*/
34+
@FunctionalInterface
35+
public interface DownloadProgressListener {
36+
37+
/**
38+
* Слушатель, ничего не делающий — значение по умолчанию, когда прогресс не нужен.
39+
*/
40+
DownloadProgressListener NONE = (bytesRead, totalBytes) -> {
41+
// no-op
42+
};
43+
44+
/**
45+
* @param bytesRead сколько байт ассета уже скачано
46+
* @param totalBytes полный размер ассета в байтах или {@code -1}, если сервер его не сообщил
47+
*/
48+
void onProgress(long bytesRead, long totalBytes);
49+
}

src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@
2626
import org.junit.jupiter.api.condition.OS;
2727
import org.junit.jupiter.api.io.TempDir;
2828

29+
import java.io.ByteArrayInputStream;
2930
import java.io.IOException;
31+
import java.nio.charset.StandardCharsets;
3032
import java.nio.file.Files;
3133
import java.nio.file.Path;
34+
import java.util.ArrayList;
35+
import java.util.List;
3236

3337
import static org.assertj.core.api.Assertions.assertThat;
3438

@@ -82,6 +86,40 @@ void installedBinaryResolvesLinuxLauncher(@TempDir Path installDir) throws IOExc
8286
assertThat(downloader.installedBinary()).contains(binary);
8387
}
8488

89+
@Test
90+
void copyToFileWritesContentAndReportsProgress(@TempDir Path installDir) throws IOException {
91+
var payload = "bsl-language-server".getBytes(StandardCharsets.UTF_8);
92+
var destination = installDir.resolve("asset.bin");
93+
var progress = new ArrayList<long[]>();
94+
95+
BslLanguageServerDownloader.copyToFile(
96+
new ByteArrayInputStream(payload),
97+
destination,
98+
payload.length,
99+
(bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes}));
100+
101+
assertThat(Files.readAllBytes(destination)).isEqualTo(payload);
102+
assertThat(progress).isNotEmpty();
103+
assertThat(progress.get(0)).containsExactly(0, payload.length);
104+
var last = progress.get(progress.size() - 1);
105+
assertThat(last).containsExactly(payload.length, payload.length);
106+
assertThat(last[0]).isLessThanOrEqualTo(last[1]);
107+
}
108+
109+
@Test
110+
void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOException {
111+
var destination = installDir.resolve("asset.bin");
112+
List<Long> totals = new ArrayList<>();
113+
114+
BslLanguageServerDownloader.copyToFile(
115+
new ByteArrayInputStream(new byte[]{1, 2, 3}),
116+
destination,
117+
-1,
118+
(bytesRead, totalBytes) -> totals.add(totalBytes));
119+
120+
assertThat(totals).isNotEmpty().allMatch(total -> total == -1);
121+
}
122+
85123
private static void writeServerInfo(Path installDir, String version) throws IOException {
86124
Files.createDirectories(installDir);
87125
Files.writeString(installDir.resolve("SERVER-INFO"),

0 commit comments

Comments
 (0)