package main import ( "archive/tar" "archive/zip" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/ulikunitz/xz" ) const ( appName = "logofclient" appVersion = "1.0.0" defaultGitea = "https://git.mypapercloud.de" defaultOwner = "fierke" defaultRepo = "logofclient" ) type Config struct { GiteaURL string Owner string Repo string } type Release struct { TagName string `json:"tag_name"` Assets []Asset `json:"assets"` } type Asset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } func main() { config := Config{ GiteaURL: envOrDefault("GITEA_URL", defaultGitea), Owner: envOrDefault("GITEA_OWNER", defaultOwner), Repo: envOrDefault("GITEA_REPO", defaultRepo), } args := os.Args[1:] if len(args) == 0 { runTUI(config) return } switch args[0] { case "install": if err := runPrivileged(config, "install"); err != nil { printError(err) os.Exit(1) } case "update": if err := runPrivileged(config, "update"); err != nil { printError(err) os.Exit(1) } case "uninstall": if err := runPrivileged(config, "uninstall"); err != nil { printError(err) os.Exit(1) } case "-v", "--version": fmt.Printf("%s %s\n", appName, appVersion) case "-h", "--help", "help": printHelp() case "-y": if len(args) < 2 { printHelp() os.Exit(1) } switch args[1] { case "install": if err := runPrivileged(config, "install"); err != nil { printError(err) os.Exit(1) } case "update": if err := runPrivileged(config, "update"); err != nil { printError(err) os.Exit(1) } case "uninstall": if err := runPrivileged(config, "uninstall"); err != nil { printError(err) os.Exit(1) } default: printHelp() os.Exit(1) } default: fmt.Printf("Unbekannter Befehl: %s\n\n", args[0]) printHelp() os.Exit(1) } } // ------------------------------------------------------------ // TUI // ------------------------------------------------------------ func runTUI(config Config) { for { fmt.Println() fmt.Println("========================================") fmt.Println(" Logof Client Installer") fmt.Println("========================================") fmt.Println() fmt.Println(" 1) Logof Client installieren") fmt.Println(" 2) Nach Updates suchen") fmt.Println(" 3) Logof Client deinstallieren") fmt.Println(" 4) Beenden") fmt.Println() fmt.Print("Auswahl: ") var choice string if _, err := fmt.Scanln(&choice); err != nil { fmt.Println() return } var err error switch choice { case "1": err = runPrivileged(config, "install") case "2": err = runPrivileged(config, "update") case "3": err = runPrivileged(config, "uninstall") case "4": return default: fmt.Println("Ungültige Auswahl.") continue } if err != nil { printError(err) } fmt.Println() fmt.Print("Drücken Sie Enter, um fortzufahren...") fmt.Scanln() } } func printHelp() { fmt.Println(`Logof Client Installer Verwendung: logofclient-installer logofclient-installer install logofclient-installer update logofclient-installer uninstall Optionen: -h, --help Diese Hilfe anzeigen -v, --version Version des Installers anzeigen -y Befehl ohne TUI ausführen Beispiele: logofclient-installer install logofclient-installer update logofclient-installer uninstall logofclient-installer -y install`) } // ------------------------------------------------------------ // Privileges // ------------------------------------------------------------ func runPrivileged(config Config, action string) error { // Windows: // Die EXE wird später über ein Manifest mit // requestedExecutionLevel="requireAdministrator" // gestartet. Hier ist daher keine Elevation nötig. if runtime.GOOS != "linux" { return runAction(config, action) } // Linux: bereits root if os.Geteuid() == 0 { return runAction(config, action) } // Linux: mit sudo neu starten fmt.Println() fmt.Println("Für diese Aktion werden Administratorrechte benötigt.") fmt.Println("Starte Installer mit sudo...") fmt.Println() exe, err := os.Executable() if err != nil { return fmt.Errorf("Installer-Pfad konnte nicht ermittelt werden: %w", err) } cmd := exec.Command("sudo", exe, action) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func runAction(config Config, action string) error { switch action { case "install": return install(config) case "update": return update(config) case "uninstall": return uninstall(config) default: return fmt.Errorf("unbekannte Aktion: %s", action) } } // ------------------------------------------------------------ // Installation // ------------------------------------------------------------ func install(config Config) error { fmt.Println("Fetching latest version...") release, err := getLatestRelease(config) if err != nil { return err } tag := release.TagName if tag == "" { return errors.New("Release enthält keinen Tag") } fmt.Printf("Installing %s %s...\n", appName, tag) assetName := getAssetName(tag) asset, err := findAsset(release, assetName) if err != nil { return err } fmt.Printf("Downloading package: %s\n", asset.Name) tempDir, err := os.MkdirTemp("", "logofclient-installer-*") if err != nil { return fmt.Errorf("temporäres Verzeichnis konnte nicht erstellt werden: %w", err) } defer os.RemoveAll(tempDir) archivePath := filepath.Join(tempDir, asset.Name) if err := download(asset.BrowserDownloadURL, archivePath); err != nil { return err } installDir, err := getInstallDir() if err != nil { return err } fmt.Printf("Installing to: %s\n", installDir) // Alte Installation entfernen if err := removeIfExists(installDir); err != nil { return fmt.Errorf("alte Installation konnte nicht entfernt werden: %w", err) } if err := os.MkdirAll(installDir, 0755); err != nil { return fmt.Errorf("Installationsverzeichnis konnte nicht erstellt werden: %w", err) } // Archiv entpacken switch runtime.GOOS { case "linux": if err := extractTarXz(archivePath, installDir); err != nil { return err } case "windows": if err := extractZip(archivePath, installDir); err != nil { return err } default: return fmt.Errorf("nicht unterstütztes Betriebssystem: %s", runtime.GOOS) } // installierte Version speichern versionFile := filepath.Join(installDir, ".version") if err := os.WriteFile( versionFile, []byte(tag+"\n"), 0644, ); err != nil { return fmt.Errorf("Version konnte nicht gespeichert werden: %w", err) } // Plattformintegration switch runtime.GOOS { case "linux": if err := installLinuxIntegration(installDir); err != nil { return err } case "windows": if err := installWindowsIntegration(installDir); err != nil { return err } } fmt.Println() fmt.Printf("%s %s wurde erfolgreich installiert.\n", appName, tag) return nil } // ------------------------------------------------------------ // Update // ------------------------------------------------------------ func update(config Config) error { installDir, err := getInstallDir() if err != nil { return err } versionFile := filepath.Join(installDir, ".version") currentVersionData, err := os.ReadFile(versionFile) if err != nil { if os.IsNotExist(err) { fmt.Println("Logof Client ist nicht installiert.") fmt.Println("Installiere aktuelle Version...") return install(config) } return fmt.Errorf("installierte Version konnte nicht gelesen werden: %w", err) } currentVersion := strings.TrimSpace(string(currentVersionData)) fmt.Println("Fetching latest version...") release, err := getLatestRelease(config) if err != nil { return err } latestVersion := release.TagName fmt.Printf("Installierte Version: %s\n", valueOrNone(currentVersion)) fmt.Printf("Neueste Version: %s\n", valueOrNone(latestVersion)) if currentVersion == latestVersion { fmt.Println() fmt.Println("Logof Client ist bereits aktuell.") return nil } fmt.Println() fmt.Printf("Update von %s auf %s...\n", currentVersion, latestVersion) // install() entfernt die alte Version selbst. return install(config) } // ------------------------------------------------------------ // Uninstall // ------------------------------------------------------------ func uninstall(config Config) error { _ = config // aktuell nicht benötigt installDir, err := getInstallDir() if err != nil { return err } if _, err := os.Stat(installDir); os.IsNotExist(err) { fmt.Println("Logof Client ist nicht installiert.") return nil } fmt.Printf("Deinstalliere %s...\n", appName) switch runtime.GOOS { case "linux": if err := removeLinuxIntegration(); err != nil { return err } case "windows": if err := removeWindowsIntegration(); err != nil { return err } } if err := removeIfExists(installDir); err != nil { return fmt.Errorf("Installation konnte nicht entfernt werden: %w", err) } fmt.Println("Logof Client wurde erfolgreich deinstalliert.") return nil } // ------------------------------------------------------------ // Gitea // ------------------------------------------------------------ func getLatestRelease(config Config) (*Release, error) { url := fmt.Sprintf( "%s/api/v1/repos/%s/%s/releases/latest", strings.TrimRight(config.GiteaURL, "/"), config.Owner, config.Repo, ) resp, err := http.Get(url) if err != nil { return nil, fmt.Errorf("Gitea Release konnte nicht abgerufen werden: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf( "Gitea API antwortete mit HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)), ) } var release Release if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf("Gitea-Antwort konnte nicht gelesen werden: %w", err) } return &release, nil } func findAsset(release *Release, name string) (*Asset, error) { for _, asset := range release.Assets { if asset.Name == name { return &asset, nil } } return nil, fmt.Errorf( "Release %s enthält nicht das erwartete Paket: %s", release.TagName, name, ) } func getAssetName(tag string) string { switch runtime.GOOS { case "linux": return fmt.Sprintf("%s-%s-linux.tar.xz", appName, tag) case "windows": return fmt.Sprintf("%s-%s-win.zip", appName, tag) default: return "" } } func download(url string, destination string) error { resp, err := http.Get(url) if err != nil { return fmt.Errorf("Download fehlgeschlagen: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf( "Download fehlgeschlagen: HTTP %d", resp.StatusCode, ) } file, err := os.Create(destination) if err != nil { return fmt.Errorf("Download-Datei konnte nicht erstellt werden: %w", err) } defer file.Close() if _, err := io.Copy(file, resp.Body); err != nil { return fmt.Errorf("Download fehlgeschlagen: %w", err) } return nil } // ------------------------------------------------------------ // Linux // ------------------------------------------------------------ func getInstallDir() (string, error) { switch runtime.GOOS { case "linux": return "/opt/logofclient", nil case "windows": programFiles := os.Getenv("ProgramFiles") if programFiles == "" { return "", errors.New("ProgramFiles ist nicht gesetzt") } return filepath.Join(programFiles, "logofclient"), nil default: return "", fmt.Errorf("nicht unterstütztes Betriebssystem: %s", runtime.GOOS) } } func installLinuxIntegration(installDir string) error { binaryPath := filepath.Join(installDir, "Logof Client") linkPath := "/usr/local/bin/logofclient" fmt.Println("Creating command symlink...") if err := removeIfExists(linkPath); err != nil { return fmt.Errorf("alter Symlink konnte nicht entfernt werden: %w", err) } if err := os.Symlink(binaryPath, linkPath); err != nil { return fmt.Errorf("Symlink konnte nicht erstellt werden: %w", err) } desktopDir := "/usr/share/applications" desktopFile := filepath.Join(desktopDir, "logofclient.desktop") if err := os.MkdirAll(desktopDir, 0755); err != nil { return fmt.Errorf("Desktop-Verzeichnis konnte nicht erstellt werden: %w", err) } iconPath := filepath.Join( installDir, "assets", "icon.png", ) desktopEntry := fmt.Sprintf(`[Desktop Entry] Name=Logof Client Comment=Logof Client Exec=%s Terminal=false Type=Application Icon=%s Categories=Office; `, linkPath, iconPath) if err := os.WriteFile( desktopFile, []byte(desktopEntry), 0644, ); err != nil { return fmt.Errorf( "Desktop-Datei konnte nicht geschrieben werden: %w", err, ) } return nil } func removeLinuxIntegration() error { linkPath := "/usr/local/bin/logofclient" desktopFile := "/usr/share/applications/logofclient.desktop" if err := removeIfExists(linkPath); err != nil { return fmt.Errorf("Symlink konnte nicht entfernt werden: %w", err) } if err := removeIfExists(desktopFile); err != nil { return fmt.Errorf("Desktop-Datei konnte nicht entfernt werden: %w", err) } return nil } // ------------------------------------------------------------ // Windows // ------------------------------------------------------------ func installWindowsIntegration(installDir string) error { startMenu := filepath.Join( os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", ) if err := os.MkdirAll(startMenu, 0755); err != nil { return fmt.Errorf( "Startmenü-Verzeichnis konnte nicht erstellt werden: %w", err, ) } shortcut := filepath.Join( startMenu, "Logof Client.lnk", ) target := filepath.Join( installDir, "Logof Client.exe", ) icon := filepath.Join( installDir, "assets", "icon.ico", ) // PowerShell wird hier nur intern zur Erstellung der Windows- // Shell-Verknüpfung verwendet. Der Benutzer muss kein // PowerShell-Skript ausführen. script := ` $ws = New-Object -ComObject WScript.Shell $sc = $ws.CreateShortcut($args[0]) $sc.TargetPath = $args[1] $sc.WorkingDirectory = $args[2] $sc.Description = "Logof Client" if (Test-Path $args[3]) { $sc.IconLocation = $args[3] } $sc.Save() ` cmd := exec.Command( "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script, shortcut, target, installDir, icon, ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf( "Startmenü-Verknüpfung konnte nicht erstellt werden: %w", err, ) } return nil } func removeWindowsIntegration() error { startMenu := filepath.Join( os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", ) shortcut := filepath.Join( startMenu, "Logof Client.lnk", ) if err := removeIfExists(shortcut); err != nil { return fmt.Errorf( "Startmenü-Verknüpfung konnte nicht entfernt werden: %w", err, ) } return nil } // ------------------------------------------------------------ // Archive extraction // ------------------------------------------------------------ func extractTarXz(archivePath string, destination string) error { file, err := os.Open(archivePath) if err != nil { return fmt.Errorf("Archiv konnte nicht geöffnet werden: %w", err) } defer file.Close() xzReader, err := xz.NewReader(file) if err != nil { return fmt.Errorf("XZ-Archiv konnte nicht gelesen werden: %w", err) } tarReader := tar.NewReader(xzReader) for { header, err := tarReader.Next() if errors.Is(err, io.EOF) { break } if err != nil { return fmt.Errorf("TAR-Archiv konnte nicht gelesen werden: %w", err) } target, err := safeJoin(destination, header.Name) if err != nil { return err } switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { return fmt.Errorf( "Verzeichnis %s konnte nicht erstellt werden: %w", target, err, ) } case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } out, err := os.OpenFile( target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode), ) if err != nil { return fmt.Errorf( "Datei %s konnte nicht erstellt werden: %w", target, err, ) } _, copyErr := io.Copy(out, tarReader) closeErr := out.Close() if copyErr != nil { return fmt.Errorf( "Datei %s konnte nicht entpackt werden: %w", target, copyErr, ) } if closeErr != nil { return closeErr } case tar.TypeSymlink: if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } if err := removeIfExists(target); err != nil { return err } linkTarget := header.Linkname if err := os.Symlink(linkTarget, target); err != nil { return fmt.Errorf( "Symlink %s konnte nicht erstellt werden: %w", target, err, ) } default: return fmt.Errorf( "nicht unterstützter TAR-Eintrag: %s", header.Name, ) } } return nil } func extractZip(archivePath string, destination string) error { reader, err := zip.OpenReader(archivePath) if err != nil { return fmt.Errorf("ZIP-Archiv konnte nicht geöffnet werden: %w", err) } defer reader.Close() for _, file := range reader.File { target, err := safeJoin(destination, file.Name) if err != nil { return err } if file.FileInfo().IsDir() { if err := os.MkdirAll(target, 0755); err != nil { return fmt.Errorf( "Verzeichnis %s konnte nicht erstellt werden: %w", target, err, ) } continue } if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } in, err := file.Open() if err != nil { return fmt.Errorf( "ZIP-Datei %s konnte nicht geöffnet werden: %w", file.Name, err, ) } out, err := os.OpenFile( target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644, ) if err != nil { in.Close() return fmt.Errorf( "Datei %s konnte nicht erstellt werden: %w", target, err, ) } _, copyErr := io.Copy(out, in) in.Close() closeErr := out.Close() if copyErr != nil { return fmt.Errorf( "Datei %s konnte nicht entpackt werden: %w", target, copyErr, ) } if closeErr != nil { return closeErr } } return nil } // ------------------------------------------------------------ // Helpers // ------------------------------------------------------------ func safeJoin(base string, name string) (string, error) { if filepath.IsAbs(name) { return "", fmt.Errorf( "unsicherer Archivpfad: %s", name, ) } cleanName := filepath.Clean(name) if cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) { return "", fmt.Errorf( "unsicherer Archivpfad: %s", name, ) } target := filepath.Join(base, cleanName) rel, err := filepath.Rel(base, target) if err != nil { return "", err } if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return "", fmt.Errorf( "unsicherer Archivpfad: %s", name, ) } return target, nil } func removeIfExists(path string) error { err := os.RemoveAll(path) if err != nil && !os.IsNotExist(err) { return err } return nil } func envOrDefault(name string, fallback string) string { value := strings.TrimSpace(os.Getenv(name)) if value == "" { return fallback } return value } func valueOrNone(value string) string { if strings.TrimSpace(value) == "" { return "" } return value } func printError(err error) { fmt.Printf("\nERROR: %v\n", err) }