From b864e179216de9e99f8497f84e1c6310253c9d36 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Tue, 1 Sep 2026 14:14:30 +0000 Subject: [PATCH] feat: 1.1.1.4 GUI bootstrap-Inno installer + standalone offline - Installer.java: Swing dark Bootstrap (bg 0c0c12, accent e94560) Inno wizard (sidebar steps, Welcome->Dir->Progress->Finish), JRE + meta download via /launcher/file, offline via embedded /offline.zip + appended ZIP + adjacent fallback, shortcut/uninstall - pom: hotfix 4, launch4j 2 exe ZernMC-Online/Offline-Setup-1.1.1.4.exe (icon, versionInfo, header gui, jar shaded), offline.zip resource - server/main.py: Content-Disposition attachment filename for online/offline, BUILDS_DIR absolute - Builds: Online 6.3M (downloads), Offline 103M (standalone embedded 98M zip), both signed, site headers fixed --- launcher/launcher/pom.xml | 39 ++ .../zernmc/launcher/installer/Installer.java | 469 ++++++++++++++++++ launcher/pom.xml | 2 +- server/main.py | 12 +- 4 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/installer/Installer.java 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}")