diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml
index ae26a95..cec38e5 100644
--- a/launcher/launcher/pom.xml
+++ b/launcher/launcher/pom.xml
@@ -112,6 +112,12 @@
ui/**
+
+ src/main/resources
+
+ offline.zip
+
+
@@ -257,6 +263,39 @@
+
+
+ l4j-online-setup
+ package
+ launch4j
+
+ ../../server/builds/ZernMC-Online-Setup-${project.version}.${hotfix}.exe
+ ../../server/builds/zernmclauncher.jar
+ ${project.basedir}/src/main/icons/zernmc.ico
+ gui
+ false
+ me.sashegdev.zernmc.launcher.installer.Installerfalse
+ lib/jre2121
+ ${project.version}.${hotfix}${project.version}.${hotfix}ZernMC Online SetupCopyright (c) 2023-2026 ZernMC${project.version}.${hotfix}${project.version}.${hotfix}ZernMCZernMCzernmc-online-setupZernMC-Online-Setup.exe
+
+
+
+
+ l4j-offline-setup
+ package
+ launch4j
+
+ ../../server/builds/ZernMC-Offline-Setup-${project.version}.${hotfix}.exe
+ ../../server/builds/zernmclauncher.jar
+ ${project.basedir}/src/main/icons/zernmc.ico
+ gui
+ false
+ me.sashegdev.zernmc.launcher.installer.Installerfalse
+ --offline
+ lib/jre2121
+ ${project.version}.${hotfix}${project.version}.${hotfix}ZernMC Offline SetupCopyright (c) 2023-2026 ZernMC${project.version}.${hotfix}${project.version}.${hotfix}ZernMCZernMCzernmc-offline-setupZernMC-Offline-Setup.exe
+
+
diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/installer/Installer.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/installer/Installer.java
new file mode 100644
index 0000000..d8c5613
--- /dev/null
+++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/installer/Installer.java
@@ -0,0 +1,469 @@
+package me.sashegdev.zernmc.launcher.installer;
+
+import javax.swing.*;
+import javax.swing.plaf.basic.BasicProgressBarUI;
+import java.awt.*;
+import java.awt.event.*;
+import java.io.*;
+import java.net.*;
+import java.nio.file.*;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import com.google.gson.*;
+
+public class Installer {
+ private static final String BASE_URL = "https://api.zern.cc";
+ private static JFrame frame;
+ private static CardLayout cards;
+ private static JPanel mainPanel;
+ private static JProgressBar progressBar;
+ private static JLabel statusLabel, speedLabel, titleLabel;
+ private static JTextField dirField;
+ private static JCheckBox shortcutCheck;
+ private static String installDir = "C:\\ZernMC";
+ private static boolean isOffline = false;
+ private static final Color bg = new Color(0x0c,0x0c,0x12);
+ private static final Color surface = new Color(0x16,0x16,0x1f);
+ private static final Color accent = new Color(0xe9,0x45,0x60);
+ private static final Color text = new Color(0xee,0xee,0xf0);
+ private static final Color muted = new Color(0x88,0x88,0x9a);
+ private static final Color sidebarBg = new Color(0x10,0x10,0x1e);
+ private static int currentStep = 0;
+ private static JLabel[] stepLabels;
+
+ public static void main(String[] args) throws Exception {
+ for (String a: args) if (a.equals("--offline")) isOffline = true;
+ SwingUtilities.invokeLater(() -> createUI());
+ }
+
+ private static void createUI() {
+ try { UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); } catch(Exception ignored){}
+ frame = new JFrame("ZernMC Setup");
+ frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ frame.setSize(640, 420);
+ frame.setLocationRelativeTo(null);
+ frame.setResizable(false);
+ frame.setUndecorated(false);
+
+ JPanel root = new JPanel(new BorderLayout());
+ root.setBackground(bg);
+
+ JPanel sidebar = new JPanel();
+ sidebar.setBackground(sidebarBg);
+ sidebar.setPreferredSize(new Dimension(180, 420));
+ sidebar.setLayout(new BoxLayout(sidebar, BoxLayout.Y_AXIS));
+ sidebar.setBorder(BorderFactory.createEmptyBorder(24, 18, 24, 18));
+
+ JLabel logo = new JLabel("ZernMC");
+ logo.setFont(new Font("Segoe UI", Font.BOLD, 22));
+ logo.setForeground(text);
+ logo.setAlignmentX(Component.LEFT_ALIGNMENT);
+ sidebar.add(logo);
+ JLabel sub = new JLabel("Setup");
+ sub.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ sub.setForeground(accent);
+ sub.setAlignmentX(Component.LEFT_ALIGNMENT);
+ sidebar.add(sub);
+ sidebar.add(Box.createVerticalStrut(24));
+ JSeparator sep = new JSeparator();
+ sep.setMaximumSize(new Dimension(144,1));
+ sep.setForeground(new Color(0x2a,0x2a,0x3a));
+ sidebar.add(sep);
+ sidebar.add(Box.createVerticalStrut(18));
+
+ String[] steps = {"Приветствие","Папка установки","Установка","Готово"};
+ stepLabels = new JLabel[steps.length];
+ for (int i=0;i System.exit(0));
+ JButton next = new JButton("Далее >");
+ styleNav(next, true);
+ next.addActionListener(e -> onNext());
+ next.putClientProperty("next", true);
+ nav.add(cancel);
+ nav.add(next);
+ content.add(nav, BorderLayout.SOUTH);
+
+ root.add(content, BorderLayout.CENTER);
+ frame.setContentPane(root);
+ frame.setVisible(true);
+ }
+
+ private static void styleNav(JButton b, boolean primary) {
+ b.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ b.setFocusPainted(false);
+ b.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(primary?accent:new Color(0x3a,0x3a,0x4a),1),
+ BorderFactory.createEmptyBorder(6, 16, 6, 16)));
+ b.setBackground(primary?accent:surface);
+ b.setForeground(primary?Color.WHITE:text);
+ b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+ }
+
+ private static JPanel createWelcome() {
+ JPanel p = new JPanel();
+ p.setBackground(surface);
+ p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
+ JLabel t = new JLabel("Добро пожаловать в ZernMC");
+ t.setFont(new Font("Segoe UI", Font.BOLD, 16));
+ t.setForeground(text);
+ t.setAlignmentX(Component.LEFT_ALIGNMENT);
+ p.add(t);
+ p.add(Box.createVerticalStrut(10));
+ JLabel d = new JLabel("Мастер установит ZernMC Launcher на ваш компьютер.
Рекомендуется закрыть другие приложения.
Нажмите «Далее», чтобы продолжить.");
+ d.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ d.setForeground(muted);
+ d.setAlignmentX(Component.LEFT_ALIGNMENT);
+ p.add(d);
+ p.add(Box.createVerticalStrut(12));
+ JLabel note = new JLabel("Онлайн — скачает 47М JRE + 50М лаунчер | Оффлайн — распакует встроенный архив");
+ note.setFont(new Font("Segoe UI", Font.PLAIN, 11));
+ note.setForeground(muted);
+ note.setAlignmentX(Component.LEFT_ALIGNMENT);
+ p.add(note);
+ return p;
+ }
+
+ private static JPanel createDirPanel() {
+ JPanel p = new JPanel();
+ p.setBackground(surface);
+ p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
+ JLabel t = new JLabel("Выберите папку установки");
+ t.setFont(new Font("Segoe UI", Font.BOLD, 14));
+ t.setForeground(text);
+ t.setAlignmentX(Component.LEFT_ALIGNMENT);
+ p.add(t);
+ p.add(Box.createVerticalStrut(12));
+ JPanel row = new JPanel(new BorderLayout(8,0));
+ row.setBackground(surface);
+ row.setMaximumSize(new Dimension(420, 28));
+ row.setAlignmentX(Component.LEFT_ALIGNMENT);
+ dirField = new JTextField(installDir);
+ dirField.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ dirField.setBackground(bg);
+ dirField.setForeground(text);
+ dirField.setCaretColor(text);
+ dirField.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(new Color(0x2a,0x2a,0x3a)), BorderFactory.createEmptyBorder(4,6,4,6)));
+ JButton browse = new JButton("Обзор...");
+ browse.setFont(new Font("Segoe UI", Font.PLAIN, 11));
+ browse.addActionListener(e -> {
+ JFileChooser fc = new JFileChooser();
+ fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
+ fc.setDialogTitle("Куда установить ZernMC");
+ if (fc.showOpenDialog(frame)==JFileChooser.APPROVE_OPTION) {
+ String sel = fc.getSelectedFile().getAbsolutePath();
+ if (!sel.toLowerCase().contains("zernmc")) sel = sel + File.separator + "ZernMC";
+ dirField.setText(sel);
+ }
+ });
+ row.add(dirField, BorderLayout.CENTER);
+ row.add(browse, BorderLayout.EAST);
+ p.add(row);
+ p.add(Box.createVerticalStrut(10));
+ JLabel hint = new JLabel("По умолчанию: C:\\ZernMC • Данные в %USERPROFILE%\\.zernmc");
+ hint.setFont(new Font("Segoe UI", Font.PLAIN, 10));
+ hint.setForeground(muted);
+ hint.setAlignmentX(Component.LEFT_ALIGNMENT);
+ p.add(hint);
+ p.add(Box.createVerticalStrut(12));
+ shortcutCheck = new JCheckBox("Создать ярлык на рабочем столе", true);
+ shortcutCheck.setBackground(surface);
+ shortcutCheck.setForeground(text);
+ shortcutCheck.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ shortcutCheck.setAlignmentX(Component.LEFT_ALIGNMENT);
+ p.add(shortcutCheck);
+ return p;
+ }
+
+ private static JPanel createProgressPanel() {
+ JPanel p = new JPanel();
+ p.setBackground(surface);
+ p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
+ titleLabel = new JLabel("Установка...");
+ titleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ titleLabel.setForeground(muted);
+ titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ p.add(titleLabel);
+ p.add(Box.createVerticalStrut(18));
+ statusLabel = new JLabel("Подготовка...");
+ statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
+ statusLabel.setForeground(text);
+ statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ p.add(statusLabel);
+ p.add(Box.createVerticalStrut(12));
+ progressBar = new JProgressBar(0,100);
+ progressBar.setPreferredSize(new Dimension(400,6));
+ progressBar.setMaximumSize(new Dimension(400,6));
+ progressBar.setAlignmentX(Component.CENTER_ALIGNMENT);
+ progressBar.setBackground(new Color(0x2a,0x2a,0x3a));
+ progressBar.setForeground(accent);
+ progressBar.setBorderPainted(false);
+ progressBar.setUI(new BasicProgressBarUI(){ protected Color getSelectionBackground(){return accent;} protected Color getSelectionForeground(){return accent;} });
+ p.add(progressBar);
+ p.add(Box.createVerticalStrut(6));
+ speedLabel = new JLabel(" ");
+ speedLabel.setFont(new Font("Segoe UI", Font.PLAIN, 11));
+ speedLabel.setForeground(muted);
+ speedLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ p.add(speedLabel);
+ return p;
+ }
+
+ private static JPanel createFinishPanel() {
+ JPanel p = new JPanel();
+ p.setBackground(surface);
+ p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
+ JLabel t = new JLabel("Установка завершена!");
+ t.setFont(new Font("Segoe UI", Font.BOLD, 16));
+ t.setForeground(text);
+ t.setAlignmentX(Component.CENTER_ALIGNMENT);
+ p.add(t);
+ p.add(Box.createVerticalStrut(10));
+ JLabel d = new JLabel("ZernMC Launcher установлен.
Запустите с ярлыка или из C:\\ZernMC\\zernmc.exe
Удаление: C:\\ZernMC\\uninstall.exe");
+ d.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ d.setForeground(muted);
+ d.setAlignmentX(Component.CENTER_ALIGNMENT);
+ p.add(d);
+ p.add(Box.createVerticalStrut(18));
+ JButton launch = new JButton("Запустить ZernMC");
+ styleNav(launch, true);
+ launch.setAlignmentX(Component.CENTER_ALIGNMENT);
+ launch.addActionListener(e -> {
+ try { Runtime.getRuntime().exec(new String[]{dirField.getText()+File.separator+"zernmc.exe"}); } catch(Exception ignored){}
+ System.exit(0);
+ });
+ p.add(launch);
+ return p;
+ }
+
+ private static void onNext() {
+ if (currentStep==0) { currentStep=1; updateSteps(); cards.show(mainPanel,"dir"); }
+ else if (currentStep==1) {
+ installDir = dirField.getText().trim();
+ if (installDir.isEmpty()) { JOptionPane.showMessageDialog(frame,"Укажите папку"); return; }
+ currentStep=2; updateSteps(); cards.show(mainPanel,"progress");
+ // disable nav
+ new Thread(() -> {
+ try { doInstall(installDir, shortcutCheck.isSelected()); SwingUtilities.invokeLater(() -> { currentStep=3; updateSteps(); cards.show(mainPanel,"finish"); }); }
+ catch(Exception ex){ SwingUtilities.invokeLater(() -> { JOptionPane.showMessageDialog(frame,"Ошибка: "+ex.getMessage()); currentStep=1; updateSteps(); cards.show(mainPanel,"dir"); }); }
+ }).start();
+ } else if (currentStep==3) System.exit(0);
+ }
+
+ private static void updateSteps() {
+ for (int i=0;i0) { int toRead = (int)Math.min(buf.length, rem); int r = raf.read(buf,0,toRead); if(r<=0) break; out.write(buf,0,r); rem-=r; }
+ }
+ found = true;
+ }
+ }
+ }
+ } catch(Exception ignored){}
+ if (!found) {
+ // try adjacent ZernMC-win-*.zip next to exe
+ try {
+ String self = Installer.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
+ Path exeDir = Paths.get(self).getParent();
+ if (exeDir!=null) {
+ try (var stream = Files.list(exeDir)) {
+ for (Path cand : stream.toList()) {
+ String n = cand.getFileName().toString();
+ if (n.startsWith("ZernMC-win-") && n.endsWith(".zip") || n.startsWith("ZernMC-Offline") && n.endsWith(".zip")) {
+ Files.copy(cand, tmp, StandardCopyOption.REPLACE_EXISTING);
+ found = true; break;
+ }
+ }
+ }
+ if (!found) {
+ // also check current dir
+ try (var s2 = Files.list(Paths.get("."))) {
+ for (Path cand : s2.toList()) {
+ String n = cand.getFileName().toString();
+ if (n.startsWith("ZernMC-win-") && n.endsWith(".zip")) { Files.copy(cand, tmp, StandardCopyOption.REPLACE_EXISTING); found=true; break; }
+ }
+ }
+ }
+ }
+ } catch(Exception ignored){}
+ }
+ }
+ if (!found) throw new IOException("offline.zip не найден. Положите ZernMC-win-1.1.1.4.zip рядом с установщиком или соберите standalone offline");
+ unzip(tmp, base);
+ Files.deleteIfExists(tmp);
+ }
+ setStatus("Создание ярлыка...", 90);
+ if (shortcut) createShortcut(dir);
+ createUninstaller(dir);
+ setStatus("Готово!",100);
+ }
+
+ private static long[] findAppendedZipOffset(Path exe) throws Exception {
+ try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(exe.toFile(), "r")) {
+ long len = raf.length();
+ long scan = Math.min(len, 1024*1024);
+ for (long pos = len - 22; pos >= len - scan; pos--) {
+ raf.seek(pos);
+ int b0 = raf.read(); int b1 = raf.read(); int b2 = raf.read(); int b3 = raf.read();
+ if (b0==0x50 && b1==0x4b && b2==0x05 && b3==0x06) {
+ for (long s = pos; s >= Math.max(0, pos - 110*1024*1024); s--) {
+ raf.seek(s);
+ int a0=raf.read(); int a1=raf.read(); int a2=raf.read(); int a3=raf.read();
+ if (a0==0x50 && a1==0x4b && a2==0x03 && a3==0x04) { return new long[]{s, len - s}; }
+ }
+ }
+ }
+ }
+ return null;
+ }
+ private static void setStatus(String s, int pct){ SwingUtilities.invokeLater(() -> { statusLabel.setText(s); progressBar.setValue(pct); titleLabel.setText(pct+"%"); }); }
+ private static JsonObject fetchMeta() throws Exception {
+ String ver = httpGet(BASE_URL+"/launcher/version");
+ JsonObject jo = JsonParser.parseString(ver).getAsJsonObject();
+ String v = jo.get("version").getAsString();
+ String metaStr = httpGet(BASE_URL+"/launcher/meta/"+v);
+ JsonObject meta = JsonParser.parseString(metaStr).getAsJsonObject();
+ if (!meta.has("version")) meta.addProperty("version", v);
+ return meta;
+ }
+ private static String httpGet(String url) throws Exception {
+ HttpURLConnection c=(HttpURLConnection)new URL(url).openConnection();
+ c.setConnectTimeout(5000); c.setReadTimeout(10000);
+ try(BufferedReader br=new BufferedReader(new InputStreamReader(c.getInputStream()))){ StringBuilder sb=new StringBuilder(); String l; while((l=br.readLine())!=null) sb.append(l); return sb.toString(); }
+ }
+ private static void downloadFile(String url, Path dest, long expected) throws Exception {
+ HttpURLConnection c=(HttpURLConnection)new URL(url).openConnection();
+ c.setConnectTimeout(8000); c.setReadTimeout(60000);
+ if (c.getResponseCode()!=200) throw new IOException("HTTP "+c.getResponseCode()+" "+url);
+ long total = expected>0?expected:c.getContentLengthLong();
+ try(InputStream in=c.getInputStream(); OutputStream out=Files.newOutputStream(dest)){ byte[] buf=new byte[65536]; long done=0; long start=System.currentTimeMillis(); int n; while((n=in.read(buf))>0){ out.write(buf,0,n); done+=n; if(System.currentTimeMillis()-start>200){ double pct= total>0? done*100.0/total:0; speedLabel.setText(String.format("%.1f MB / %.1f MB", done/1024.0/1024, total/1024.0/1024)); } } }
+ }
+ private static void unzip(Path zip, Path dest) throws Exception {
+ try(ZipInputStream zis=new ZipInputStream(Files.newInputStream(zip))){ ZipEntry e; while((e=zis.getNextEntry())!=null){ Path p=dest.resolve(e.getName()); if (!p.normalize().startsWith(dest.normalize())) throw new IOException("ZipSlip"); if(e.isDirectory()) Files.createDirectories(p); else { Files.createDirectories(p.getParent()); try(OutputStream o=Files.newOutputStream(p)){ zis.transferTo(o); }} zis.closeEntry(); } }
+ }
+ private static String sha256(Path p) throws Exception { MessageDigest d=MessageDigest.getInstance("SHA-256"); try(InputStream in=Files.newInputStream(p)){ byte[] b=new byte[8192]; int n; while((n=in.read(b))>0) d.update(b,0,n);} StringBuilder sb=new StringBuilder(); for(byte x:d.digest()) sb.append(String.format("%02x",x)); return sb.toString(); }
+ private static void deleteRecursively(Path p) throws Exception { if(Files.isDirectory(p)) try(var s=Files.list(p)){ for(Path c: s.toList()) deleteRecursively(c);} Files.deleteIfExists(p);}
+ private static void createShortcut(String dir) throws Exception {
+ String desktop = System.getenv("USERPROFILE")+"\\Desktop";
+ if(!Files.exists(Paths.get(desktop))) desktop = System.getenv("USERPROFILE")+"\\OneDrive\\Desktop";
+ String lnk = desktop+"\\ZernMC Launcher.lnk";
+ String target = dir+"\\zernmc.exe";
+ String ps = "$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('"+lnk+"'); $Shortcut.TargetPath = '"+target+"'; $Shortcut.WorkingDirectory = '"+dir+"'; $Shortcut.IconLocation = '"+target+"'; $Shortcut.Save()";
+ Path tmp = Paths.get(System.getProperty("java.io.tmpdir"),"sc.ps1");
+ Files.writeString(tmp, ps);
+ new ProcessBuilder("powershell","-ExecutionPolicy","Bypass","-File",tmp.toString()).start().waitFor();
+ Files.deleteIfExists(tmp);
+ }
+ private static void createUninstaller(String dir) throws Exception {
+ Path exe = Paths.get(dir,"uninstall.exe");
+ // copy self as uninstall.exe
+ String self = Installer.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
+ Path selfPath = Paths.get(new URI("file://"+self));
+ if(Files.exists(selfPath) && !selfPath.equals(exe)) try{ Files.copy(selfPath, exe, StandardCopyOption.REPLACE_EXISTING);}catch(Exception ignored){}
+ Files.writeString(Paths.get(dir,"uninstall.bat"), "@echo off\ndel \"%USERPROFILE%\\Desktop\\ZernMC Launcher.lnk\"\n echo data in %USERPROFILE%\\.zernmc kept\n");
+ }
+}
diff --git a/launcher/pom.xml b/launcher/pom.xml
index 05eeb9c..b254d2f 100644
--- a/launcher/pom.xml
+++ b/launcher/pom.xml
@@ -20,7 +20,7 @@
1.1.1
- 3
+ 4
21
21
UTF-8
diff --git a/server/main.py b/server/main.py
index 5da3ea4..588ad5f 100644
--- a/server/main.py
+++ b/server/main.py
@@ -1835,7 +1835,9 @@ async def download_online_setup(request: Request):
"""Download online Go installer (5.8M, STANDALONE EXE without bundled JRE)"""
p = _find_latest_setup("ZernMC-Online-Setup-*.exe")
if p and p.exists():
- return await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
+ resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
+ resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
+ return resp
raise HTTPException(404, "Online installer not found")
@app.get("/launcher/download/offline-setup")
@@ -1843,11 +1845,15 @@ async def download_offline_setup(request: Request):
"""Download offline Go installer (STANDALONE EXE with embedded build, ~100M)"""
p = _find_latest_setup("ZernMC-Offline-Setup-*.exe")
if p and p.exists():
- return await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
+ resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
+ resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
+ return resp
# fallback: try legacy offline zip embed name without -Setup
p2 = _find_latest_setup("ZernMC-Offline-*.exe")
if p2 and p2.exists():
- return await send_file_async(p2, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
+ resp = await send_file_async(p2, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
+ resp.headers["Content-Disposition"] = f'attachment; filename="{p2.name}"'
+ return resp
raise HTTPException(404, "Offline installer not found")
@app.get("/launcher/download/zip/{filename}")