logo
Hello World!

PatchKit Upload Widget

30 maja 2025 (Updated: 5 lipca 2025)
javascriptexpresspatchkit

Widget do tworzenia buildów launchera przy użyciu usług PatchKit z funkcją drag & drop i automatyczną detekcją platformy.

Projekt powstał pod koniec 2022 roku jako odpowiedź na potrzebę niektórych klientów, aby whitelabel'ować usługi PatchKit'a. Widget pozwala na stworzenie gotowego do pobrania build'u Launchera bez potrzeby używania panelu administracyjnego platformy.

To w rzeczywistości trzy projekty w jednym:

  • pkWidget-interface_minimal: Uproszczona wersja pokazująca, jak używać API
  • pkWidget-interface_extended: Pełnowartościowy widget z dopracowanym interfejsem
  • pkWidget-server: Przykładowy serwer Node.js zapewniający bezpieczeństwo kluczy API

Wszystkie implementacje stanowią przykłady startowe, które deweloperzy mogą dostosować do swoich potrzeb i rozszerzyć o dodatkowe funkcjonalności.

Wygląd interfejsu został zaprojektowany przez Daniela Łukucia, animacje z kolei są moją interpretacją. Widget został też użyty jako demo funkcjonalności platformy i można go znaleźć na stronie PatchKit Demo.



Widget implementuje trzystopniowy proces tworzenia launchera:

  1. Upload: Walidacja pliku → Detekcja platformy → Przesyłanie chunkami
  2. Processing: Tworzenie aplikacji → Przetwarzanie zawartości → Śledzenie postępu
  3. Publishing: Publikacja wersji → Śledzenie publikacji → Link do pobrania

  • Przesyłanie plików drag & drop: Natywna obsługa przeciągania archiwów ZIP
  • Walidacja plików po stronie klienta:
    • Wykrywanie uszkodzonych archiwów ZIP
    • Walidacja znaków ASCII (32-127, wykluczając backslash)
    • Kontrola rozmiaru pliku (limit 10GB)
  • Inteligentna detekcja platformy:
    • Windows
    • macOS
    • Linux
  • Dzielenie plików na części z logiką ponownego wysyłania:
    • Podział plików na konfigurowane części
    • Automatyczne ponowne wysyłanie przy błędach połączenia
      • Śledzenie postępu z dokładnością do części
  • Bezpieczeństwo: Autoryzacja JWT, ochrona kluczy API po stronie serwera
  • Responsywny interfejs: Płynne animacje fade/collapse, feedback wizualny

Vanilla JavaScript (ES6), zip.js, Express.js, CSS3

Implementacja została stworzona w vanilla JavaScript, co było wymaganiem projektowym. Rozwiązanie miało być uniwersalne i zrozumiałe dla każdego dewelopera, niezależnie od używanych frameworków frontendowych. Choć współczesne biblioteki znacznie ułatwiają tworzenie aplikacji, pisanie w vanilla JS pozwoliło na stworzenie lekkiego i niezależnego rozwiązania.

javascript
// Struktura projektu
pkWidget-interface_extended/
├── src/pkWidget/
│   ├── main.js              // Główna klasa widgetu
│   ├── Components.js        // Rejestr komponentów
│   ├── engine.js            // Logika biznesowa (validate, detectPlatforms, upload, process)
│   ├── utils.js             // Funkcje pomocnicze (ZIP processing, platform detection)
│   ├── request.js           // Komunikacja z API
│   ├── config.js            // Konfiguracja (limity, endpointy)
│   ├── Alert.js             // System powiadomień
│   └── components/
│       ├── Component.js     // Klasa bazowa z animacjami
│       ├── FileInput.js     // Obsługa plików i drag & drop
│       ├── StepProgress.js  // Wskaźnik postępu
│       ├── Progress.js      // Pasek postępu uploadu
│       ├── PlatformSelect.js// Wybór platformy
│       └── Button.js        // Komponenty przycisków
// Struktura projektu
pkWidget-interface_extended/
├── src/pkWidget/
│   ├── main.js              // Główna klasa widgetu
│   ├── Components.js        // Rejestr komponentów
│   ├── engine.js            // Logika biznesowa (validate, detectPlatforms, upload, process)
│   ├── utils.js             // Funkcje pomocnicze (ZIP processing, platform detection)
│   ├── request.js           // Komunikacja z API
│   ├── config.js            // Konfiguracja (limity, endpointy)
│   ├── Alert.js             // System powiadomień
│   └── components/
│       ├── Component.js     // Klasa bazowa z animacjami
│       ├── FileInput.js     // Obsługa plików i drag & drop
│       ├── StepProgress.js  // Wskaźnik postępu
│       ├── Progress.js      // Pasek postępu uploadu
│       ├── PlatformSelect.js// Wybór platformy
│       └── Button.js        // Komponenty przycisków

Do przetwarzania archiwów ZIP wykorzystana została biblioteka zip.js, która pozwala na:

  • Odczyt struktury plików: Analiza zawartości archiwum bez pełnego rozpakowania
  • Asynchroniczne przetwarzanie: Nieblokujące operacje na dużych archiwach
  • Walidacja integralności: Wykrywanie uszkodzonych archiwów
  • Dostęp do metadanych: Informacje o rozmiarze, liczbie plików, strukturze katalogów

Prosty serwer Node.js z Express.js pełni rolę proxy między widgetem a PatchKit API. To jest przykładowa implementacja, którą deweloperzy mogą rozszerzyć o dodatkowe funkcjonalności:

  • Ochrona kluczy API: Klucz API przechowywany po stronie serwera (nie w przeglądarce)
  • Proxy do PatchKit API: Przekierowanie żądań (/upload, /createApp, /process, /publish, /fetchApp)
  • Pobieranie tokenów JWT: Serwer pobiera tokeny JWT z PatchKit API dla uploadu
  • Podstawowa obsługa CORS: Domyślna konfiguracja cors() bez dodatkowych ograniczeń

Możliwe rozszerzenia: rate limiting, zaawansowana obsługa błędów, logowanie, walidacja żądań, konfiguracja CORS dla konkretnych domen.

Stworzyłem system komponentów oparty na klasach, zawierających swoje właściwości i metody. Poszczególne komponenty rozszerzają klasę Component, która zawiera uniwersalne metody do renderowania, animacji i destrukcji komponentu.

javascript
export default class Component {
  constructor(className) {
    this.componentDiv = document.createElement('div');
    this.componentDiv.className = 'widgetComponent';
    this.div = document.createElement('div');
    this.componentDiv.appendChild(this.div);
    this.div.className = className;
  }

  appendTo = (parentDiv) => parentDiv.appendChild(this.componentDiv);

  prependTo = (parentDiv) => parentDiv.prepend(this.componentDiv);

  remove = () => this.componentDiv.parentNode.removeChild(this.componentDiv);

  setOpacity(div, opacity = 1, time = 1) {
    if (!div) return;

    div.style.transitionProperty = 'opacity';
    div.style.transitionDuration = `${time}s`;

    div.style.opacity = opacity;

    return new Promise((resolve) => setTimeout(() => resolve(), time*1000));
  }
  
  collapse(state, time = 1) {
    this.setOpacity(this.componentDiv, 1, 0);

    const initialHeight = !state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${initialHeight}px`;

    this.componentDiv.style.transitionProperty = 'height';
    this.componentDiv.style.transitionTimingFunction = 'cubic-bezier(0, 0.5, 0, 1)';
    this.componentDiv.style.transitionDuration = `${time}s`;

    const height = state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${height}px`;

    state
    ? this.fadeOutContent(time/2)
    : this.fadeInContent(time/2);

    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeIn = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 0, 0);
    return this.setOpacity(this.componentDiv, 1, time);
  }

  fadeOut = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 1, 0);
    return this.setOpacity(this.componentDiv, 0, time);
  }

  fadeInContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 1, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeOutContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 0, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }
}
export default class Component {
  constructor(className) {
    this.componentDiv = document.createElement('div');
    this.componentDiv.className = 'widgetComponent';
    this.div = document.createElement('div');
    this.componentDiv.appendChild(this.div);
    this.div.className = className;
  }

  appendTo = (parentDiv) => parentDiv.appendChild(this.componentDiv);

  prependTo = (parentDiv) => parentDiv.prepend(this.componentDiv);

  remove = () => this.componentDiv.parentNode.removeChild(this.componentDiv);

  setOpacity(div, opacity = 1, time = 1) {
    if (!div) return;

    div.style.transitionProperty = 'opacity';
    div.style.transitionDuration = `${time}s`;

    div.style.opacity = opacity;

    return new Promise((resolve) => setTimeout(() => resolve(), time*1000));
  }
  
  collapse(state, time = 1) {
    this.setOpacity(this.componentDiv, 1, 0);

    const initialHeight = !state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${initialHeight}px`;

    this.componentDiv.style.transitionProperty = 'height';
    this.componentDiv.style.transitionTimingFunction = 'cubic-bezier(0, 0.5, 0, 1)';
    this.componentDiv.style.transitionDuration = `${time}s`;

    const height = state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${height}px`;

    state
    ? this.fadeOutContent(time/2)
    : this.fadeInContent(time/2);

    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeIn = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 0, 0);
    return this.setOpacity(this.componentDiv, 1, time);
  }

  fadeOut = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 1, 0);
    return this.setOpacity(this.componentDiv, 0, time);
  }

  fadeInContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 1, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeOutContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 0, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }
}

Przykład, jak komponent Button rozszerza klasę Component:

javascript
import Component from "./Component.js";

export default class Button extends Component {
  constructor(className, html, onClick) {
    super(className);
    this.onClick = onClick;
    this.div.innerHTML = html;
    this.div.addEventListener('click', () => this.onClick && this.onClick());
  }
}
import Component from "./Component.js";

export default class Button extends Component {
  constructor(className, html, onClick) {
    super(className);
    this.onClick = onClick;
    this.div.innerHTML = html;
    this.div.addEventListener('click', () => this.onClick && this.onClick());
  }
}

Wszystkie komponenty są dostępne poprzez obiekt Components, który je inicjalizuje i pozwala na łatwy dostęp do każdego z nich.

javascript
import FileInput from "./components/FileInput.js";
import PlatformSelect from "./components/PlatformSelect.js";
import StepProgress from './components/StepProgress.js';
import Progress from './components/Progress.js';
import Div from './components/Div.js';
import Button from './components/Button.js';

class Components {
  constructor() {
    this.components = [];
  }

  add = (name, obj) => this.components.push({ name, obj });

  get = (name) => this.components.find((elem) => elem.name === name)?.obj;
  
  getAll() {
    const result = {};
    this.components.forEach((elem) => result[elem.name] = elem.obj);
    return result;
  }

  init(pkWidget) {
    this.add('stepProgress', new StepProgress({
      steps: [
        { name: '1', description: 'Upload game archive' },
        { name: '2', description: 'Processing & Publishing' },
        { name: '3', description: 'Download the launcher' },
      ],
    }));
    this.add('fileInputLabel', new Div('fileInputLabel', '<span>Uploaded file</span>'));
    this.add('fileInput', new FileInput());
    this.add('platformSelectLabel', new Div('platformSelectLabel', '<span>Detected platform</span>'));
    this.add('platformSelect', new PlatformSelect());
    this.add('processButton', new Button('processButton', '<div class="btn">Process<i class="icon icon-chevron-down"></i></div>'));
    this.add('progress', new Progress());
  }
}

export default new Components();
import FileInput from "./components/FileInput.js";
import PlatformSelect from "./components/PlatformSelect.js";
import StepProgress from './components/StepProgress.js';
import Progress from './components/Progress.js';
import Div from './components/Div.js';
import Button from './components/Button.js';

class Components {
  constructor() {
    this.components = [];
  }

  add = (name, obj) => this.components.push({ name, obj });

  get = (name) => this.components.find((elem) => elem.name === name)?.obj;
  
  getAll() {
    const result = {};
    this.components.forEach((elem) => result[elem.name] = elem.obj);
    return result;
  }

  init(pkWidget) {
    this.add('stepProgress', new StepProgress({
      steps: [
        { name: '1', description: 'Upload game archive' },
        { name: '2', description: 'Processing & Publishing' },
        { name: '3', description: 'Download the launcher' },
      ],
    }));
    this.add('fileInputLabel', new Div('fileInputLabel', '<span>Uploaded file</span>'));
    this.add('fileInput', new FileInput());
    this.add('platformSelectLabel', new Div('platformSelectLabel', '<span>Detected platform</span>'));
    this.add('platformSelect', new PlatformSelect());
    this.add('processButton', new Button('processButton', '<div class="btn">Process<i class="icon icon-chevron-down"></i></div>'));
    this.add('progress', new Progress());
  }
}

export default new Components();

  • FileInput: Obsługa drag & drop i walidacja plików
  • StepProgress: Trójstopniowy wskaźnik postępu (Upload → Processing → Download)
  • PlatformSelect: Wybór wykrytej platformy aplikacji
  • Progress: Śledzenie postępu z animowanym paskiem
  • Button: Uniwersalny komponent przycisków z animacjami
  • Div: Podstawowy kontener z systemem animacji

Widget implementuje trzy główne widoki odpowiadające etapom procesu:

  • Wyświetlanie komponentu FileInput
  • Obsługa drag & drop i walidacja plików
  • Wykrywanie platformy i przejście do podsumowania

  • Prezentacja szczegółów pliku
  • Wybór platformy z wykrytych opcji
  • Przycisk rozpoczęcia przetwarzania

  • Śledzenie postępu przetwarzania i publikacji
  • Wyświetlanie komunikatów o statusie
  • Możliwość anulowania procesu

Przechodzenie między widokami odbywa się z płynnymi animacjami fade/collapse, co zapewnia spójne doświadczenie użytkownika.

Bez Redux czy Zustand musiałem stworzyć własny system zarządzania stanem:

  • Ręczna manipulacja DOM: Bezpośrednie tworzenie, modyfikowanie i usuwanie elementów
  • Event-driven architecture: Komunikacja między komponentami poprzez callbacki
  • Scentralizowane zarządzanie: Główna klasa pkWidget koordynuje stan wszystkich komponentów
  • Animacje CSS: Wykorzystanie transition i keyframes dla płynnych przejść

Widget implementuje szczegółową walidację plików przed przesłaniem:

javascript
const isCharCodeCorrect = (charCode) => 
  charCode >= 32 && charCode < 127 && charCode !== 92;

const noSpecialCharsInString = (str) => {
  for (const i in str) 
    if (!isCharCodeCorrect(str.charCodeAt(i)))
      return [false, str];
  return [true];
};
const isCharCodeCorrect = (charCode) => 
  charCode >= 32 && charCode < 127 && charCode !== 92;

const noSpecialCharsInString = (str) => {
  for (const i in str) 
    if (!isCharCodeCorrect(str.charCodeAt(i)))
      return [false, str];
  return [true];
};

System sprawdza każdy znak w nazwach plików, akceptując tylko znaki ASCII w zakresie 32-127, wykluczając backslash (kod 92). To zapewnia kompatybilność z różnymi systemami operacyjnymi.

  • Wykrywanie uszkodzonych ZIP: Sprawdzenie struktury archiwum przed przetwarzaniem
  • Kontrola rozmiaru: Limit 10GB dla pojedynczego archiwum
  • Walidacja zawartości: Sprawdzenie dostępności plików wykonywalnych

javascript
// Serwer proxy - config.js
export default {
  api_key: '', // PUT YOUR API KEY HERE
  port: 3000,
  pkEndpoint: 'https://api2.patchkit.net/1',
};
// Serwer proxy - config.js
export default {
  api_key: '', // PUT YOUR API KEY HERE
  port: 3000,
  pkEndpoint: 'https://api2.patchkit.net/1',
};

Klucze API są przechowywane wyłącznie po stronie serwera Node.js, który działa jako proxy między widgetem a API PatchKit.

  • Pobieranie tokenów: Serwer pobiera tokeny JWT z PatchKit API dla każdego uploadu
  • Ograniczony czas życia: Tokeny są ważne tylko przez czas trwania uploadu
  • Autoryzacja chunków: Każdy chunk używa tego samego tokena JWT w nagłówku Authorization

  • Content-Range headers: Weryfikacja integralności chunków po stronie PatchKit API
  • Retry logic: Automatyczne ponowne próby przy błędach sieciowych (konfigurowalny limit)
  • Rate limiting: Ograniczenia po stronie PatchKit API (przykładowy serwer proxy nie implementuje własnych limitów)

Problem: Pliki uploadowane przez użytkownika muszą być walidowane po stronie frontendu. Aplikacje mogą mieć bardzo duży rozmiar - niektóre współczesne gry mają nawet kilkadziesiąt GB. Nie chcemy, aby użytkownik czekał kilkanaście minut na upload i przetworzenie pliku, a na końcu dowiedział się, że plik jest niepoprawny.

Rozwiązanie: Wykorzystanie biblioteki zip.js do analizy struktury archiwum bez pełnego rozpakowania:

javascript
export const getEntriesFromZip = async (archive) => {
  const blob = new zip.BlobReader(archive);
  const reader = new zip.ZipReader(blob);
  
  let entries;
  try {
    entries = await reader.getEntries();
  } catch(err) {
    // Obsługa uszkodzonych archiwów
  }
  
  return entries;
}
export const getEntriesFromZip = async (archive) => {
  const blob = new zip.BlobReader(archive);
  const reader = new zip.ZipReader(blob);
  
  let entries;
  try {
    entries = await reader.getEntries();
  } catch(err) {
    // Obsługa uszkodzonych archiwów
  }
  
  return entries;
}

Problem: Wykrycie platformy aplikacji wyłącznie na podstawie rozszerzeń plików jest niewystarczające. Pliki wykonywalne dla Windows mają rozszerzenie .exe, .x86, lub .x86_64, ale dla macOS i Linux często nie mają rozszerzenia w ogóle.

Rozwiązanie: Implementacja detekcji przez analizę magic numbers w nagłówkach plików:

javascript
const _findExeArr = async (entries) => {
  if (!entries) return;
  
  const getBytesFromFile = async (entry, length) => {
    const data = await entry.getData(new zip.TextWriter());
    return data.slice(0, length);
  }
  const hasAnyExtension = ({ filename }) => filename.split('/').pop().includes('.');
  const hasSpecifiedExtension = ({ filename }, extensions) => extensions.some((extension) => filename.split('/').pop().includes(extension));
  const isFolder = ({ filename }) => filename[filename.length - 1] === '/';
  const isTooDeep = ({ filename }) => config.exeSearchDepth && filename.split('/').length > config.exeSearchDepth;

  const isOSX = (entry) => entry.filename.split('/').some((part) => part.includes('.app'));
  const isWin32 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
  }
  const isWin64 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
  }
  const isLin32 = (bytes) => {
    return bytes.includes('ELF') && bytes[28] === '4';
  }
  const isLin64 = (bytes) => {
    return bytes.includes('ELF') && bytes[18] === '>';
  }
  let osxDetected = false;
  
  for (let i=0; i < config.exeSearchDepth; i++) {
    if (isOSX(entries[i])) {
      osxDetected = true;
      return([{
        platform: 'osx',
        path: entries[i].filename.substring(0, entries[i].filename.length-1),
        name: entries[i].filename.split('.')[i].split('/').pop(),
      }]);
    }
  }
  if (!osxDetected) {
    const filteredEntries = entries.filter((entry) => 
      !isTooDeep(entry)
      && (hasSpecifiedExtension(entry, ['.exe', '.x86', '.x86_64'])
      || (!hasAnyExtension(entry) && !isFolder(entry)))
    );

    return filteredEntries.map(async (entry) => {
      const bytes = await getBytesFromFile(entry, 400);
      const detectedPlatform =
        isWin32(bytes)
        ? 'win32'
        : isWin64(bytes)
          ? 'win64'
          : isLin32(bytes)
            ? 'lin32'
            : isLin64(bytes)
              ? 'lin64'
              : undefined

      return (
        detectedPlatform
        && {
          platform: detectedPlatform,
          path: entry.filename,
          name: entry.filename.split('/').pop().split('.')[0],
        }
      )
    })
  }
}
const _findExeArr = async (entries) => {
  if (!entries) return;
  
  const getBytesFromFile = async (entry, length) => {
    const data = await entry.getData(new zip.TextWriter());
    return data.slice(0, length);
  }
  const hasAnyExtension = ({ filename }) => filename.split('/').pop().includes('.');
  const hasSpecifiedExtension = ({ filename }, extensions) => extensions.some((extension) => filename.split('/').pop().includes(extension));
  const isFolder = ({ filename }) => filename[filename.length - 1] === '/';
  const isTooDeep = ({ filename }) => config.exeSearchDepth && filename.split('/').length > config.exeSearchDepth;

  const isOSX = (entry) => entry.filename.split('/').some((part) => part.includes('.app'));
  const isWin32 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
  }
  const isWin64 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
  }
  const isLin32 = (bytes) => {
    return bytes.includes('ELF') && bytes[28] === '4';
  }
  const isLin64 = (bytes) => {
    return bytes.includes('ELF') && bytes[18] === '>';
  }
  let osxDetected = false;
  
  for (let i=0; i < config.exeSearchDepth; i++) {
    if (isOSX(entries[i])) {
      osxDetected = true;
      return([{
        platform: 'osx',
        path: entries[i].filename.substring(0, entries[i].filename.length-1),
        name: entries[i].filename.split('.')[i].split('/').pop(),
      }]);
    }
  }
  if (!osxDetected) {
    const filteredEntries = entries.filter((entry) => 
      !isTooDeep(entry)
      && (hasSpecifiedExtension(entry, ['.exe', '.x86', '.x86_64'])
      || (!hasAnyExtension(entry) && !isFolder(entry)))
    );

    return filteredEntries.map(async (entry) => {
      const bytes = await getBytesFromFile(entry, 400);
      const detectedPlatform =
        isWin32(bytes)
        ? 'win32'
        : isWin64(bytes)
          ? 'win64'
          : isLin32(bytes)
            ? 'lin32'
            : isLin64(bytes)
              ? 'lin64'
              : undefined

      return (
        detectedPlatform
        && {
          platform: detectedPlatform,
          path: entry.filename,
          name: entry.filename.split('/').pop().split('.')[0],
        }
      )
    })
  }
}
javascript
const isWin32 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
}

const isWin64 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
}

const isLin32 = (bytes) => {
  return bytes.includes('ELF') && bytes[28] === '4';
}

const isLin64 = (bytes) => {
  return bytes.includes('ELF') && bytes[18] === '>';
}
const isWin32 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
}

const isWin64 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
}

const isLin32 = (bytes) => {
  return bytes.includes('ELF') && bytes[28] === '4';
}

const isLin64 = (bytes) => {
  return bytes.includes('ELF') && bytes[18] === '>';
}

System najpierw sprawdza foldery .app dla macOS, a następnie analizuje pierwsze 400 bajtów potencjalnych plików wykonywalnych w poszukiwaniu charakterystycznych sygnatur.

Problem: Przesyłanie plików o dużym rozmiarze (do 10GB) wymaga mechanizmu dzielenia na mniejsze części oraz obsługi błędów sieciowych z automatycznymi ponownymi próbami.

Rozwiązanie: System chunkowania z konfigurowalną ilością ponownych prób i precyzyjnym śledzeniem postępu:

javascript
const chunkUploadRetries = config.chunkUploadRetries || 0;
let totalChunks = undefined;
let loadedChunks = 0;
let failedChunks = 0;
let wasProgressWarned = false;

export const chunkedFileUpload = (callbacks = {}, start = 0) => {
  if (!uploadData.url || !uploadData.file || !uploadData.jwt || totalChunks <= 0) {
    console.warn('Upload data is not set properly');
    return;
  }

  if (loadedChunks >= totalChunks) {
    totalChunks = 0;
    loadedChunks = 0;
    callbacks.success && callbacks.success();
    return;
  }

  const newChunk = _createChunk(uploadData.file, start);
  let end = loadedChunks * config.chunkSize - 1;
  if (loadedChunks === totalChunks) {
    end = uploadData.file.size - 1;
  }

  const chunkUploadSuccess = () => {
    failedChunks = 0;
    chunkedFileUpload(callbacks, start + config.chunkSize);
  };
  const chunkUploadError = () => {
    if (failedChunks === chunkUploadRetries) {
      failedChunks = 0;
      callbacks.error && callbacks.error();
      return;
    }

    failedChunks++;
    console.warn(`Chunk upload error. Retrying... (${failedChunks}/${chunkUploadRetries})`);
    _uploadChunk(uploadData.file.size, newChunk, start, end, { 
      success: chunkUploadSuccess, 
      error: chunkUploadError 
    });
  }

  _uploadChunk(uploadData.file.size, newChunk, start, end, {
    progress: callbacks.progress,
    success: chunkUploadSuccess,
    error: chunkUploadError,
  });
}

const _createChunk = (file, start) => {
  loadedChunks++;
  const end = Math.min(start + config.chunkSize, file.size);
  const chunk = file.slice(start, end);
  const chunkForm = new FormData()
  chunkForm.append('chunk', chunk, file.name);

  return chunkForm;
}

const _uploadChunk = (fileSize, chunkForm, start, end, callbacks = {}) => {
  sendRequest(
    {
      method: 'POST',
      url: uploadData.url,
      data: chunkForm,
      headers: {
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
        Authorization: `Bearer ${uploadData.jwt}`,
      }
    },
    {
      progress: (e) => {
        if (e.lengthComputable) {
          const chunkPercentComplete = e.loaded / e.total * 100;
          const totalPercentComplete = (loadedChunks - 1) / totalChunks * 100 + chunkPercentComplete / totalChunks;
          const loaded = (loadedChunks - 1) * config.chunkSize + e.loaded;

          callbacks.progress && callbacks.progress({
            chunkPercentComplete: chunkPercentComplete.toFixed(2),
            totalPercentComplete: totalPercentComplete.toFixed(2),
            loaded: loaded,
            total: uploadData.file.size,
          });
        } else {
          if (!wasProgressWarned) {
            console.warn('Content-Length not responded. Unable to track progress');
            wasProgressWarned = true;
          }
        }
      },
      success: callbacks.success,
      error: callbacks.error,
    }
  );
}
const chunkUploadRetries = config.chunkUploadRetries || 0;
let totalChunks = undefined;
let loadedChunks = 0;
let failedChunks = 0;
let wasProgressWarned = false;

export const chunkedFileUpload = (callbacks = {}, start = 0) => {
  if (!uploadData.url || !uploadData.file || !uploadData.jwt || totalChunks <= 0) {
    console.warn('Upload data is not set properly');
    return;
  }

  if (loadedChunks >= totalChunks) {
    totalChunks = 0;
    loadedChunks = 0;
    callbacks.success && callbacks.success();
    return;
  }

  const newChunk = _createChunk(uploadData.file, start);
  let end = loadedChunks * config.chunkSize - 1;
  if (loadedChunks === totalChunks) {
    end = uploadData.file.size - 1;
  }

  const chunkUploadSuccess = () => {
    failedChunks = 0;
    chunkedFileUpload(callbacks, start + config.chunkSize);
  };
  const chunkUploadError = () => {
    if (failedChunks === chunkUploadRetries) {
      failedChunks = 0;
      callbacks.error && callbacks.error();
      return;
    }

    failedChunks++;
    console.warn(`Chunk upload error. Retrying... (${failedChunks}/${chunkUploadRetries})`);
    _uploadChunk(uploadData.file.size, newChunk, start, end, { 
      success: chunkUploadSuccess, 
      error: chunkUploadError 
    });
  }

  _uploadChunk(uploadData.file.size, newChunk, start, end, {
    progress: callbacks.progress,
    success: chunkUploadSuccess,
    error: chunkUploadError,
  });
}

const _createChunk = (file, start) => {
  loadedChunks++;
  const end = Math.min(start + config.chunkSize, file.size);
  const chunk = file.slice(start, end);
  const chunkForm = new FormData()
  chunkForm.append('chunk', chunk, file.name);

  return chunkForm;
}

const _uploadChunk = (fileSize, chunkForm, start, end, callbacks = {}) => {
  sendRequest(
    {
      method: 'POST',
      url: uploadData.url,
      data: chunkForm,
      headers: {
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
        Authorization: `Bearer ${uploadData.jwt}`,
      }
    },
    {
      progress: (e) => {
        if (e.lengthComputable) {
          const chunkPercentComplete = e.loaded / e.total * 100;
          const totalPercentComplete = (loadedChunks - 1) / totalChunks * 100 + chunkPercentComplete / totalChunks;
          const loaded = (loadedChunks - 1) * config.chunkSize + e.loaded;

          callbacks.progress && callbacks.progress({
            chunkPercentComplete: chunkPercentComplete.toFixed(2),
            totalPercentComplete: totalPercentComplete.toFixed(2),
            loaded: loaded,
            total: uploadData.file.size,
          });
        } else {
          if (!wasProgressWarned) {
            console.warn('Content-Length not responded. Unable to track progress');
            wasProgressWarned = true;
          }
        }
      },
      success: callbacks.success,
      error: callbacks.error,
    }
  );
}

Problem: Implementacja złożonego interfejsu z trzema widokami, animacjami i zarządzaniem stanem bez użycia React czy Vue wymagała stworzenia własnego systemu.

Rozwiązanie: Architektura oparta na zdarzeniach z centralnym zarządzaniem komponentami:

javascript
// Przejście między widokami z animacjami
async setView(viewName, data) {
  switch(viewName) {
    case View.file:
      this.C.fileInput.onChange = async (file) => {
        // Walidacja i przejście do summary
      };
      await this.C.fileInput.fadeIn();
      break;
      
    case View.summary:
      await this.cleanupView(View.summary);
      // Konfiguracja komponentów dla widoku summary
      break;
  }
}
// Przejście między widokami z animacjami
async setView(viewName, data) {
  switch(viewName) {
    case View.file:
      this.C.fileInput.onChange = async (file) => {
        // Walidacja i przejście do summary
      };
      await this.C.fileInput.fadeIn();
      break;
      
    case View.summary:
      await this.cleanupView(View.summary);
      // Konfiguracja komponentów dla widoku summary
      break;
  }
}

System wykorzystuje klasy komponentów z wbudowanymi metodami animacji (fadeIn, fadeOut, collapse) i centralne zarządzanie przez rejestr Components.

PatchKit Upload Widget to narzędzie, które automatyzuje proces tworzenia buildu launchera dla aplikacji, umożliwiając whitelabel'owanie usług PatchKit. Projekt składa się z trzech komplementarnych części: uproszczonej wersji demonstracyjnej, pełnowartościowego widgetu oraz bezpiecznego serwera proxy.

Vanilla JavaScript w erze frameworków: Świadoma decyzja o użyciu czystego JavaScript zamiast React czy Vue pozwoliła na stworzenie uniwersalnego rozwiązania, które może być zintegrowane z dowolnym środowiskiem.

Zaawansowana detekcja platformy: Implementacja systemu rozpoznawania platform przez magic numbers w nagłówkach plików wykonywalnych, wykraczająca poza proste sprawdzanie rozszerzeń.

Niezawodny system chunkowania: Stworzenie systemu dzielenia plików na części z logiką ponownego wysyłania, umożliwiającego przesyłanie dużychplików z automatyczną obsługą błędów sieciowych.

Architektura bezpieczeństwa: Implementacja prostego serwera proxy chroniącego klucze API i pobierającego tokeny JWT z PatchKit API.

Ten projekt był dla mnie powrotem do korzeni web developmentu. Pisanie w vanilla JavaScript w dobie powszechności React i TypeScript było swoistym wyzwaniem, które uświadomiło mi, jaką ewolucję przeszło tworzenie aplikacji webowych.

Największą satysfakcję sprawiło mi rozwiązanie problemu detekcji platformy przez magic numbers. Analiza pierwszych bajtów plików wykonywalnych i implementacja logiki rozpoznawania PE, ELF i struktur .app wymagała głębszego zrozumienia formatów plików binarnych.

Brak frameworka zmusił mnie do przemyślenia każdego aspektu architektury - od zarządzania stanem po system animacji. Rezultatem jest lekkie, wydajne rozwiązanie, które działa wszędzie bez dodatkowych zależności.

©2025 BatStack