diff --git a/flake.lock b/flake.lock index db501c2649..a762c0ef67 100644 --- a/flake.lock +++ b/flake.lock @@ -576,6 +576,23 @@ "type": "github" } }, + "nixpkgs-nsis": { + "flake": false, + "locked": { + "lastModified": 1547508252, + "narHash": "sha256-ivcmGg01aeeod0rzjMJ86exUNHHRJu4526rGq9s7rJU=", + "owner": "input-output-hk", + "repo": "nixpkgs", + "rev": "be445a9074f139d63e704fa82610d25456562c3d", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "nixpkgs", + "rev": "be445a9074f139d63e704fa82610d25456562c3d", + "type": "github" + } + }, "nixpkgs-regression": { "locked": { "lastModified": 1643052045, @@ -726,6 +743,7 @@ "cardano-world": "cardano-world", "nix-bundle-exe": "nix-bundle-exe", "nixpkgs": "nixpkgs_4", + "nixpkgs-nsis": "nixpkgs-nsis", "ogmios": "ogmios", "ogmios-CHaP": "ogmios-CHaP" } diff --git a/flake.nix b/flake.nix index 5d473740fc..c4a1308451 100644 --- a/flake.nix +++ b/flake.nix @@ -26,6 +26,10 @@ nix-bundle-exe.url = "github:3noch/nix-bundle-exe"; nix-bundle-exe.flake = false; + + # FIXME: ‘nsis’ can’t cross-compile with the regular Nixpkgs (above) + nixpkgs-nsis.url = "github:input-output-hk/nixpkgs/be445a9074f139d63e704fa82610d25456562c3d"; + nixpkgs-nsis.flake = false; # too old }; outputs = inputs: let @@ -45,8 +49,7 @@ x86_64-linux = inputs.self.packages.x86_64-linux.lace-blockchain-services-installer; x86_64-darwin = inputs.self.packages.x86_64-darwin.lace-blockchain-services-installer; aarch64-darwin = inputs.self.packages.aarch64-darwin.lace-blockchain-services-installer; - # Uncomment once implemented: - # x86_64-windows = inputs.self.packages.x86_64-linux.lace-blockchain-services-installer-x86_64-windows; + x86_64-windows = inputs.self.packages.x86_64-linux.lace-blockchain-services-installer-x86_64-windows; }; required = inputs.nixpkgs.legacyPackages.x86_64-linux.releaseTools.aggregate { diff --git a/nix/lace-blockchain-services/internal/any-darwin.nix b/nix/lace-blockchain-services/internal/any-darwin.nix index 3db3fb7d5a..f33bda562a 100644 --- a/nix/lace-blockchain-services/internal/any-darwin.nix +++ b/nix/lace-blockchain-services/internal/any-darwin.nix @@ -98,7 +98,7 @@ in rec { lace-blockchain-services-exe = pkgs.buildGoModule rec { name = "lace-blockchain-services"; src = ./lace-blockchain-services; - vendorHash = "sha256-DjDyHOENtaFSNGQtX50wL3hIo+lmMY1BJBn/TaAcXU0="; + vendorHash = "sha256-1slTIiIGxraIFdtKNeH4llXjrtSEaEQ7IIbOM3LL3N0="; nativeBuildInputs = with pkgs; [ imagemagick go-bindata ]; buildInputs = (with pkgs; [ ]) @@ -108,7 +108,8 @@ in rec { }; preBuild = '' convert -background none -size 66x66 cardano-template.svg cardano.png - go-bindata -pkg main -o assets.go cardano.png + cp cardano.png tray-icon + go-bindata -pkg main -o assets.go tray-icon ''; }; @@ -264,7 +265,7 @@ in rec { else "dirty"; in pkgs.runCommand "lace-blockchain-services-dmg" {} '' mkdir -p $out - target=$out/lace-blockchain-services-${revShort}-${targetSystem}.dmg + target=$out/lace-blockchain-services-${common.laceVersion}-${revShort}-${targetSystem}.dmg /usr/bin/hdiutil makehybrid -iso -joliet -o tmp.iso \ ${lace-blockchain-services-bundle}/Applications @@ -508,7 +509,7 @@ in rec { ''; in pkgs.runCommand "lace-blockchain-services-dmg" {} '' mkdir -p $out - target=$out/lace-blockchain-services-${revShort}-${targetSystem}.dmg + target=$out/lace-blockchain-services-${common.laceVersion}-${revShort}-${targetSystem}.dmg ${dmgbuild}/bin/dmgbuild \ -D app_path=${lace-blockchain-services-bundle}/Applications/${lib.escapeShellArg common.prettyName}.app \ diff --git a/nix/lace-blockchain-services/internal/common.nix b/nix/lace-blockchain-services/internal/common.nix index 7c8dfe6440..ee5b93a412 100644 --- a/nix/lace-blockchain-services/internal/common.nix +++ b/nix/lace-blockchain-services/internal/common.nix @@ -10,15 +10,43 @@ in rec { prettyName = "Lace Blockchain Services"; + laceVersion = (builtins.fromJSON (builtins.readFile ../../../package.json)).version; + cardanoWorldFlake = (flake-compat { src = inputs.cardano-world; }).defaultNix; + # These are configs of ‘cardano-node’ for all networks we make available from the UI. + # The patching of the official networks needs to happen to: + # • turn off ‘EnableP2P’ (and modify topology accordingly), because it doesn’t work on Windows, + # • and turn off ‘hadPrometheus’, because it makes cardano-node hang on Windows during graceful exit. networkConfigs = let selectedNetworks = [ "mainnet" "preprod" "preview" ]; website = cardanoWorldFlake.${buildSystem}.cardano.packages.cardano-config-html-internal; - in pkgs.runCommand "network-configs" {} (lib.concatMapStringsSep "\n" (network: '' + in pkgs.runCommand "network-configs" { + nativeBuildInputs = [ pkgs.jq ]; + } ((lib.concatMapStringsSep "\n" (network: '' mkdir -p $out/${network} cp -r ${website}/config/${network}/. $out/${network} - '') selectedNetworks); + '') selectedNetworks) + (lib.optionalString (targetSystem == "x86_64-windows") '' + # Transform P2P topologies to non-P2P (or else, on Windows, we’d require C:\etc\resolv.conf) + chmod -R +w $out + find $out -type f -name 'topology.*' | while IFS= read -r file ; do + addr=$(jq -er '.PublicRoots[0].publicRoots.accessPoints[0].address' "$file") || continue + port=$(jq -er '.PublicRoots[0].publicRoots.accessPoints[0].port' "$file") || continue + jq --arg addr "$addr" --argjson port "$port" --null-input \ + '.Producers = [.addr = $addr | .port = $port | .valency = 1]' > tmp.json + mv tmp.json "$file" + done + find $out -type f -name 'config.*' | while IFS= read -r file ; do + if [ "$(jq .EnableP2P "$file")" == "true" ] ; then + jq '.EnableP2P = false' "$file" >tmp.json + mv tmp.json "$file" + fi + + # With '.hasPrometheus', cardano-node hangs during graceful exit on Windows: + jq 'del(.hasPrometheus)' "$file" >tmp.json + mv tmp.json "$file" + done + '')); # XXX: they don’t enable aarch64-darwin builds yet: cardanoNodeFlake = if targetSystem != "aarch64-darwin" then inputs.cardano-node else let @@ -38,6 +66,16 @@ in rec { ogmiosCompiler = "ghc8107"; + ogmiosPatched = { + outPath = toString (pkgs.runCommand "ogmios-patched" {} '' + cp -r ${inputs.ogmios} $out + chmod -R +w $out + cd $out + patch -p1 -i ${./ogmios--on-windows.patch} + ''); + inherit (inputs.ogmios.sourceInfo) rev shortRev lastModified lastModifiedDate; + }; + ogmiosProject = let theirDefaultNix = __readFile "${inputs.ogmios}/default.nix"; in @@ -50,7 +88,7 @@ in rec { inputMap = { "https://input-output-hk.github.io/cardano-haskell-packages" = inputs.ogmios-CHaP; }; src = haskell-nix.haskellLib.cleanSourceWith { name = "ogmios-src"; - src = inputs.ogmios; + src = ogmiosPatched; subDir = "server"; filter = path: type: builtins.all (x: x) [ (baseNameOf path != "package.yaml") @@ -60,7 +98,7 @@ in rec { ogmios = { x86_64-linux = ogmiosProject.projectCross.musl64.hsPkgs.ogmios.components.exes.ogmios; - x86_64-windows = throw "unimplemented"; + x86_64-windows = ogmiosProject.projectCross.mingwW64.hsPkgs.ogmios.components.exes.ogmios; x86_64-darwin = ogmiosProject.hsPkgs.ogmios.components.exes.ogmios; aarch64-darwin = ogmiosProject.hsPkgs.ogmios.components.exes.ogmios; }.${targetSystem}; diff --git a/nix/lace-blockchain-services/internal/go--windows-StartupInfoLpReserved2.patch b/nix/lace-blockchain-services/internal/go--windows-StartupInfoLpReserved2.patch new file mode 100644 index 0000000000..9b9cf2d670 --- /dev/null +++ b/nix/lace-blockchain-services/internal/go--windows-StartupInfoLpReserved2.patch @@ -0,0 +1,38 @@ +diff --git a/src/syscall/exec_windows.go b/src/syscall/exec_windows.go +index 45295dedff..cae07b0433 100644 +--- a/src/syscall/exec_windows.go ++++ b/src/syscall/exec_windows.go +@@ -250,6 +250,8 @@ type SysProcAttr struct { + NoInheritHandles bool // if set, each inheritable handle in the calling process is not inherited by the new process + AdditionalInheritedHandles []Handle // a list of additional handles, already marked as inheritable, that will be inherited by the new process + ParentProcess Handle // if non-zero, the new process regards the process given by this handle as its parent process, and AdditionalInheritedHandles, if set, should exist in this parent process ++ StartupInfoCbReserved2 uint16 ++ StartupInfoLpReserved2 *byte + } + + var zeroProcAttr ProcAttr +@@ -410,6 +412,9 @@ func StartProcess(argv0 string, argv []string, attr *ProcAttr) (pid int, handle + return 0, 0, err + } + ++ si.CbReserved2 = sys.StartupInfoCbReserved2 ++ si.LpReserved2 = sys.StartupInfoLpReserved2 ++ + pi := new(ProcessInformation) + flags := sys.CreationFlags | CREATE_UNICODE_ENVIRONMENT | _EXTENDED_STARTUPINFO_PRESENT + if sys.Token != 0 { +diff --git a/src/syscall/types_windows.go b/src/syscall/types_windows.go +index 384b5b4f2c..497ccf7357 100644 +--- a/src/syscall/types_windows.go ++++ b/src/syscall/types_windows.go +@@ -483,8 +483,8 @@ type StartupInfo struct { + FillAttribute uint32 + Flags uint32 + ShowWindow uint16 +- _ uint16 +- _ *byte ++ CbReserved2 uint16 ++ LpReserved2 *byte + StdInput Handle + StdOutput Handle + StdErr Handle diff --git a/nix/lace-blockchain-services/internal/lace-blockchain-services/go.mod b/nix/lace-blockchain-services/internal/lace-blockchain-services/go.mod index 6a4316b74d..06a8d89dd2 100644 --- a/nix/lace-blockchain-services/internal/lace-blockchain-services/go.mod +++ b/nix/lace-blockchain-services/internal/lace-blockchain-services/go.mod @@ -29,4 +29,5 @@ require ( github.com/tklauser/numcpus v0.6.0 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/sys v0.8.0 // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/nix/lace-blockchain-services/internal/lace-blockchain-services/go.sum b/nix/lace-blockchain-services/internal/lace-blockchain-services/go.sum index d28d24841f..2b9fd6495c 100644 --- a/nix/lace-blockchain-services/internal/lace-blockchain-services/go.sum +++ b/nix/lace-blockchain-services/internal/lace-blockchain-services/go.sum @@ -73,6 +73,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nix/lace-blockchain-services/internal/lace-blockchain-services/main.go b/nix/lace-blockchain-services/internal/lace-blockchain-services/main.go index 8ddc9144e9..9c6ab76926 100644 --- a/nix/lace-blockchain-services/internal/lace-blockchain-services/main.go +++ b/nix/lace-blockchain-services/internal/lace-blockchain-services/main.go @@ -203,7 +203,7 @@ func setupTrayUI( logFile string, networks []string, ) func() { return func() { - iconData, err := Asset("cardano.png") + iconData, err := Asset("tray-icon") if err != nil { panic(err) } @@ -281,8 +281,12 @@ func setupTrayUI( menuItem := systray.AddMenuItem("", "") menuItem.Disable() go func(component string, statusCh <-chan string, menuItem *systray.MenuItem) { + prevStatus := "" // lessen refreshing, too often causes glitching on Windows for newStatus := range statusCh { - menuItem.SetTitle(component + " · " + newStatus) + if newStatus != prevStatus { + menuItem.SetTitle(component + " · " + newStatus) + prevStatus = newStatus + } } }(component, statusCh, menuItem) } @@ -367,6 +371,9 @@ type ManagedChild struct { LogMonitor func(string) LogModifier func(string) string // e.g. to drop redundant timestamps AfterExit func() + TerminateGracefullyByInheritedFd3 bool // + ForceKillAfter time.Duration // graceful exit timeout, after which we SIGKILL the child + Enabled bool // for isolated tests etc. } type HealthStatus struct { @@ -390,6 +397,13 @@ func manageChildren(comm CommChannels_Manager) { omitSleep := false keepGoing := true + windowsPipeCounter := -1 + mkNewWindowsPipeName := func() string { + windowsPipeCounter += 1 + return fmt.Sprintf("\\\\.\\pipe\\cardano-node-%s.%d.%d", + network, os.Getpid(), windowsPipeCounter) + } + // XXX: we nest a function here, so that we can defer cleanups, and return early on errors etc. for keepGoing { func() { if !firstIteration && !omitSleep { @@ -406,6 +420,17 @@ func manageChildren(comm CommChannels_Manager) { cardanoNodeConfigDir := ourpaths.NetworkConfigDir + sep + network cardanoNodeSocket := ourpaths.WorkDir + sep + network + sep + "cardano-node.socket" + cardanoServicesAvailable := true + if _, err := os.Stat(cardanoServicesDir); os.IsNotExist(err) { + fmt.Printf("%s[%d]: warning: no cardano-services available, will run without them " + + "(No such file or directory: %s)\n", OurLogPrefix, os.Getpid(), cardanoServicesDir) + cardanoServicesAvailable = false + } + + if (runtime.GOOS == "windows") { + cardanoNodeSocket = mkNewWindowsPipeName() + } + var ogmiosPort int var providerServerPort int @@ -417,7 +442,7 @@ func manageChildren(comm CommChannels_Manager) { // XXX: we take that from Ogmios, we should probably calculate ourselves syncProgress := -1.0 - childrenDefs := []ManagedChild{ + childrenDefsAll := []ManagedChild{ func() ManagedChild { hostname, _ := os.Hostname() trimmedHostname := hostname @@ -437,6 +462,7 @@ func manageChildren(comm CommChannels_Manager) { } return line } + reValidatingChunk := regexp.MustCompile(`^.*ChainDB:Info.*Validating chunk no. \d+ out of \d+\. Progress: (\d*\.\d+%)$`) reReplayingLedger := regexp.MustCompile(`^.*ChainDB:Info.*Replayed block: slot \d+ out of \d+\. Progress: (\d*\.\d+%)$`) rePushingLedger := regexp.MustCompile(`^.*ChainDB:Info.*Pushing ledger state for block [0-9a-f]+ at slot \d+. Progress: (\d*\.\d+%)$`) reSyncing := regexp.MustCompile(`^.*ChainDB:Notice.*Chain extended, new tip: [0-9a-f]+ at slot (\d+)$`) @@ -454,12 +480,19 @@ func manageChildren(comm CommChannels_Manager) { "--host-addr", "0.0.0.0", "--config", cardanoNodeConfigDir + sep + "config.json", "--socket-path", cardanoNodeSocket, + "--shutdown-ipc=3", } }, MkExtraEnv: func() []string { return []string{} }, StatusCh: comm.CardanoNodeStatus, HealthProbe: func(prev HealthStatus) HealthStatus { - err := probeUnixSocket(cardanoNodeSocket, 1 * time.Second) + tmout := 1 * time.Second + var err error + if runtime.GOOS == "windows" { + err = probeWindowsNamedPipe(cardanoNodeSocket, tmout) + } else { + err = probeUnixSocket(cardanoNodeSocket, tmout) + } nextProbeIn := 1 * time.Second if (err == nil) { nextProbeIn = 60 * time.Second @@ -472,7 +505,11 @@ func manageChildren(comm CommChannels_Manager) { } }, LogMonitor: func(line string) { - if strings.Index(line, "Started opening Ledger DB") != -1 { + if ms := reValidatingChunk.FindStringSubmatch(line); len(ms) > 0 { + comm.CardanoNodeStatus <- "validating chunks · " + ms[1] + } else if strings.Index(line, "Started opening Volatile DB") != -1 { + comm.CardanoNodeStatus <- "opening volatile DB…" + } else if strings.Index(line, "Started opening Ledger DB") != -1 { comm.CardanoNodeStatus <- "opening ledger DB…" } else if ms :=reReplayingLedger.FindStringSubmatch(line);len(ms)>0 { comm.CardanoNodeStatus <- "replaying ledger · " + ms[1] @@ -497,9 +534,16 @@ func manageChildren(comm CommChannels_Manager) { line = removeTimestamp(line, now) line = removeTimestamp(line, now.Add(-1 * time.Second)) line = strings.ReplaceAll(line, droppedHostname, "[") + if (runtime.GOOS == "windows") { + // garbled output on cmd.exe instead: + line = stripansi.Strip(line) + } return line }, AfterExit: func() {}, + TerminateGracefullyByInheritedFd3: true, + ForceKillAfter: 10 * time.Second, + Enabled: true, } }(), func() ManagedChild { @@ -547,6 +591,9 @@ func manageChildren(comm CommChannels_Manager) { AfterExit: func() { comm.SetOgmiosDashboard <- "" }, + TerminateGracefullyByInheritedFd3: false, + ForceKillAfter: 5 * time.Second, + Enabled: true, } }(), func() ManagedChild { @@ -602,6 +649,9 @@ func manageChildren(comm CommChannels_Manager) { AfterExit: func() { comm.SetBackendUrl <- "" }, + TerminateGracefullyByInheritedFd3: false, + ForceKillAfter: 5 * time.Second, + Enabled: cardanoServicesAvailable, } }(), } @@ -614,7 +664,7 @@ func manageChildren(comm CommChannels_Manager) { } comm.BlockRestartUI <- true wgChildren.Wait() - for _, child := range childrenDefs { + for _, child := range childrenDefsAll { // Reset all statuses to "off" (not all children might’ve been started // and they’re "waiting" now) child.StatusCh <- "off" @@ -622,6 +672,13 @@ func manageChildren(comm CommChannels_Manager) { fmt.Printf("%s[%d]: session ended for network %s\n", OurLogPrefix, os.Getpid(), networkMemo) }("" + network) + var childrenDefs []ManagedChild + for _, childUnsafe := range childrenDefsAll { + if childUnsafe.Enabled { + childrenDefs = append(childrenDefs, childUnsafe) + } + } + anyChildExitedCh := make(chan struct{}, len(childrenDefs)) for childIdx, childUnsafe := range childrenDefs { @@ -637,7 +694,9 @@ func manageChildren(comm CommChannels_Manager) { childDidExit := false childPid := 0 go childProcess(child.ExePath, child.MkArgv(), child.MkExtraEnv(), - child.LogModifier, outputLines, terminateCh, &childPid) + child.LogModifier, outputLines, terminateCh, &childPid, + child.TerminateGracefullyByInheritedFd3, + child.ForceKillAfter) defer func() { if !childDidExit { child.StatusCh <- "terminating…" @@ -802,49 +861,80 @@ func childProcess( path string, argv []string, extraEnv []string, logModifier func(string) string, // e.g. to drop redundant timestamps outputLines chan<- string, terminate <-chan struct{}, pid *int, + terminateGracefullyByInheritedFd3 bool, + gracefulExitTimeout time.Duration, ) { defer close(outputLines) + var terminationPipeReader *os.File + var terminationPipeWriter *os.File + if terminateGracefullyByInheritedFd3 { + var err error + terminationPipeReader, terminationPipeWriter, err = os.Pipe() + if err != nil { + outputLines <- fmt.Sprintf("fatal: %v", err) + return + } + } + var wgOuts sync.WaitGroup wgOuts.Add(2) cmd := exec.Command(path, argv...) + setManagedChildSysProcAttr(cmd) + if len(extraEnv) > 0 { cmd.Env = append(os.Environ(), extraEnv...) } - stderr, err := cmd.StderrPipe() + // Starting a process with no stdin can confuse it: + stdin, err := cmd.StdinPipe() + if err != nil { + outputLines <- fmt.Sprintf("fatal: %s", err) + return + } + defer stdin.Close() + + stdout, err := cmd.StdoutPipe() if err != nil { outputLines <- fmt.Sprintf("fatal: %s", err) return } go func() { - scanner := bufio.NewScanner(stderr) + scanner := bufio.NewScanner(stdout) for scanner.Scan() { - outputLines <- "[stderr] " + logModifier(scanner.Text()) + outputLines <- logModifier(scanner.Text()) } wgOuts.Done() }() - stdout, err := cmd.StdoutPipe() + stderr, err := cmd.StderrPipe() if err != nil { outputLines <- fmt.Sprintf("fatal: %s", err) return } go func() { - scanner := bufio.NewScanner(stdout) + scanner := bufio.NewScanner(stderr) for scanner.Scan() { - outputLines <- logModifier(scanner.Text()) + outputLines <- "[stderr] " + logModifier(scanner.Text()) } wgOuts.Done() }() + if terminateGracefullyByInheritedFd3 { + inheritExtraFiles(cmd, []*os.File{terminationPipeReader}) + } + if err := cmd.Start(); err != nil { outputLines <- fmt.Sprintf("fatal: %s", err) return } + if terminateGracefullyByInheritedFd3 { + terminationPipeReader.Close() // close child’s end in our process + } + if (pid != nil) { *pid = cmd.Process.Pid } @@ -858,13 +948,36 @@ func childProcess( select { case <-terminate: - if runtime.GOOS == "windows" { - // FIXME: how to exit gracefully on Windows? - cmd.Process.Kill() + if terminateGracefullyByInheritedFd3 { + fmt.Printf("%s[%d]: closing shutdown IPC pipe (fd %v) of %s[%d]\n", + OurLogPrefix, os.Getpid(), terminationPipeWriter.Fd(), + filepath.Base(path), cmd.Process.Pid) + terminationPipeWriter.Close() + } else if runtime.GOOS == "windows" { + fmt.Printf("%s[%d]: sending CTRL_BREAK_EVENT to %s[%d]\n", + OurLogPrefix, os.Getpid(), filepath.Base(path), cmd.Process.Pid) + windowsSendCtrlBreak(cmd.Process.Pid) } else { + fmt.Printf("%s[%d]: sending SIGTERM to %s[%d]\n", + OurLogPrefix, os.Getpid(), filepath.Base(path), cmd.Process.Pid) cmd.Process.Signal(syscall.SIGTERM) } - <-waitDone + + doForceKill := make(chan struct{}, 1) + go func() { + time.Sleep(gracefulExitTimeout) + doForceKill <- struct{}{} + }() + select { + case <-doForceKill: + fmt.Printf("%s[%d]: %s[%d] did not exit gracefully in %s, killing it forcefully...\n", + OurLogPrefix, os.Getpid(), + filepath.Base(path), cmd.Process.Pid, gracefulExitTimeout) + // In a rare event that it hangs, we cannot afford a deadlock here: + go cmd.Process.Kill() + <-waitDone + case <-waitDone: + } case <-waitDone: } } @@ -882,8 +995,6 @@ func getFreeTCPPort() int { } func probeUnixSocket(path string, timeout time.Duration) error { - // XXX: for Windows named pipes this would be: - // net.DialTimeout("pipe", `\\.\pipe\mypipe`, timeout) conn, err := net.DialTimeout("unix", path, timeout) if err == nil { defer conn.Close() @@ -913,11 +1024,21 @@ func openWithDefaultApp(target string) error { case "darwin": cmd = exec.Command("open", target) case "windows": - cmd = exec.Command("cmd", "/c", "start", target) + // XXX: there’s this "cmd.exe /c start ${target}" thing, but if we don’t pass our targets + // through "explorer.exe" instead, we can’t open the log file that’s currently being written to + // Note: don’t HideWindow, because then when opening the logs directory, it won’t show any window + cmd = exec.Command("explorer.exe", target) default: panic("cannot happen, unknown OS: " + runtime.GOOS) } - return cmd.Run() + err := cmd.Run() + + if err != nil { + fmt.Printf("%s[%d]: error: failed to open '%s' with a default app: %s\n", + OurLogPrefix, os.Getpid(), target, err) + } + + return err } func logSystemHealth() { diff --git a/nix/lace-blockchain-services/internal/lace-blockchain-services/main_fd_inheritance_windows.go b/nix/lace-blockchain-services/internal/lace-blockchain-services/main_fd_inheritance_windows.go new file mode 100644 index 0000000000..b287ea14e1 --- /dev/null +++ b/nix/lace-blockchain-services/internal/lace-blockchain-services/main_fd_inheritance_windows.go @@ -0,0 +1,105 @@ +// +build windows + +package main + +import ( + "encoding/binary" + "syscall" + "unsafe" +) + +// XXX: there is an undocumented API on Windows that enables you to pass something similar +// to standard POSIX file descriptors, by providing a mapping between true Windows file +// `Handle`s and those small fd numbers. The child can then use `_get_osfhandle` to turn 0 +// back to a Handle for stdin, 1 to stdout etc., including additional inherited Handles +// See +// See +// The official documentation lies that `cbReserved2` must be zero, and `lpReserved2` must be NULL. +// +// Reference implementation: +func mkStartupInfoLpReserved2(handlesToMap []syscall.Handle) []byte { + // + // #define CHILD_STDIO_SIZE(count) \ + // (sizeof(int) + \ + // sizeof(unsigned char) * (count) + \ + // sizeof(uintptr_t) * (count)) + bufSize := unsafe.Sizeof(int32(0)) + + uintptr(len(handlesToMap)) * unsafe.Sizeof(uint8(0)) + + uintptr(len(handlesToMap)) * unsafe.Sizeof(uintptr(0)) + buf := make([]byte, bufSize) + + if len(handlesToMap) < 3 { + panic("in lpReserved2, you have to pass at least stdin, stdout, and stderr (in this order)") + } + + if len(handlesToMap) > 255 { + panic("we cannnot map more than 255 handles in lpReserved2") + } + + binary.LittleEndian.PutUint32(buf, uint32(len(handlesToMap))) + + FOPEN := uint8(0x01) + FEOFLAG := uint8(0x02) + FCRLF := uint8(0x04) + FPIPE := uint8(0x08) + FNOINHERIT := uint8(0x10) + FAPPEND := uint8(0x20) + FDEV := uint8(0x40) + FTEXT := uint8(0x80) + + // Silence "declared and not used" + _ = []byte{FOPEN, FEOFLAG, FCRLF, FPIPE, FNOINHERIT, FAPPEND, FDEV, FTEXT} + + setFlags := func(idx int, value uint8) { + // #define CHILD_STDIO_CRT_FLAGS(buffer, fd) \ + // *((unsigned char*) (buffer) + sizeof(int) + fd) + position := unsafe.Sizeof(int32(0)) + uintptr(idx) + buf[position] = value + } + + setHandle := func(idx int, value syscall.Handle) { + // #define CHILD_STDIO_HANDLE(buffer, fd) \ + // *((HANDLE*) ((unsigned char*) (buffer) + \ + // sizeof(int) + \ + // sizeof(unsigned char) * \ + // CHILD_STDIO_COUNT((buffer)) + \ + // sizeof(HANDLE) * (fd))) + position := unsafe.Sizeof(int32(0)) + + uintptr(len(handlesToMap)) * unsafe.Sizeof(uint8(0)) + + uintptr(idx) * unsafe.Sizeof(uintptr(0)) + binary.LittleEndian.PutUint64(buf[position:], uint64(uintptr(value))) + } + + for idx, handle := range handlesToMap { + // XXX: if we passed something else than an os.Pipe() end, the flags would likely have to change! + if idx <= 2 { + setFlags(idx, FOPEN | FDEV) + } else { + setFlags(idx, FOPEN | FPIPE) + } + setHandle(idx, handle) + } + + return buf +} + +func _get_osfhandle(fd uintptr) (uintptr, error) { + // Windows SDK → Include/10.0.19041.0/um/handleapi.h contains: + // #define INVALID_HANDLE_VALUE ((HANDLE)(LONG_PTR)-1) + // ^ means maximum value, so the same bits as casting a -1 to uint + var INVALID_HANDLE_VALUE = ^uintptr(0) + + dll, err := syscall.LoadDLL("msvcrt.dll") + if err != nil { + return INVALID_HANDLE_VALUE, err + } + proc, err := dll.FindProc("_get_osfhandle") + if err != nil { + return INVALID_HANDLE_VALUE, err + } + rv, _, err := proc.Call(fd) + if rv == INVALID_HANDLE_VALUE { + return INVALID_HANDLE_VALUE, err + } + return rv, nil +} diff --git a/nix/lace-blockchain-services/internal/lace-blockchain-services/main_unix.go b/nix/lace-blockchain-services/internal/lace-blockchain-services/main_unix.go new file mode 100644 index 0000000000..7875cda393 --- /dev/null +++ b/nix/lace-blockchain-services/internal/lace-blockchain-services/main_unix.go @@ -0,0 +1,25 @@ +// +build !windows + +package main + +import ( + "os" + "os/exec" + "time" + "errors" +) + +func probeWindowsNamedPipe(path string, timeout time.Duration) error { + return errors.New("probeWindowsNamedPipe is only supported on Windows") +} + +func setManagedChildSysProcAttr(cmd *exec.Cmd) { +} + +func windowsSendCtrlBreak(pid int) { + panic("windowsSendCtrlBreak is only supported on Windows") +} + +func inheritExtraFiles(cmd *exec.Cmd, extraFiles []*os.File) { + cmd.ExtraFiles = extraFiles +} diff --git a/nix/lace-blockchain-services/internal/lace-blockchain-services/main_windows.go b/nix/lace-blockchain-services/internal/lace-blockchain-services/main_windows.go new file mode 100644 index 0000000000..ceb0e892fa --- /dev/null +++ b/nix/lace-blockchain-services/internal/lace-blockchain-services/main_windows.go @@ -0,0 +1,104 @@ +// +build windows + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" + + "lace.io/lace-blockchain-services/ourpaths" + + "gopkg.in/natefinch/npipe.v2" +) + +func probeWindowsNamedPipe(path string, timeout time.Duration) error { + conn, err := npipe.DialTimeout(path, timeout) + if err == nil { + defer conn.Close() + } + + return err +} + +// XXX: Reading: +// · https://learn.microsoft.com/en-us/windows/console/generateconsolectrlevent +// · https://learn.microsoft.com/en-us/windows/console/ctrl-c-and-ctrl-break-signals +// · https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags +// · https://learn.microsoft.com/en-us/windows/console/creation-of-a-console +// · https://learn.microsoft.com/en-us/windows/console/setconsolectrlhandler +// · https://learn.microsoft.com/en-us/windows/console/registering-a-control-handler-function +// · https://github.com/ElyDotDev/windows-kill +// · https://github.com/walware/statet/blob/master/de.walware.statet.r.console.core/cppSendSignal/sendsignal.cpp +// · https://github.com/vim/vim/blob/master/src/os_win32.c +// · https://github.com/input-output-hk/cardano-node/issues/726 +// · https://github.com/input-output-hk/cardano-launcher/blob/master/docs/windows-clean-shutdown.md + +func setManagedChildSysProcAttr(cmd *exec.Cmd) { + // XXX: we have to create it in a new process group, because Ctrl-Break "signals" + // are sent to the whole process group on Windows, and we don’t want to kill ourselves. + // Ctrl+C will be ignored by processes in the new process groups + // + // HideWindow is not CREATE_NO_WINDOW (that won’t work with "signals"), + // but STARTF_USESHOWWINDOW, and ShowWindow = SW_HIDE + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP + cmd.SysProcAttr.HideWindow = true +} + +// XXX: we can’t make these WinAPI calls from the current process, as they change to much regarding +// consoles attached to processes. Let’s offload them to a small C program: +func windowsSendCtrlBreak(pid int) { + path := ourpaths.LibexecDir + string(filepath.Separator) + "sigbreak.exe" + var cmd *exec.Cmd + cmd = exec.Command(path, "break", fmt.Sprintf("%d", pid)) + cmd.SysProcAttr = &syscall.SysProcAttr{ + // XXX: if we don’t create a new console for sigbreak.exe, the signal not always lands + CreationFlags: 0x00000010, // CREATE_NEW_CONSOLE not defined in syscall.* + HideWindow: true, + } + err := cmd.Run() + if err != nil { + fmt.Printf("%s[%d]: error: failed to run '%s': %v\n", + OurLogPrefix, os.Getpid(), filepath.Base(path), err) + } +} + +func inheritExtraFiles(cmd *exec.Cmd, extraFiles []*os.File) { + handles := []syscall.Handle{} + for _, file := range extraFiles { + handles = append(handles, syscall.Handle(file.Fd())) + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.NoInheritHandles = false + cmd.SysProcAttr.AdditionalInheritedHandles = handles + + // This is needed so that the child can see our extra handles under stable fd=3,4,5… (`_get_osfhandle(3)`). + // See documentation for `mkStartupInfoLpReserved2` for more details. + handleMapping := []syscall.Handle{} + appendFD := func(stream interface{}) { + if file, ok := stream.(*os.File); ok { + handleMapping = append(handleMapping, syscall.Handle(file.Fd())) + } else { + handleMapping = append(handleMapping, syscall.InvalidHandle) + } + } + appendFD(cmd.Stdin) + appendFD(cmd.Stdout) + appendFD(cmd.Stderr) + for _, file := range extraFiles { + appendFD(file) + } + + lpReserved2 := mkStartupInfoLpReserved2(handleMapping) + + cmd.SysProcAttr.StartupInfoCbReserved2 = uint16(len(lpReserved2)) + cmd.SysProcAttr.StartupInfoLpReserved2 = &lpReserved2[0] +} diff --git a/nix/lace-blockchain-services/internal/make-windows-installer/WindowsInstaller.hs b/nix/lace-blockchain-services/internal/make-windows-installer/WindowsInstaller.hs new file mode 100644 index 0000000000..9a2edfd22b --- /dev/null +++ b/nix/lace-blockchain-services/internal/make-windows-installer/WindowsInstaller.hs @@ -0,0 +1,225 @@ +{-# LANGUAGE RecordWildCards, LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Main + ( main + , writeInstallerNSIS + , writeUninstallerNSIS + ) where + +import Universum hiding (pass, writeFile, stdout, FilePath, die, view) + +import qualified Data.List +import qualified Data.Text as T +import Development.NSIS (Attrib (IconFile, IconIndex, RebootOK, Recursive, Required, StartOptions, Target), + HKEY (HKLM), Level (Highest), Page (Directory, InstFiles), abort, + constant, constantStr, createDirectory, createShortcut, delete, + deleteRegKey, file, iff_, installDir, installDirRegKey, + name, nsis, onPagePre, onError, outFile, page, readRegStr, + requestExecutionLevel, rmdir, section, setOutPath, str, + strLength, uninstall, unsafeInject, unsafeInjectGlobal, + loadLanguage, sleep, (@=), detailPrint, (%<), (%&&), + not_, mutableInt_, mutable_, while, false, true, strShow, (&), + writeRegDWORD, writeRegStr, (%/=), fileExists) +import Prelude ((!!)) +import qualified System.IO as IO +import qualified Options.Applicative as O + + +data CliOptions = CliOptions + { cliSpacedName :: Text + , cliInstallDirectory :: Text + , cliFullVersion :: Text + , cliOutName :: Text + , cliIconPath :: Text + , cliBannerBmp :: Text + , cliLockFile :: Text + , cliContentsDirectory :: Text + , cliShortcutExe :: Text + } + +cliOptionsParser :: O.Parser CliOptions +cliOptionsParser = CliOptions + <$> O.strOption (O.long "spaced-name" <> O.metavar "NAME" {- <> O.help "" -}) + <*> O.strOption (O.long "install-dir" <> O.metavar "DIR") + <*> O.strOption (O.long "full-version" <> O.metavar "VER") + <*> O.strOption (O.long "out-name" <> O.metavar "NAME") + <*> O.strOption (O.long "icon-path" <> O.metavar "ICO") + <*> O.strOption (O.long "banner-bmp" <> O.metavar "BMP") + <*> O.strOption (O.long "lock-file" <> O.metavar "FILE") + <*> O.strOption (O.long "contents-dir" <> O.metavar "DIR") + <*> O.strOption (O.long "shortcut-exe" <> O.metavar "EXE") + +main :: IO () +main = do + cliOptions <- O.execParser (O.info (cliOptionsParser <**> O.helper) O.fullDesc) + writeUninstallerNSIS cliOptions + writeInstallerNSIS cliOptions + +------------------- adapted from Daedalus: ------------------- + +-- For use in `unsafeInject` and `unsafeInjectGlobal`. +-- Based on +escapeLiteralString :: String -> String +escapeLiteralString s = "\"" <> concatMap g s <> "\"" + where + g '\"' = "$\\\"" + g '\r' = "$\\r" + g '\n' = "$\\n" + g '\t' = "$\\t" + g '$' = "$$" + g x = [x] + +desktopShortcut :: CliOptions -> [Attrib] +desktopShortcut CliOptions{..} = + [ Target $ fromString $ T.unpack $ "$INSTDIR\\" <> cliShortcutExe + , IconFile $ fromString $ T.unpack $ "$INSTDIR\\" <> cliShortcutExe + , StartOptions "SW_SHOWMINIMIZED" + , IconIndex 0 + ] + +-- See INNER blocks at http://nsis.sourceforge.net/Signing_an_Uninstaller +writeUninstallerNSIS :: CliOptions -> IO () +writeUninstallerNSIS CliOptions{..} = + IO.writeFile "uninstaller.nsi" $ nsis $ do + _ <- constantStr "Version" (str $ T.unpack cliFullVersion) + _ <- constantStr "InstallDir" (str $ T.unpack $ cliInstallDirectory) + _ <- constantStr "SpacedName" (str $ T.unpack $ cliSpacedName) + unsafeInjectGlobal "Unicode true" + + loadLanguage "English" + loadLanguage "Japanese" + + name "$SpacedName Uninstaller $Version" + outFile "tempinstaller.exe" + unsafeInjectGlobal "SetCompress off" + + _ <- section "" [Required] $ do + unsafeInject "WriteUninstaller \"c:\\uninstall.exe\"" + + uninstall $ do + -- Remove registry keys + deleteRegKey HKLM "Software/Microsoft/Windows/CurrentVersion/Uninstall/$SpacedName" + deleteRegKey HKLM "Software/$SpacedName" + rmdir [Recursive,RebootOK] "$INSTDIR" + delete [] "$SMPROGRAMS/$SpacedName/*.*" + delete [] "$DESKTOP\\$SpacedName.lnk" + -- Note: we leave user data alone + +parseVersion :: Text -> [String] +parseVersion ver = + case T.split (== '.') (toText ver) of + v@[_, _, _, _] -> map toString v + _ -> ["0", "0", "0", "0"] + +writeInstallerNSIS :: CliOptions -> IO () +writeInstallerNSIS CliOptions{..} = do + let fullVersion = T.unpack cliFullVersion + viProductVersion = Data.List.intercalate "." $ parseVersion cliFullVersion + putStrLn $ "VIProductVersion: " <> T.pack viProductVersion + putStrLn $ "escaped cliIconPath: " <> (T.pack . escapeLiteralString . T.unpack) cliIconPath + + IO.writeFile "installer.nsi" $ nsis $ do + _ <- constantStr "Version" (str fullVersion) + _ <- constantStr "InstallDir" (str $ T.unpack cliInstallDirectory) + _ <- constantStr "SpacedName" (str $ T.unpack cliSpacedName) + name "$SpacedName ($Version)" -- The name of the installer + outFile $ str $ T.unpack cliOutName -- Where to produce the installer + unsafeInjectGlobal $ "!define MUI_ICON " <> (escapeLiteralString . T.unpack) cliIconPath + unsafeInjectGlobal $ "!define MUI_HEADERIMAGE" + unsafeInjectGlobal $ "!define MUI_HEADERIMAGE_BITMAP " <> (escapeLiteralString . T.unpack) cliBannerBmp + unsafeInjectGlobal $ "!define MUI_HEADERIMAGE_RIGHT" + unsafeInjectGlobal $ "!include WinVer.nsh" + unsafeInjectGlobal $ "VIProductVersion " <> viProductVersion + unsafeInjectGlobal $ "VIAddVersionKey \"ProductVersion\" " <> fullVersion + unsafeInjectGlobal "Unicode true" + requestExecutionLevel Highest -- TODO: needed? + + installDir "$PROGRAMFILES64\\$SpacedName" -- Default installation directory... + installDirRegKey HKLM "Software/$SpacedName" "Install_Dir" -- ...except when already installed. + + loadLanguage "English" + loadLanguage "Japanese" + mapM_ unsafeInjectGlobal + [ "LangString AlreadyRunning ${LANG_ENGLISH} \"is running. It needs to be fully shut down before running the installer!\"" + , "LangString AlreadyRunning ${LANG_JAPANESE} \"が起動中です。 インストーラーを実行する前に完全にシャットダウンする必要があります!\"" + , "LangString TooOld ${LANG_ENGLISH} \"This version of Windows is not supported. Windows 8.1 or above required.\"" + , "LangString TooOld ${LANG_JAPANESE} \"このWindowsバージョンはサポートされていません。Windows 8.1以降が必要です。\"" + ] + + mapM_ unsafeInject [ + "${IfNot} ${AtLeastWin8.1}" + , " MessageBox MB_OK \"$(TooOld)\"" + , " Quit" + , "${EndIf}" + ] + + page Directory -- Pick where to install + _ <- constant "INSTALLEDAT" $ readRegStr HKLM "Software/$SpacedName" "Install_Dir" + onPagePre Directory (iff_ (strLength "$INSTALLEDAT" %/= 0) $ abort "") + + page InstFiles -- Give a progress bar while installing + + _ <- section "" [Required] $ do + setOutPath "$INSTDIR" -- Where to install files in this section + unsafeInject "AllowSkipFiles off" + writeRegStr HKLM "Software/$SpacedName" "Install_Dir" "$INSTDIR" -- Used by launcher batch script + + -- XXX: sometimes during auto-update, it takes longer for the app to exit, + -- and cardano-launcher.exe’s lockfile to be unlocked (deletable), so + -- let’s loop waiting for this to happen: + let waitSeconds = 30 + lockfileCounter <- mutableInt_ 0 + lockfileDeleted <- mutable_ false + while ((lockfileCounter %< waitSeconds) %&& (not_ lockfileDeleted)) $ do + detailPrint ( + "Checking if " + Development.NSIS.& str (T.unpack cliSpacedName) + Development.NSIS.& " is not running (" + Development.NSIS.& strShow (lockfileCounter + 1) + Development.NSIS.& "/" + Development.NSIS.& strShow waitSeconds + Development.NSIS.& ")..." + ) + lockfileDeleted @= true + onError (delete [] (fromString . T.unpack $ cliLockFile)) $ do + lockfileDeleted @= false + iff_ (not_ lockfileDeleted) $ do + sleep 1000 -- milliseconds + lockfileCounter @= lockfileCounter + 1 + iff_ (not_ (lockfileDeleted)) $ do + unsafeInject $ T.unpack $ "Abort \"" <> cliInstallDirectory <> " $(AlreadyRunning)\"" + + iff_ (fileExists "$INSTDIR") $ do + detailPrint "Removing previously installed version" + rmdir [Recursive] "$INSTDIR" + + file [Recursive] (str $ T.unpack cliContentsDirectory) + + createShortcut "$DESKTOP\\$SpacedName.lnk" (desktopShortcut CliOptions{..}) + + -- Uninstaller + let + uninstallKey = "Software/Microsoft/Windows/CurrentVersion/Uninstall/$SpacedName" + do + writeRegStr HKLM uninstallKey "InstallLocation" "$INSTDIR" + writeRegStr HKLM uninstallKey "Publisher" "IOHK" + writeRegStr HKLM uninstallKey "ProductVersion" (str fullVersion) + writeRegStr HKLM uninstallKey "VersionMajor" (str . (!! 0). parseVersion $ cliFullVersion) + writeRegStr HKLM uninstallKey "VersionMinor" (str . (!! 1). parseVersion $ cliFullVersion) + writeRegStr HKLM uninstallKey "DisplayName" "$SpacedName" + writeRegStr HKLM uninstallKey "DisplayVersion" (str fullVersion) + writeRegStr HKLM uninstallKey "UninstallString" "\"$INSTDIR/uninstall.exe\"" + writeRegStr HKLM uninstallKey "QuietUninstallString" "\"$INSTDIR/uninstall.exe\" /S" + writeRegDWORD HKLM uninstallKey "NoModify" 1 + writeRegDWORD HKLM uninstallKey "NoRepair" 1 + file [] "uninstall.exe" + + -- this string never appears in the UI + _ <- section "Start Menu Shortcuts" [] $ do + createDirectory "$SMPROGRAMS/$SpacedName" + createShortcut "$SMPROGRAMS/$SpacedName/Uninstall $SpacedName.lnk" + [Target "$INSTDIR/uninstall.exe", IconFile "$INSTDIR/uninstall.exe", IconIndex 0] + createShortcut "$SMPROGRAMS/$SpacedName/$SpacedName.lnk" (desktopShortcut CliOptions{..}) + return () diff --git a/nix/lace-blockchain-services/internal/make-windows-installer/cabal.project b/nix/lace-blockchain-services/internal/make-windows-installer/cabal.project new file mode 100644 index 0000000000..694d0846b0 --- /dev/null +++ b/nix/lace-blockchain-services/internal/make-windows-installer/cabal.project @@ -0,0 +1,9 @@ +packages: . + +write-ghc-environment-files: never + +source-repository-package + type: git + location: https://github.com/input-output-hk/haskell-nsis + tag: 016a122d845849b14711e0f86805a4748ec44584 + --sha256: 14phxmmzncq63aahpw6f4sfmkf3cy075zjfyzhv2689csjwivm8y diff --git a/nix/lace-blockchain-services/internal/make-windows-installer/make-windows-installer.cabal b/nix/lace-blockchain-services/internal/make-windows-installer/make-windows-installer.cabal new file mode 100644 index 0000000000..618bf8fd92 --- /dev/null +++ b/nix/lace-blockchain-services/internal/make-windows-installer/make-windows-installer.cabal @@ -0,0 +1,22 @@ +name: make-windows-installer +version: 0.1.0.0 +build-type: Simple +cabal-version: >=1.10 + +executable make-windows-installer + main-is: WindowsInstaller.hs + build-depends: base + , nsis + , optparse-applicative + , text + , universum + + default-language: Haskell2010 + ghc-options: -threaded -rtsopts + -Wextra -Werror -Wno-unused-do-bind + -fno-warn-orphans + -with-rtsopts=-N + -O2 + + default-extensions: NoImplicitPrelude + OverloadedStrings diff --git a/nix/lace-blockchain-services/internal/nsis.nix b/nix/lace-blockchain-services/internal/nsis.nix new file mode 100644 index 0000000000..935b5fc0eb --- /dev/null +++ b/nix/lace-blockchain-services/internal/nsis.nix @@ -0,0 +1,68 @@ +{ nsisNixpkgs }: + +# the native makensis binary, with cross-compiled windows stubs + +let + nsisPkgs = import nsisNixpkgs { system = "x86_64-linux"; }; + + nsis = { pkgs }: let + arch = "x86"; + buildGcc = pkgs.gcc; + crossPkgs = import pkgs.path { + crossSystem = pkgs.lib.systems.examples."${if arch == "x86" then "mingw32" else "mingwW64"}"; + localSystem.system = pkgs.system; + }; + in crossPkgs.callPackage nsisInner { inherit arch buildGcc; }; + + nsisInner = + { stdenv, lib, fetchurl, scons, gcc, zlib, arch, buildGcc, buildPackages }: + let + version = "3.03"; + sconsArgs = lib.concatStringsSep "\" \"" [ + "STRIP=0" + "ZLIB_W32=${zlibJoin}" + "TARGET_ARCH=${arch}" + "PREFIX=$out" + # TODO: note: building zip2exe fails due to zlib linking issues. + "SKIPUTILS=NSIS Menu,zip2exe" + "VERSION=${version}" + "CC=${buildGcc}/bin/gcc" + "CXX=${buildGcc}/bin/g++" + "VERBOSE=1" + "XGCC_W32_PREFIX=${if arch == "x86" then "i686" else "x86_64"}-pc-mingw32-" + "APPEND_CPPPATH=${buildPackages.zlib.dev}/include" + "APPEND_LIBPATH=${buildPackages.zlib}/lib" + "PATH=$PATH" + ]; + # the NSIS SCons script expects to find the `.dll` in /lib. + # it also expects include and lib to be together. + zlibJoin = buildPackages.buildEnv { + name = "full-zlib"; + paths = [ zlib.dev zlib ]; + postBuild = '' + ln -sv $out/bin/*.dll $out/lib + ''; + }; + in stdenv.mkDerivation { + name = "nsis"; + src = fetchurl { + url = "https://downloads.sourceforge.net/project/nsis/NSIS%203/${version}/nsis-${version}-src.tar.bz2"; + sha256 = "abae7f4488bc6de7a4dd760d5f0e7cd3aad7747d4d7cd85786697c8991695eaa"; + }; + + nativeBuildInputs = [ scons ]; + + buildPhase = '' + scons makensis "${sconsArgs}" + ''; + installPhase = '' + scons install "${sconsArgs}" + ''; + + meta = with lib; { + description = "System to create Windows installers"; + homepage = "https://nsis.sourceforge.io/"; + }; + }; + +in nsisPkgs.callPackage nsis {} diff --git a/nix/lace-blockchain-services/internal/ogmios--on-windows.patch b/nix/lace-blockchain-services/internal/ogmios--on-windows.patch new file mode 100644 index 0000000000..b643f688d6 --- /dev/null +++ b/nix/lace-blockchain-services/internal/ogmios--on-windows.patch @@ -0,0 +1,87 @@ +diff --git a/server/ogmios.cabal b/server/ogmios.cabal +index fc0d752f1..44e3c7b8e 100644 +--- a/server/ogmios.cabal ++++ b/server/ogmios.cabal +@@ -33,6 +33,10 @@ flag production + manual: True + default: False + ++common maybe-unix ++ if !os(windows) ++ build-depends: unix ++ + library + exposed-modules: + Ogmios +@@ -121,6 +125,8 @@ library + ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -Wunused-packages + build-tools: + git ++ import: ++ maybe-unix + build-depends: + aeson + , array +@@ -192,7 +198,6 @@ library + , time-manager + , transformers + , typed-protocols +- , unix + , unordered-containers + , vector + , wai +diff --git a/server/src/Ogmios.hs b/server/src/Ogmios.hs +index c48cbadc8..75f51720b 100644 +--- a/server/src/Ogmios.hs ++++ b/server/src/Ogmios.hs +@@ -2,6 +2,7 @@ + -- License, v. 2.0. If a copy of the MPL was not distributed with this + -- file, You can obtain one at http://mozilla.org/MPL/2.0/. + ++{-# LANGUAGE CPP #-} + {-# LANGUAGE DerivingVia #-} + {-# LANGUAGE GeneralizedNewtypeDeriving #-} + {-# LANGUAGE TypeApplications #-} +@@ -104,6 +105,7 @@ import Ogmios.Options + import Ogmios.Version + ( version + ) ++#ifdef UNIX + import System.Posix.Signals + ( Handler (..) + , installHandler +@@ -111,6 +113,7 @@ import System.Posix.Signals + , raiseSignal + , softwareTermination + ) ++#endif + + import qualified Control.Concurrent.Async as Async + import qualified Control.Concurrent.STM.TBQueue as STM +@@ -167,9 +170,13 @@ application tracers = hijackSigTerm >> withDebouncer _10s (\debouncer -> do + -- This function install handlers for SIGTERM and turn them into SIGINT. + hijackSigTerm :: App () + hijackSigTerm = ++#ifdef UNIX + liftIO $ void (installHandler softwareTermination handler empty) + where + handler = CatchOnce (raiseSignal keyboardSignal) ++#else ++ pure () ++#endif + + -- + -- Environment +diff --git a/server/src/Ogmios/Options.hs b/server/src/Ogmios/Options.hs +index 52905f2f8..a4138904f 100644 +--- a/server/src/Ogmios/Options.hs ++++ b/server/src/Ogmios/Options.hs +@@ -87,7 +87,7 @@ import Options.Applicative.Help.Pretty + import Safe + ( readMay + ) +-import System.FilePath.Posix ++import System.FilePath + ( replaceFileName + ) + diff --git a/nix/lace-blockchain-services/internal/sigbreak.cc b/nix/lace-blockchain-services/internal/sigbreak.cc new file mode 100644 index 0000000000..b5e0296ae5 --- /dev/null +++ b/nix/lace-blockchain-services/internal/sigbreak.cc @@ -0,0 +1,52 @@ +#include +#include +#include +#include + +// XXX: it’s not possible to ignore Ctrl+Break, so we have to explicitly ignore it in our own +// registered handler, or else sigbreak.exe will return exit code 0xc000013a. In this handler, +// TRUE means we handled it, and FALSE passes the event to the next registered handler (the +// default one in this case). + +BOOL WINAPI IgnoringCtrlHandler(DWORD fdwCtrlType) { + switch (fdwCtrlType) { + + case CTRL_C_EVENT: + printf("; sigbreak.exe got Ctrl-C, ignoring\n"); + return TRUE; + + case CTRL_BREAK_EVENT: + printf("; sigbreak.exe got Ctrl-Break, ignoring\n"); + return TRUE; + + default: + return FALSE; + + } +} + +void usage(char *exe) { + printf("usage: %s break|interrupt \n", exe); + printf("\n"); + printf("Note: for this to work, the process of PID has to be started with the CREATE_NEW_PROCESS_GROUP flag!\n"); + exit(1); +} + +int main (int argc, char **argv) { + if (argc != 3) usage(argv[0]); + + DWORD dwProcessGroupId = strtoul(argv[2], NULL, 10); + DWORD dwCtrlEvent; + + if (!strcmp("break", argv[1])) dwCtrlEvent = CTRL_BREAK_EVENT; + else if (!strcmp("interrupt", argv[1])) dwCtrlEvent = CTRL_C_EVENT; + else usage(argv[0]); + + if (!FreeConsole()) return 2; + if (!AttachConsole(dwProcessGroupId)) return 3; + if (!SetConsoleCtrlHandler(IgnoringCtrlHandler, true)) return 4; + if (!GenerateConsoleCtrlEvent(dwCtrlEvent, dwProcessGroupId)) return 5; + if (!FreeConsole()) return 6; + + return 0; +} diff --git a/nix/lace-blockchain-services/internal/win-test/go.mod b/nix/lace-blockchain-services/internal/win-test/go.mod new file mode 100644 index 0000000000..aa0a94e07d --- /dev/null +++ b/nix/lace-blockchain-services/internal/win-test/go.mod @@ -0,0 +1,3 @@ +module lace.io/win-test + +go 1.20 diff --git a/nix/lace-blockchain-services/internal/win-test/main.go b/nix/lace-blockchain-services/internal/win-test/main.go new file mode 100644 index 0000000000..1d5a15dd42 --- /dev/null +++ b/nix/lace-blockchain-services/internal/win-test/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "syscall" +) + +const ( + ChildEnvVar = "TEST_IS_CHILD" +) + +func main() { + if os.Getenv(ChildEnvVar) != "" { + child() + } else { + parent() + } +} + +func parent() { + fmt.Printf("parent: called\n") + + executablePath, err := os.Executable() + if err != nil { + panic(err) + } + + sharedReader, sharedWriter, err := os.Pipe() + if err != nil { + panic(err) + } + inheritedPipe := sharedReader + + sInheritedHandle := strconv.FormatUint(uint64(inheritedPipe.Fd()), 10) + + var cmd *exec.Cmd + cmd = exec.Command(executablePath, sInheritedHandle) + cmd.Env = append(os.Environ(), ChildEnvVar + "=true") + + childStdout, err := cmd.StdoutPipe() + if err != nil { panic(err) } + go func() { + scanner := bufio.NewScanner(childStdout) + for scanner.Scan() { + fmt.Fprintf(os.Stdout, "%s\n", scanner.Text()) + } + }() + + childStderr, err := cmd.StderrPipe() + if err != nil { panic(err) } + go func() { + scanner := bufio.NewScanner(childStderr) + for scanner.Scan() { + fmt.Fprintf(os.Stderr, "%s\n", scanner.Text()) + } + }() + + handlesToMap := []syscall.Handle{ + syscall.InvalidHandle, // syscall.Handle(cmd.Stdin.(*os.File).Fd()), + syscall.Handle(cmd.Stdout.(*os.File).Fd()), + syscall.Handle(cmd.Stderr.(*os.File).Fd()), + syscall.Handle(inheritedPipe.Fd()), + } + + lpReserved2 := mkStartupInfoLpReserved2(handlesToMap) + + fmt.Printf("parent: lpReserved2 = %v\n", lpReserved2) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + NoInheritHandles: false, + AdditionalInheritedHandles: []syscall.Handle{ + syscall.Handle(inheritedPipe.Fd()), + }, + StartupInfoCbReserved2: uint16(len(lpReserved2)), + StartupInfoLpReserved2: &lpReserved2[0], + } + + fmt.Printf("parent: going to start child with inherited Handle=%s\n", sInheritedHandle) + + err = cmd.Start() + inheritedPipe.Close() + if err != nil { panic(err) } + + fmt.Printf("parent: writing to child...\n") + fmt.Fprintf(sharedWriter, "hello, world\n") + sharedWriter.Close() + + cmd.Wait() + + fmt.Printf("parent: exiting\n") +} + +func child() { + fmt.Printf("child: called\n") + + for fd := 0; fd <= 10; fd++ { + rv, err := _get_osfhandle(uintptr(fd)) + fmt.Printf("child: _get_osfhandle(%v) = %v (err: %v)\n", fd, rv, err) + } + + // sInheritedHandle := os.Args[1] + // inheritedHandle, err := strconv.ParseUint(sInheritedHandle, 10, 64) + inheritedHandle, err := _get_osfhandle(3) + if err != nil { panic(err) } + fmt.Printf("child: got inherited fd=%d\n", inheritedHandle) + + inheritedPipe := os.NewFile(uintptr(inheritedHandle), "") + defer inheritedPipe.Close() + + fmt.Printf("child: reading from pipe\n") + msg, err := io.ReadAll(inheritedPipe) + + fmt.Printf("child: read: \"%s\"\n", string(msg)) + + fmt.Printf("child: exiting\n") +} diff --git a/nix/lace-blockchain-services/internal/windows-nsis-banner.bmp b/nix/lace-blockchain-services/internal/windows-nsis-banner.bmp new file mode 100644 index 0000000000..4d84def79c Binary files /dev/null and b/nix/lace-blockchain-services/internal/windows-nsis-banner.bmp differ diff --git a/nix/lace-blockchain-services/internal/x86_64-linux.nix b/nix/lace-blockchain-services/internal/x86_64-linux.nix index bb371bf367..ee8cf1f5cf 100644 --- a/nix/lace-blockchain-services/internal/x86_64-linux.nix +++ b/nix/lace-blockchain-services/internal/x86_64-linux.nix @@ -19,7 +19,7 @@ in rec { lace-blockchain-services-exe = pkgs.buildGoModule rec { name = "lace-blockchain-services"; src = ./lace-blockchain-services; - vendorHash = "sha256-DjDyHOENtaFSNGQtX50wL3hIo+lmMY1BJBn/TaAcXU0="; + vendorHash = "sha256-1slTIiIGxraIFdtKNeH4llXjrtSEaEQ7IIbOM3LL3N0="; nativeBuildInputs = with pkgs; [ pkgconfig imagemagick go-bindata ]; buildInputs = with pkgs; [ (libayatana-appindicator-gtk3.override { @@ -34,7 +34,8 @@ in rec { }; preBuild = '' convert -background none -size 44x44 cardano.svg cardano.png - go-bindata -pkg main -o assets.go cardano.png + cp cardano.png tray-icon + go-bindata -pkg main -o assets.go tray-icon ''; }; @@ -109,7 +110,7 @@ in rec { passAsFile = [ "script" ]; } '' mkdir -p $out - target=$out/lace-blockchain-services-${revShort}-${targetSystem}.bin + target=$out/lace-blockchain-services-${common.laceVersion}-${revShort}-${targetSystem}.bin cat $scriptPath >$target echo 'Compressing (xz)...' tar -cJ -C ${lace-blockchain-services-bundle} . >>$target diff --git a/nix/lace-blockchain-services/internal/x86_64-windows.nix b/nix/lace-blockchain-services/internal/x86_64-windows.nix index e09cb75ba8..d928e0d8c0 100644 --- a/nix/lace-blockchain-services/internal/x86_64-windows.nix +++ b/nix/lace-blockchain-services/internal/x86_64-windows.nix @@ -7,6 +7,642 @@ let inherit (pkgs) lib; in rec { common = import ./common.nix { inherit inputs targetSystem; }; - package = pkgs.runCommand "unimplemented" {} "echo unimplemented && exit 1"; - installer = package; + package = lace-blockchain-services; + installer = unsignedInstaller; + inherit (common) cardano-node ogmios; + + patchedGo = pkgs.go.overrideAttrs (drv: { + patches = (drv.patches or []) ++ [ ./go--windows-StartupInfoLpReserved2.patch ]; + }); + + # XXX: we have to be a bit creative to cross-compile Go code for Windows: + # • having a MinGW-w64 stdenv (for the C/C++ parts), + # • Linux Go (but instructed to cross-compile), + # • and taking go-modules (vendor) from the Linux derivation – these are only sources + lace-blockchain-services-exe = let + noConsoleWindow = true; + go = patchedGo; + go-modules = inputs.self.internal.lace-blockchain-services.x86_64-linux.lace-blockchain-services-exe.go-modules; + in pkgs.pkgsCross.mingwW64.stdenv.mkDerivation { + name = "lace-blockchain-services"; + src = ./lace-blockchain-services; + GOPROXY = "off"; + GOSUMDB = "off"; + GO111MODULE = "on"; + GOFLAGS = ["-mod=vendor" "-trimpath"]; + GOOS = "windows"; + GOARCH = "amd64"; + inherit (go) CGO_ENABLED; + nativeBuildInputs = [ go ] ++ (with pkgs; [ go-bindata imagemagick ]); + configurePhase = '' + export GOCACHE=$TMPDIR/go-cache + export GOPATH="$TMPDIR/go" + rm -rf vendor + cp -r --reflink=auto ${go-modules} vendor + ''; + buildPhase = '' + cp ${icon} tray-icon + go-bindata -pkg main -o assets.go tray-icon + go build ${if noConsoleWindow then "-ldflags -H=windowsgui" else ""} + ''; + installPhase = '' + mkdir -p $out + mv lace-blockchain-services.exe $out/ + ''; + passthru = { inherit go go-modules; }; + }; + + win-test-exe = let + go = patchedGo; + in pkgs.pkgsCross.mingwW64.stdenv.mkDerivation { + name = "win-test"; + src = ./win-test; + GOPROXY = "off"; + GOSUMDB = "off"; + GO111MODULE = "on"; + GOFLAGS = ["-mod=vendor" "-trimpath"]; + GOOS = "windows"; + GOARCH = "amd64"; + inherit (go) CGO_ENABLED; + nativeBuildInputs = [ go ]; + configurePhase = '' + export GOCACHE=$TMPDIR/go-cache + export GOPATH="$TMPDIR/go" + rm -rf vendor + mkdir -p vendor + cp ${./lace-blockchain-services/main_fd_inheritance_windows.go} main_fd_inheritance_windows.go + ''; + buildPhase = '' + go build + ''; + installPhase = '' + mkdir -p $out + mv win-test.exe $out/ + ''; + passthru = { inherit go; }; + }; + + svg2ico = source: let + sizes = [16 24 32 48 64 128 256 512]; + d2s = d: "${toString d}x${toString d}"; + in pkgs.runCommand "${baseNameOf source}.ico" { + buildInputs = with pkgs; [ imagemagick ]; + } '' + ${lib.concatMapStringsSep "\n" (dim: '' + convert -background none -size ${d2s dim} ${source} ${d2s dim}.png + '') sizes} + convert ${lib.concatMapStringsSep " " (dim: "${d2s dim}.png") sizes} $out + ''; + + icon = svg2ico ./lace-blockchain-services/cardano.svg; + + # FIXME: This is terrible, we have to do it better, but I can’t get the Go cross-compiler + # to embed Windows resources properly in the EXE. The file increases in size, but is still + # missing something. I have no time to investigate now, so let’s have this dirty hack. + lace-blockchain-services-exe-with-icon = pkgs.runCommand "lace-blockchain-services-with-icon" { + buildInputs = with cardano-js-sdk.fresherPkgs; [ + wineWowPackages.stableFull + winetricks samba /*samba for bin/ntlm_auth*/ + ]; + } '' + export HOME=$(realpath $NIX_BUILD_TOP/home) + mkdir -p $HOME + ${pkgs.xvfb-run}/bin/xvfb-run \ + --server-args="-screen 0 1920x1080x24 +extension GLX +extension RENDER -ac -noreset" \ + ${pkgs.writeShellScript "wine-setup-inside-xvfb" '' + set -euo pipefail + export WINEDEBUG=-all # comment out to get normal output (err,fixme), or set to +all for a flood + set +e + wine ${resourceHacker}/ResourceHacker.exe \ + -log res-hack.log \ + -open "$(winepath -w ${lace-blockchain-services-exe}/*.exe)" \ + -save with-icon.exe \ + -action addoverwrite \ + -res "$(winepath -w ${icon})" \ + -mask ICONGROUP,MAINICON, + wine_ec="$?" + set -e + echo "wine exit code: $wine_ec" + cat res-hack.log + if [ "$wine_ec" != 0 ] ; then + exit "$wine_ec" + fi + ''} + mkdir -p $out + mv with-icon.exe $out/lace-blockchain-services.exe + ''; + + go-rsrc = pkgs.buildGoModule rec { + pname = "go-rsrc"; + version = "0.10.2"; + src = pkgs.fetchFromGitHub { + owner = "akavel"; repo = pname; + rev = "v${version}"; + hash = "sha256-QsPx3RYA2uc+qIN2LKRCvumeMedg0kIEuUOkaRvuLbs="; + }; + vendorHash = null; + }; + + go-winres = pkgs.buildGoModule rec { + pname = "go-winres"; + version = "0.3.1"; + src = pkgs.fetchFromGitHub { + owner = "tc-hib"; repo = pname; + rev = "v${version}"; + hash = "sha256-D/B5ZJkCutrVeIdgqnalgfNAPiIUDGy+sRi3bYfdBS8="; + }; + vendorHash = "sha256-ntLgiD4CS1QtWTYbrsEraqndtWYOFqmwgQnSBhF1xuE="; + doCheck = false; + }; + + lace-blockchain-services = mkPackage { withJS = true; }; + + mkPackage = { withJS }: pkgs.runCommand "lace-blockchain-services" {} '' + mkdir -p $out/libexec + cp -Lr ${lace-blockchain-services-exe-with-icon}/* $out/ + cp -L ${ogmios}/bin/*.{exe,dll} $out/libexec/ + cp -L ${cardano-js-sdk.target.nodejs}/node.exe $out/libexec/ + cp -Lf ${cardano-node}/bin/*.{exe,dll} $out/libexec/ + cp -Lf ${sigbreak}/*.exe $out/libexec/ + cp -Lr ${common.networkConfigs} $out/cardano-node-config + ${if !withJS then "" else '' + cp -Lr ${cardano-js-sdk.ourPackage} $out/cardano-js-sdk + ''} + ''; + + lace-blockchain-services-zip = mkArchive { withJS = true; }; + + # This is much smaller, and much quicker to unpack, and very useful + # if you want to just iteratively test the process manager: + lace-blockchain-services-zip-nojs = mkArchive { withJS = false; }; + + # For easier testing, skipping the installer (for now): + mkArchive = { withJS }: let + revShort = + if inputs.self ? shortRev + then builtins.substring 0 9 inputs.self.rev + else "dirty"; + in pkgs.runCommand "lace-blockchain-services.7z" {} '' + mkdir -p $out + target=$out/lace-blockchain-services-${common.laceVersion}-${revShort}-${targetSystem}.7z + + ln -s ${mkPackage { inherit withJS; }} lace-blockchain-services + ${with pkgs; lib.getExe p7zip} a -r -l $target lace-blockchain-services + + # Make it downloadable from Hydra: + mkdir -p $out/nix-support + echo "file binary-dist \"$target\"" >$out/nix-support/hydra-build-products + ''; + + # XXX: we’re compiling it with MSVC so that it takes 122 kB, not 100× more… + sigbreak = pkgs.runCommandNoCC "sigbreak" { + buildInputs = with cardano-js-sdk.fresherPkgs; [ wineWowPackages.stableFull winetricks ]; + } '' + export HOME=$(realpath $NIX_BUILD_TOP/home) + mkdir -p $HOME + + cp ${./sigbreak.cc} sigbreak.cc + + export WINEDEBUG=-all # comment out to get normal output (err,fixme), or set to +all for a flood + export WINEPATH="$(winepath -w ${cardano-js-sdk.msvc-installed}/VC/Tools/MSVC/*/bin/Hostx64/x64)" + + inclPath_1="$(winepath -w ${cardano-js-sdk.msvc-installed}/VC/Tools/MSVC/*/include)" + inclPath_2="$(winepath -w ${cardano-js-sdk.msvc-installed}/kits/10/Include/*/ucrt)" + inclPath_3="$(winepath -w ${cardano-js-sdk.msvc-installed}/kits/10/Include/*/um)" + inclPath_4="$(winepath -w ${cardano-js-sdk.msvc-installed}/kits/10/Include/*/shared)" + + libPath_1="$(winepath -w ${cardano-js-sdk.msvc-installed}/VC/Tools/MSVC/*/lib/x64)" + libPath_2="$(winepath -w ${cardano-js-sdk.msvc-installed}/kits/10/Lib/*/ucrt/x64)" + libPath_3="$(winepath -w ${cardano-js-sdk.msvc-installed}/kits/10/Lib/*/um/x64)" + + wine cl.exe /EHsc "/I$inclPath_1" "/I$inclPath_2" "/I$inclPath_3" "/I$inclPath_4" /c sigbreak.cc + + wine cl.exe sigbreak.obj /link "/LIBPATH:$libPath_1" "/LIBPATH:$libPath_2" "/LIBPATH:$libPath_3" \ + /out:sigbreak.exe + + mkdir -p $out + mv sigbreak.exe $out/ + ''; + + make-windows-installer = let + project = common.haskell-nix.project { + compiler-nix-name = "ghc8107"; + projectFileName = "cabal.project"; + src = ./make-windows-installer; + }; + in project.make-windows-installer.components.exes.make-windows-installer; + + nsis = import ./nsis.nix { nsisNixpkgs = inputs.nixpkgs-nsis; }; + + unsignedUninstaller = pkgs.runCommand "unsigned-uninstaller" { + buildInputs = [ + make-windows-installer + pkgs.glibcLocales # ← or else: commitBuffer: invalid argument (invalid character) + nsis + cardano-js-sdk.fresherPkgs.wineWowPackages.stableFull + ]; + } '' + # ↓ or else: commitBuffer: invalid argument (invalid character) + export LANG=en_US.UTF-8 + + make-windows-installer \ + --spaced-name ${lib.escapeShellArg common.prettyName} \ + --install-dir ${lib.escapeShellArg common.prettyName} \ + --full-version ${lib.escapeShellArg common.laceVersion} \ + --out-name "installer.exe" \ + --icon-path icon.ico \ + --banner-bmp banner.bmp \ + --lock-file '$APPDATA\lace-blockchain-services\instance.lock' \ + --shortcut-exe "lace-blockchain-services.exe" \ + --contents-dir 'contents\*' + + makensis uninstaller.nsi -V4 + + mkdir home + export HOME="$(realpath ./home)" + export WINEDEBUG=-all # comment out to get normal output (err,fixme), or set to +all for a flood + wine tempinstaller.exe /S + + mkdir -p $out + cp -v installer.nsi $HOME/.wine/drive_c/uninstall.exe $out/ + ''; + + unsignedInstaller = let + revShort = + if inputs.self ? shortRev + then builtins.substring 0 9 inputs.self.rev + else "dirty"; + in pkgs.runCommand "unsigned-installer" { + buildInputs = [ + pkgs.glibcLocales # ← or else: commitBuffer: invalid argument (invalid character) + nsis + ]; + } '' + cp ${unsignedUninstaller}/* ./ + cp ${icon} icon.ico + cp ${./windows-nsis-banner.bmp} banner.bmp + + ln -s ${mkPackage { withJS = true; }} contents + + makensis installer.nsi -V4 + + mkdir -p $out + target=$out/lace-blockchain-services-${common.laceVersion}-${revShort}-${targetSystem}.exe + + mv installer.exe "$target" + + # Make it downloadable from Hydra: + mkdir -p $out/nix-support + echo "file binary-dist \"$target\"" >$out/nix-support/hydra-build-products + ''; + + resourceHacker = pkgs.fetchzip { + name = "resource-hacker-5.1.7"; + url = "http://www.angusj.com/resourcehacker/resource_hacker.zip"; + hash = "sha256-W5TmyjNNXE3nvn37XYbTM+DBeupPijE4M70LJVKJupU="; + stripRoot = false; + }; + + # -------------------------------------- cardano-js-sdk ------------------------------------------ # + + # XXX: the main challenge here is that we must cross-build *.node + # DLLs from Linux to Windows, and it can only be done with Visual + # Studio running in Wine (Node.js doesn’t support MinGW-w64) + # + # See also: similar approach in Daedalus: + cardano-js-sdk = rec { + theirPackage = inputs.cardano-js-sdk.packages.x86_64-linux.default; + + # Let’s grab the build-time `node_modules` of the Linux build, and + # we’ll call specific "install" scripts manually inside Wine. + # + # One improvement would be to skip building the Linux binaries altogether here. + theirNodeModules = theirPackage.overrideAttrs (drv: { + name = "cardano-js-sdk-node_modules"; + buildPhase = ":"; + installPhase = '' + # Clear the Linux binaries: + find -type f '(' -name '*.node' -o -name '*.o' -o -name '*.o.d' -o -name '*.target.mk' \ + -o -name '*.Makefile' -o -name 'Makefile' -o -name 'config.gypi' ')' -exec rm -vf '{}' ';' + + mkdir $out + ${with pkgs; lib.getExe rsync} -Rah \ + $(find -type d -name 'node_modules' -prune) \ + $(find -type f '(' -name 'package.json' -o -name 'yarn.lock' ')' -a -not -path '*/node_modules/*') \ + $out/ + ''; + dontFixup = true; + }); + + # Let’s build the TS/JS files as on Linux, but then copy native Windows DLLs (${nativeModules} below) + ourPackage = pkgs.stdenv.mkDerivation { + name = "cardano-js-sdk"; + src = toString inputs.cardano-js-sdk; + nativeBuildInputs = with pkgs; [ python3 rsync ]; + inherit (theirPackage) buildInputs npm_config_nodedir configurePhase CHROMEDRIVER_FILEPATH; + buildPhase = "yarn build"; + installPhase = '' + mkdir $out + rsync -Rah $(find . '(' '(' -type d -name 'dist' ')' -o -name 'package.json' ')' \ + -not -path '*/node_modules/*') $out/ + + cp -r ${theirPackage.production-deps}/libexec/source/node_modules $out/ + chmod -R +w $out + + # Clear the Linux binaries: + find $out/node_modules/ -type f '(' -name '*.node' -o -name '*.o' -o -name '*.o.d' -o -name '*.target.mk' \ + -o -name '*.Makefile' -o -name 'Makefile' -o -name 'config.gypi' ')' -exec rm -vf '{}' ';' + + # Inject the Windows DLLs: + rsync -ah ${nativeModules}/. $out/ + ''; + dontFixup = true; + }; + + # XXX: `pkgs.nodejs` lacks `uv/win.h`, `node.lib` etc., so: + nodejsHeaders = pkgs.runCommand "nodejs-headers-${theirPackage.nodejs.version}" rec { + version = theirPackage.nodejs.version; + src = pkgs.fetchurl { + url = "https://nodejs.org/dist/v${version}/node-v${version}-headers.tar.gz"; + hash = "sha256-4LCiKSF5q4VTV876cE95fXhTtWny1mu3wxPdXLNuBjs="; + }; + # XXX: normally, node-gyp would download it only for Windows, see `resolveLibUrl()` + # in `node-gyp/lib/process-release.js` + node_lib = pkgs.fetchurl { + url = "https://nodejs.org/dist/v${version}/win-x64/node.lib"; + hash = "sha256-Orh+nCfi1Jhp/RXHhBwWdK9Wyb468WcsFzYhozAPDg0="; + }; + } '' + mkdir unpack + tar -C unpack -xf $src + mv unpack/* $out + mkdir -p $out/Release + ln -s $node_lib $out/Release/node.lib + ''; + + nativeModules = pkgs.stdenv.mkDerivation { + name = "cardano-js-sdk-nativeModules"; + dontUnpack = true; + nativeBuildInputs = (with pkgs; [ jq file procps ]) + ++ (with fresherPkgs; [ wineWowPackages.stableFull fontconfig winetricks samba /*samba for bin/ntlm_auth*/ ]) + ; + configurePhase = '' + # XXX: `HOME` (for various caches) shouldn’t be under our source root, that confuses some Node.js tools: + export HOME=$(realpath $NIX_BUILD_TOP/home) + mkdir -p $HOME + + cp -R ${theirNodeModules}/. ./ + chmod -R +w . + ''; + FONTCONFIG_FILE = fresherPkgs.makeFontsCache { + fontDirectories = with fresherPkgs; [ + dejavu_fonts freefont_ttf gyre-fonts liberation_ttf noto-fonts-emoji + unifont winePackages.fonts xorg.fontcursormisc xorg.fontmiscmisc + ]; + }; + buildPhase = let + mkSection = title: '' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo ' ' + echo "===================== ${title} =====================" + ''; + in '' + ${pkgs.xvfb-run}/bin/xvfb-run \ + --server-args="-screen 0 1920x1080x24 +extension GLX +extension RENDER -ac -noreset" \ + ${pkgs.writeShellScript "wine-setup-inside-xvfb" '' + set -euo pipefail + + export WINEDEBUG=-all # comment out to get normal output (err,fixme), or set to +all for a flood + + ${mkSection "Setting Windows system version"} + winetricks -q win81 + + ${mkSection "Setting up env and symlinks in standard locations"} + + # Symlink Windows SDK in a standard location: + lx_program_files="$HOME/.wine/drive_c/Program Files (x86)" + mkdir -p "$lx_program_files" + ln -svf ${msvc-installed}/kits "$lx_program_files/Windows Kits" + + # Symlink VC in a standard location: + vc_versionYear="$(jq -r .info.productLineVersion <${msvc-cache}/*.manifest)" + lx_VSINSTALLDIR="$lx_program_files/Microsoft Visual Studio/$vc_versionYear/Community" + mkdir -p "$lx_VSINSTALLDIR" + ln -svf ${msvc-installed}/VC "$lx_VSINSTALLDIR"/ + ln -svf ${msvc-installed}/MSBuild "$lx_VSINSTALLDIR"/ + + export VCINSTALLDIR="$(winepath -w "$lx_VSINSTALLDIR/VC")\\" + export VCToolsVersion="$(ls ${msvc-installed}/VC/Tools/MSVC | head -n1)" + export VCToolsInstallDir="$(winepath -w "$lx_VSINSTALLDIR/VC/Tools/MSVC/$VCToolsVersion")\\" + export VCToolsRedistDir="$(winepath -w "$lx_VSINSTALLDIR/VC/Redist/MSVC/$VCToolsVersion")\\" + + export ClearDevCommandPromptEnvVars=false + + export VSINSTALLDIR="$(winepath -w "$lx_VSINSTALLDIR")\\" + + lx_WindowsSdkDir=("$lx_program_files/Windows Kits"/*) + export WindowsSdkDir="$(winepath -w "$lx_WindowsSdkDir")\\" + + set -x + + # XXX: this can break, as `v10.0` is not determined programmatically; + # XXX: the path is taken from `${msvc-installed}/MSBuild/Microsoft/VC/v160/Microsoft.Cpp.WindowsSDK.props` + wine reg ADD 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Microsoft SDKs\Windows\v10.0' \ + /v 'InstallationFolder' /t 'REG_SZ' /d "$WindowsSdkDir" /f + + # XXX: This path is taken from `${msvc-installed}/unpack/Common7/Tools/vsdevcmd/core/winsdk.bat` + wine reg ADD 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows Kits\Installed Roots' \ + /v 'KitsRoot10' /t 'REG_SZ' /d "$WindowsSdkDir" /f + + set +x + + ${mkSection "Preparing ‘Find-VisualStudio-cs-output.json’"} + jq --null-input \ + --arg path "$VSINSTALLDIR" \ + --arg version "$(jq -r .info.productDisplayVersion <${msvc-cache}/*.manifest)" \ + --argjson packages "$( ( + echo "Microsoft.VisualStudio.VC.MSBuild.Base" + echo "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + echo "Microsoft.VisualStudio.Component.Windows10SDK.$(ls ${msvc-installed}/kits/10/Source | grep -oP '(?<=^10\.0\.)\d+(?=\.0$)')" + ) | jq -Rn '[inputs]')" \ + '[{$path,$version,$packages}]' \ + > Find-VisualStudio-cs-output.json + + ${mkSection "Patching all **/node-gyp/lib/find-visualstudio.js"} + find -path '*/node-gyp/lib/find-visualstudio.js' | while IFS= read -r toPatch ; do + echo "Patching ‘$toPatch’…" + sed -r 's/function findVisualStudio2017OrNewer.*/\0\n\nthis.parseData(undefined, '"JSON.stringify($(cat Find-VisualStudio-cs-output.json | tr -d '\n' | sed 's/[\/&]/\\&/g'))"', "", cb);\nreturn;\n/g' \ + -i "$toPatch" + done + + ${mkSection "Patching ‘buildcheck/lib/findvs.js’"} + sed -r 's/execFileSync\(ps, args, execOpts\)/'"JSON.stringify($(cat Find-VisualStudio-cs-output.json | tr -d '\n' | sed 's/[\/&]/\\&/g'))"'/g' \ + -i node_modules/buildcheck/lib/findvs.js + + ${mkSection "Setting WINEPATH"} + export WINEPATH="$(winepath -w ${target.python})" + + ${mkSection "Removing all symlinks to /nix/store (mostly python3)"} + find node_modules -type l >all-symlinks.lst + paste all-symlinks.lst <(xargs node_modules/.bin/${cmd}.cmd + '') batWrappers)} + + # Make it use our node.exe and npm.cmd, etc.: + export WINEPATH="$(winepath -w node_modules/.bin);$WINEPATH" + + # Tell node-gyp to use the provided Node.js headers for native code builds. + export npm_config_nodedir="$(winepath -w ${nodejsHeaders})" + export npm_config_build_from_source=true + + # Make it use our node_modules: + export NODE_PATH="$(winepath -w ./node_modules)" + + export CHROMEDRIVER_FILEPATH="$(winepath -w ${lib.escapeShellArg target.chromedriverBin})"; + + find -type f -name package.json | xargs grep -RF '"install":' | cut -d: -f1 \ + | while IFS= read -r package + do + ${mkSection "Running the install script of ‘$package’"} + + # XXX: we have to do that, so that Node.js sets environment properly: + windowsScriptName="windows-$(sha256sum <<<"$package" | cut -d' ' -f1)" + + jq \ + --arg key "$windowsScriptName" \ + --arg val "cd \"$(winepath -w "$(dirname "$package")")\" && npm run install" \ + '.scripts[$key] = $val' package.json >package.json.new + mv package.json.new package.json + + wine npm.cmd run "$windowsScriptName" + done + + # Packages that have a binding.gyp but don’t have an "install" script in their package.json + # – a weird bunch, but we still have to build them… + find -name 'binding.gyp' | xargs -n1 dirname | sort | grep -vE --file <(find -type f -name package.json | xargs grep -RF '"install":' | cut -d: -f1 | sort | xargs -n1 dirname | sed -r 's/[]\/$*.^|[]/\\&/g; s/^/^/g') | while IFS= read -r package + do + ${mkSection "Running the binding.gyp of ‘$package’"} + ( + cd "$package" + wine node-gyp.cmd rebuild + ) + done + ''} + ''; + installPhase = '' + find -type f -name '*.node' | xargs ${with pkgs; lib.getExe file} + + mkdir $out + ${with pkgs; lib.getExe rsync} -Rah \ + $(find -type f -name '*.node') \ + $out/ + ''; + }; + + chromedriverVersion = __unsafeDiscardStringContext (__readFile (pkgs.runCommandLocal "chromedriver-version" {} '' + cat ${theirNodeModules}/node_modules/chromedriver/lib/chromedriver.js \ + | grep -F 'exports.version' | grep -Po '\d+\.\d+\.\d+\.\d+' | tr -d '\n' >$out + '')); + + target = rec { + nodejs = pkgs.fetchzip { + url = "https://nodejs.org/dist/v${theirPackage.nodejs.version}/node-v${theirPackage.nodejs.version}-win-x64.zip"; + hash = "sha256-lLE7yiyN/qOjrA9As3it3HN1VSbQgQlTTYUtMcQ2Xsk="; + }; + + python = pkgs.fetchzip { + url = "https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip"; + hash = "sha256-p83yidrRg5Rz1vQpyRuZCb5F+s3ddgHt+JakPjgFgUc="; + stripRoot = false; + }; + + chromedriverBin = pkgs.fetchurl { + name = "chromedriver-${targetSystem}-${chromedriverVersion}.zip"; + url = "https://chromedriver.storage.googleapis.com/${chromedriverVersion}/chromedriver_win32.zip"; + hash = "sha256-kM4G4AEcgelYopfWG5xVS+IvnXPB9F4lTpWCXivD3n0="; + }; + }; + + fresherPkgs = import (pkgs.fetchFromGitHub { + owner = "NixOS"; repo = "nixpkgs"; + rev = "17a689596b72d1906883484838eb1aaf51ab8001"; # nixos-unstable on 2023-05-15T08:29:41Z + hash = "sha256-YPLMeYE+UzxxP0qbkBzv3RBDvyGR5I4d7v2n8dI3+fY="; + }) { inherit (pkgs) system; }; + + msvc-wine = pkgs.stdenv.mkDerivation { + name = "msvc-wine"; + src = pkgs.fetchFromGitHub { + owner = "mstorsjo"; + repo = "msvc-wine"; + rev = "c4fd83d53689f30ae6cfd8e9ef1ea01712907b59"; # 2023-05-09T21:52:05Z + hash = "sha256-hA11dIOIL9sta+rwGb2EwWrEkRm6nvczpGmLZtr3nHI="; + }; + buildInputs = [ + (pkgs.python3.withPackages (ps: with ps; [ six ])) + ]; + configurePhase = ":"; + buildPhase = ":"; + installPhase = '' + sed -r 's,msiextract,${pkgs.msitools}/bin/\0,g' -i vsdownload.py + mkdir -p $out/libexec + cp -r . $out/libexec/. + ''; + }; + + msvc-cache = let + version = "16"; # There doesn’t seem to be an easy way to specify a more stable full version, 16.11.26 + in pkgs.stdenv.mkDerivation { + name = "msvc-cache-${version}"; + inherit version; + outputHashMode = "recursive"; + outputHashAlgo = "sha256"; + outputHash = "sha256-7+vNhYbrizqhoIDL6vN7vE+Gq2duoYW5adMgOpJgw2w="; + buildInputs = []; + dontUnpack = true; + dontConfigure = true; + NIX_SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + buildPhase = '' + mkdir -p $out + ${msvc-wine}/libexec/vsdownload.py --accept-license --major ${version} \ + --save-manifest \ + --only-download --cache $out --dest ./ + cp -v *.manifest $out/. + ''; + dontInstall = true; + }; + + msvc-installed = pkgs.stdenv.mkDerivation { + name = "msvc-installed-${msvc-cache.version}"; + inherit (msvc-cache) version; + dontUnpack = true; + dontConfigure = true; + buildPhase = '' + mkdir -p $out + ${msvc-wine}/libexec/vsdownload.py --accept-license --major ${msvc-cache.version} \ + --manifest ${msvc-cache}/*.manifest \ + --keep-unpack --cache ${msvc-cache} --dest $out/ + mv $out/unpack/MSBuild $out/ + ''; + dontInstall = true; + }; + }; + }