From 29643f26b39fbf81ddb38ff2f8dd8cc1199149e1 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 19 Aug 2026 11:11:30 +0300 Subject: [PATCH] Keep the two Windows executables from being one file Windows matches a filename without regard to its case, so Dikte.exe and dikte.exe were one file in the installation directory: PyInstaller wrote the console build second and that is the only one that landed. Every launcher then opened a console window behind the tray, and closing it killed the application. The console one is dikte-cli.exe now, which the setup's shim names. The build reads the PE subsystem of both afterwards, since a filesystem that keeps the two names apart is exactly what the check would run on otherwise. --- CONTRIBUTING.md | 2 +- README.windows.md | 4 +++- packaging/build-windows.ps1 | 24 ++++++++++++++++++++++-- packaging/dikte.iss | 6 +++--- packaging/dikte.spec | 7 ++++++- tests/test_integrate.py | 32 +++++++++++++++++++++++++++++++- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b925daf..c77685a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ there can tell that apart from an empty device list, which only means the tool that lists them is not installed. The tests are split along the same line, and almost none of them are skipped. -1094 of the 1157 run on any machine, including every line of the Wayland, X11, +1096 of the 1159 run on any machine, including every line of the Wayland, X11, macOS and Windows backends: the programs are faked at `shutil.which`, the frameworks and system libraries at the one function that loads them (`paste._win_api`, `hotkey._win_input`). A test class says which system it is diff --git a/README.windows.md b/README.windows.md index 33bb63d..7fdb819 100644 --- a/README.windows.md +++ b/README.windows.md @@ -21,7 +21,9 @@ entry, a `dikte` command and, unless you untick it, a start at sign-in. It is signed with no certificate, so SmartScreen offers only **Don't run** until you press **More info**. Add/Remove Programs uninstalls it, and `dikte integrate` and `dikte integrate --remove` are the sign-in entry on its own, for changing -your mind about that later. +your mind about that later. The `dikte` command is the same file install.ps1 +writes, so over a checkout the setup takes it over and uninstalling takes it +away; run install.ps1 again to get the checkout's back. From a checkout instead: diff --git a/packaging/build-windows.ps1 b/packaging/build-windows.ps1 index 9b64b96..6170606 100644 --- a/packaging/build-windows.ps1 +++ b/packaging/build-windows.ps1 @@ -45,13 +45,33 @@ $env:DIKTE_ICO = $icon # 2. The application -------------------------------------------------------- # Two executables in the one directory: Dikte.exe, which is windowed and is -# what a shortcut starts, and dikte.exe, which has a console and is what the -# `dikte` command runs. +# what a shortcut starts, and dikte-cli.exe, which has a console and is what +# the `dikte` command runs. Their names differ by more than case on purpose; +# Windows would otherwise keep one file for both. & python -m PyInstaller (Join-Path $root "packaging\dikte.spec") ` --distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") ` --noconfirm --clean if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" } +# And the check that the two are really two. Windows matches a filename +# without regard to its case, so a rename that leaves them a case apart puts +# one file in the directory and whichever was written second is what both +# names find. It cost a windowed build once, silently: the machines the +# packaging is otherwise checked on all have case-sensitive filesystems, so +# this only ever shows up here. Subsystem 2 is windowed, 3 is a console, and +# it is the field the loader reads to decide which to give. +function Get-PeSubsystem($path) { + $bytes = [System.IO.File]::ReadAllBytes($path) + $header = [BitConverter]::ToInt32($bytes, 0x3C) + return [BitConverter]::ToUInt16($bytes, $header + 0x5C) +} +foreach ($pair in @(@("Dikte.exe", 2), @("dikte-cli.exe", 3))) { + $subsystem = Get-PeSubsystem (Join-Path $dist $pair[0]) + if ($subsystem -ne $pair[1]) { + throw "$($pair[0]) is subsystem $subsystem, expected $($pair[1]): the two executables collided" + } +} + # 3. ffmpeg ----------------------------------------------------------------- # Recording on Windows goes through ffmpeg's DirectShow input, and Windows # ships nothing like it, so without this the download would be an application diff --git a/packaging/dikte.iss b/packaging/dikte.iss index a727efa..d16d784 100644 --- a/packaging/dikte.iss +++ b/packaging/dikte.iss @@ -84,8 +84,8 @@ Filename: "{app}\Dikte.exe"; Description: "Start Dikte"; \ { The `dikte` command. WindowsApps is already on the user's PATH, so a .cmd left there runs from any terminal without touching the PATH and without an administrator; the alternative is an environment variable edit that every - open terminal misses. It names the console executable, which is the one that - can print to the terminal it was typed in. } + open terminal misses. It names dikte-cli.exe, the console executable, which + is the one that can print to the terminal it was typed in. } function ShimDir(): String; begin @@ -104,7 +104,7 @@ begin if CurStep = ssPostInstall then begin if DirExists(ShimDir()) then begin Shim := '@echo off' + #13#10 - + '"' + ExpandConstant('{app}\dikte.exe') + '" %*' + #13#10; + + '"' + ExpandConstant('{app}\dikte-cli.exe') + '" %*' + #13#10; SaveStringToFile(ShimPath(), Shim, False); end; end; diff --git a/packaging/dikte.spec b/packaging/dikte.spec index 6df6534..6cfd651 100644 --- a/packaging/dikte.spec +++ b/packaging/dikte.spec @@ -84,12 +84,17 @@ executable = EXE( # noqa: F821 # print nothing to it and answer nothing to a script. Everywhere else the one # executable does both jobs: a terminal that started it keeps its output, and # nothing opens a window nobody asked for. +# +# Named apart from the windowed one rather than `dikte` beside `Dikte`. +# Windows matches a filename without regard to its case, so those two are one +# file in one directory: whichever PyInstaller writes second is the only one +# installed, and a build made on a case-sensitive filesystem never sees it. console_executable = EXE( # noqa: F821 archive, analysis.scripts, [], exclude_binaries=True, - name="dikte", + name="dikte-cli", console=True, target_arch=None, icon=os.environ.get("DIKTE_ICO") or None, diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 999f115..f5a5f97 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -9,6 +9,7 @@ has to notice AppImageLauncher's entry, which is not under the name ours is. import os import pathlib import plistlib +import re import sys import tempfile import unittest @@ -493,7 +494,7 @@ class Windows(unittest.TestCase): def test_the_windowed_executable_is_what_starts_at_sign_in(self): """The console one is what the `dikte` command runs, and a sign-in that started that would open a console window nobody asked for.""" - with Frozen(str(self.installed / "dikte.exe"), platform="win32"): + with Frozen(str(self.installed / "dikte-cli.exe"), platform="win32"): self.assertEqual(integrate.target(), self.app) def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self): @@ -520,5 +521,34 @@ class Windows(unittest.TestCase): self.assertEqual(self.remove(), []) +class WindowsExecutableNames(unittest.TestCase): + """The two Windows executables, read out of the files that name them. + + Windows matches a filename without regard to its case, so Dikte.exe and + dikte.exe are one file in one directory and whichever was written second is + the only one installed. Nothing else here would catch that: these tests and + the builds that check the packaging both run on filesystems where the two + names are two files. + """ + + root = pathlib.Path(__file__).resolve().parent.parent + + def executables(self): + """What the spec calls each one, in the order it builds them.""" + spec = (self.root / "packaging" / "dikte.spec").read_text() + return [re.search(r'name="(.*?)"', block).group(1) + for block in spec.split("EXE(")[1:]] + + def test_the_two_are_more_than_a_case_apart(self): + windowed, console = self.executables() + self.assertNotEqual(windowed.lower(), console.lower()) + + def test_the_command_runs_the_console_one(self): + """The setup writes the shim, so it is the setup that has to be right.""" + console = self.executables()[1] + setup = (self.root / "packaging" / "dikte.iss").read_text() + self.assertIn(f"{{app}}\\{console}.exe", setup) + + if __name__ == "__main__": unittest.main()