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.
This commit is contained in:
2026-08-19 11:11:30 +03:00
parent 23d56acfb5
commit 29643f26b3
6 changed files with 66 additions and 9 deletions
+1 -1
View File
@@ -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. that lists them is not installed.
The tests are split along the same line, and almost none of them are skipped. 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 macOS and Windows backends: the programs are faked at `shutil.which`, the
frameworks and system libraries at the one function that loads them 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 (`paste._win_api`, `hotkey._win_input`). A test class says which system it is
+3 -1
View File
@@ -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 signed with no certificate, so SmartScreen offers only **Don't run** until you
press **More info**. Add/Remove Programs uninstalls it, and `dikte integrate` 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 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: From a checkout instead:
+22 -2
View File
@@ -45,13 +45,33 @@ $env:DIKTE_ICO = $icon
# 2. The application -------------------------------------------------------- # 2. The application --------------------------------------------------------
# Two executables in the one directory: Dikte.exe, which is windowed and is # 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 # what a shortcut starts, and dikte-cli.exe, which has a console and is what
# `dikte` command runs. # 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") ` & python -m PyInstaller (Join-Path $root "packaging\dikte.spec") `
--distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") ` --distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") `
--noconfirm --clean --noconfirm --clean
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" } 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 ----------------------------------------------------------------- # 3. ffmpeg -----------------------------------------------------------------
# Recording on Windows goes through ffmpeg's DirectShow input, and Windows # Recording on Windows goes through ffmpeg's DirectShow input, and Windows
# ships nothing like it, so without this the download would be an application # ships nothing like it, so without this the download would be an application
+3 -3
View File
@@ -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 { 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 left there runs from any terminal without touching the PATH and without an
administrator; the alternative is an environment variable edit that every administrator; the alternative is an environment variable edit that every
open terminal misses. It names the console executable, which is the one that open terminal misses. It names dikte-cli.exe, the console executable, which
can print to the terminal it was typed in. } is the one that can print to the terminal it was typed in. }
function ShimDir(): String; function ShimDir(): String;
begin begin
@@ -104,7 +104,7 @@ begin
if CurStep = ssPostInstall then begin if CurStep = ssPostInstall then begin
if DirExists(ShimDir()) then begin if DirExists(ShimDir()) then begin
Shim := '@echo off' + #13#10 Shim := '@echo off' + #13#10
+ '"' + ExpandConstant('{app}\dikte.exe') + '" %*' + #13#10; + '"' + ExpandConstant('{app}\dikte-cli.exe') + '" %*' + #13#10;
SaveStringToFile(ShimPath(), Shim, False); SaveStringToFile(ShimPath(), Shim, False);
end; end;
end; end;
+6 -1
View File
@@ -84,12 +84,17 @@ executable = EXE( # noqa: F821
# print nothing to it and answer nothing to a script. Everywhere else the one # 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 # executable does both jobs: a terminal that started it keeps its output, and
# nothing opens a window nobody asked for. # 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 console_executable = EXE( # noqa: F821
archive, archive,
analysis.scripts, analysis.scripts,
[], [],
exclude_binaries=True, exclude_binaries=True,
name="dikte", name="dikte-cli",
console=True, console=True,
target_arch=None, target_arch=None,
icon=os.environ.get("DIKTE_ICO") or None, icon=os.environ.get("DIKTE_ICO") or None,
+31 -1
View File
@@ -9,6 +9,7 @@ has to notice AppImageLauncher's entry, which is not under the name ours is.
import os import os
import pathlib import pathlib
import plistlib import plistlib
import re
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -493,7 +494,7 @@ class Windows(unittest.TestCase):
def test_the_windowed_executable_is_what_starts_at_sign_in(self): 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 """The console one is what the `dikte` command runs, and a sign-in that
started that would open a console window nobody asked for.""" 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) self.assertEqual(integrate.target(), self.app)
def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self): 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(), []) 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__": if __name__ == "__main__":
unittest.main() unittest.main()