1 module nsis;
2 
3 import std.string;
4 import std.conv;
5 import std.file;
6 import std.path;
7 
8 import plugin;
9 import utils;
10 import consolecolors;
11 
12 
13 
14 
15 struct WindowsPackage
16 {
17     string format;
18     string pluginDir;
19     string title;
20     string installDir;
21     double bytes;
22     bool is64b;
23 }
24 
25 void generateWindowsInstaller(string outputDir,
26                               Plugin plugin,
27                               WindowsPackage[] packs,
28                               string outExePath,
29                               bool verbose)
30 {
31     import std.algorithm.iteration : uniq, filter;
32     import std.regex: regex, replaceAll;
33     import std.array : array;
34 
35     string regVendorKey = "Software\\" ~ plugin.vendorName;
36     string regProductKey = regVendorKey ~ "\\" ~ plugin.pluginName;
37     string regUninstallKey = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" ~ plugin.prettyName;
38 
39     // changes slashes in path to backslashes, which are the only supported within NSIS
40     string escapeNSISPath(string path)
41     {
42         return path.replace("/", "\\");
43     }
44 
45     string formatSectionDisplayName(WindowsPackage pack) pure
46     {
47         if (pack.format == "VST")
48             return format("%s %s", "VST 2.4", pack.is64b ? "(64 bit)" : "(32 bit)");
49         return format("%s %s", pack.format, pack.is64b ? "(64 bit)" : "(32 bit)");
50     }
51 
52     string formatSectionIdentifier(WindowsPackage pack) pure
53     {
54         return format("%s%s", pack.format, pack.is64b ? "64b" : "32b");
55     }
56 
57     string sectionDescription(WindowsPackage pack) pure
58     {
59         if (pack.format == "VST")
60             return "For hosts like Live and Studio One.";
61         else if(pack.format == "VST3")
62             return "Most compatible format: FL Studio, Cubase, etc.";
63         else if(pack.format == "AAX")
64             return "For Pro Tools support.";
65         else if(pack.format == "LV2")
66             return "For LV2 hosts like REAPER, Mixbus, and Ardour.";
67         else if(pack.format == "FLP")
68             return "For FL Studio only.";
69         else
70             return "";
71     }
72 
73     string vstInstallDirDescription(bool is64b) pure
74     {
75         string description = "";
76         if (is64b)
77             description ~= "Select your 64-bit VST 2.4 folder.";
78         else
79             description ~= "Select your 32-bit VST 2.4 folder.";
80         return description;
81     }
82 
83     //remove ./ if it occurs at the beginning of windowsInstallerHeaderBmp
84     string headerImagePage = plugin.windowsInstallerHeaderBmp.replaceAll(r"^./".regex, "");
85     string nsisPath = "WindowsInstaller.nsi"; // Note: the NSIS script is generated in current directory.
86 
87     string content = "";
88     content ~= "!include \"MUI2.nsh\"\n";
89     content ~= "!include \"LogicLib.nsh\"\n";
90     content ~= "!include \"x64.nsh\"\n";
91 
92 
93     // See Issue #824, there is no real true win with this in non-100% DPI.
94     //  - Either we keep the installer DPI-unaware and everything is blurry in non-100% DPI.
95     //  - Either we set the flag to true and the MUI_HEADERIMAGE_BITMAP is resampled with something 
96     //    that looks like nearest-neighbour sampling.
97     //
98     // Moral of story: make your MUI_HEADERIMAGE in pixel art style to suffer this in a way that looks
99     // on-purpose.
100     content ~= "ManifestDPIAware true\n";
101 
102     content ~= "BrandingText \"" ~ plugin.vendorName ~ "\"\n";
103     content ~= "SpaceTexts none\n";
104     content ~= `OutFile "` ~ outExePath ~ `"` ~ "\n";
105     content ~= "RequestExecutionLevel admin\n";
106 
107     if (plugin.windowsInstallerHeaderBmp != null)
108     {
109         content ~= "!define MUI_HEADERIMAGE\n";
110         content ~= "!define MUI_HEADERIMAGE_BITMAP \"" ~ escapeNSISPath(headerImagePage) ~ "\"\n";
111     }
112 
113     content ~= "!define MUI_ABORTWARNING\n";
114     if (plugin.iconPathWindows)
115         content ~= "!define MUI_ICON \"" ~ escapeNSISPath(plugin.iconPathWindows) ~ "\"\n";
116 
117     // Use the markdown licence file with macro expanded.
118     if (plugin.licensePath)
119     {
120         string licensePath = outputDir ~ "/license-expanded.md";
121         content ~= "!insertmacro MUI_PAGE_LICENSE \"" ~ licensePath ~ "\"\n";
122     }
123 
124     content ~= "!insertmacro MUI_PAGE_COMPONENTS\n";
125     content ~= "!insertmacro MUI_LANGUAGE \"English\"\n\n";
126 
127     auto sections = packs.uniq!((p1, p2) => p1.format == p2.format);
128     foreach(p; sections)
129     {
130         // FLStudio format optional, and disabled by default.
131         bool optional = (p.format == "FLP");
132         string optionalFlag = optional ? "/o " : "";
133         content ~= `Section ` ~ optionalFlag ~ `"` ~ p.title ~ `" Sec` ~ p.format ~ "\n";
134         content ~= "AddSize " ~ p.bytes.to!string ~ "\n";
135         content ~= "SectionEnd\n";
136     }
137 
138     foreach(p; sections)
139     {
140         content ~= "LangString DESC_" ~ p.format ~ " ${LANG_ENGLISH} \"" ~ sectionDescription(p) ~ "\"\n";
141     }
142 
143     content ~= "!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN\n";
144     foreach(p; sections)
145     {
146 
147         content ~= "!insertmacro MUI_DESCRIPTION_TEXT ${Sec" ~ p.format ~ "} $(DESC_" ~ p.format ~ ")\n";
148     }
149     content ~= "!insertmacro MUI_FUNCTION_DESCRIPTION_END\n\n";
150 
151     foreach(p; packs)
152     {
153         if(p.format == "VST" || p.format == "FLP")
154         {
155             content ~= `Var InstDir` ~ formatSectionIdentifier(p) ~ "\n";
156         }
157     }
158 
159     content ~= "; check file can be written to\n";
160     content ~= "Function checkNotRunning\n";
161     content ~= "    Pop $0\n"; // pop path
162     content ~= "    Pop $2\n"; // pop plugin name
163     content ~= "    IfFileExists $0 0 skipclose\n";
164     content ~= "    FileOpen $1 $0 \"a\"\n";
165     content ~= "    IfErrors 0 skipcheck\n";
166     content ~= "    MessageBox MB_OK|MB_ICONEXCLAMATION \"$2 is currently running. Please close your DAW.\"\n";
167     content ~= "    skipcheck:\n";
168     content ~= "    FileClose $1\n";
169     content ~= "    skipclose:\n";
170     content ~= "FunctionEnd\n\n";
171 
172 
173     content ~= "Name \"" ~ plugin.pluginName ~ " v" ~ plugin.publicVersionString ~ "\"\n\n";
174 
175     foreach(p; packs)
176     {
177         if(p.format == "VST")
178         {
179             string identifier = formatSectionIdentifier(p);
180             string formatNiceName = formatSectionDisplayName(p);
181             content ~= "PageEx directory\n";
182             content ~= "  PageCallbacks defaultInstDir" ~ identifier ~ ` "" getInstDir` ~ identifier ~ "\n";
183             content ~= "  DirText \"" ~ vstInstallDirDescription(p.is64b) ~ "\" \"\" \"\" \"\"\n";
184             content ~= `  Caption ": ` ~ formatNiceName ~ ` Directory"` ~ "\n";
185             content ~= "PageExEnd\n";
186         }
187 
188         if (p.format == "FLP")
189         {
190             string identifier = formatSectionIdentifier(p);
191             string formatNiceName = formatSectionDisplayName(p);
192             content ~= "PageEx directory\n";
193             content ~= "  PageCallbacks defaultInstDir" ~ identifier ~ ` "" getInstDir` ~ identifier ~ "\n";
194             content ~= "  DirText \"" ~ "Your FLStudio Effect/ or Generators/ directory." ~ "\" \"\" \"\" \"\"\n";
195             content ~= `  Caption ": FL Studio Directory."` ~ "\n";
196             content ~= "PageExEnd\n";
197         }
198     }
199 
200     content ~= "Page instfiles\n";
201     content ~= "  InstallColors 000000 FFFFFF\n";
202 
203     foreach(p; packs)
204     {
205         if(p.format == "VST")
206         {
207             string identifier = formatSectionIdentifier(p);
208             content ~= "Function defaultInstDir" ~ identifier ~ "\n";
209             if(p.is64b)
210             {
211                 // The 64-bit version does not get installed on a 32-bit system, skip asking in this case
212                 content ~= "  ${IfNot} ${RunningX64}\n";
213                 content ~= "    Abort\n";
214                 content ~= "  ${EndIf}\n";
215             }
216             content ~= "  ${IfNot} ${SectionIsSelected} ${Sec" ~ p.format ~ "}\n";
217             content ~= "    Abort\n";
218             content ~= "  ${Else}\n";
219             content ~= `    StrCpy $INSTDIR "` ~ p.installDir ~ `"` ~ "\n";
220             content ~= "  ${EndIf}\n";
221             content ~= "FunctionEnd\n\n";
222             content ~= "Function getInstDir" ~ identifier ~ "\n";
223             content ~= "  StrCpy $InstDir" ~ identifier ~ " $INSTDIR\n";
224             content ~= "FunctionEnd\n\n";
225         }
226         else if (p.format == "FLP")
227         {
228             string identifier = formatSectionIdentifier(p);
229             // return installation path of FL Studio
230             content ~= "Function defaultInstDir" ~ identifier ~ "\n";
231             if(p.is64b)
232             {
233                 // The 64-bit version does not get installed on a 32-bit system, skip asking in this case
234                 content ~= "  ${IfNot} ${RunningX64}\n";
235                 content ~= "    Abort\n";
236                 content ~= "  ${EndIf}\n";
237             }
238             content ~= "  ${IfNot} ${SectionIsSelected} ${Sec" ~ p.format ~ "}\n";
239             content ~= "    Abort\n";
240             content ~= "  ${Else}\n";
241             foreach(int FLMajor; [12, 20, 21, 22, 23, 2024, 2025, 2026, 2027])
242             {
243                 // If the FL directory exist, becomes the one directory.
244                 // If FL changes its plugin layout, or exceed FL 2027, it will need to be redone.
245                 content ~= format(`  IfFileExists "$PROGRAMFILES64\Image-Line\FL Studio %s\*.*" yesFL%s noFL%s` ~"\n", FLMajor, FLMajor, FLMajor);
246                 content ~= format(`      yesFL%s:` ~"\n", FLMajor);
247                 if (plugin.isSynth)
248                     content ~= format(`      StrCpy $INSTDIR "$PROGRAMFILES64\Image-Line\FL Studio %s\Plugins\Fruity\Generators"` ~"\n", FLMajor);
249                 else
250                     content ~= format(`      StrCpy $INSTDIR "$PROGRAMFILES64\Image-Line\FL Studio %s\Plugins\Fruity\Effects"` ~"\n", FLMajor);
251                 content ~= format(`      noFL%s:` ~"\n", FLMajor);
252             }
253             content ~= "  ${EndIf}\n";
254             content ~= "FunctionEnd\n\n";
255             content ~= "Function getInstDir" ~ identifier ~ "\n";
256             content ~= "  StrCpy $InstDir" ~ identifier ~ " $INSTDIR\n";
257             content ~= "FunctionEnd\n\n";
258         }
259     }
260 
261     content ~= "Section\n";
262     content ~= "  ${If} ${RunningX64}\n";
263     content ~= "    SetRegView 64\n";
264     content ~= "  ${EndIf}\n";
265 
266     auto lv2Packs = packs.filter!((p) => p.format == "LV2").array();
267     
268     foreach(p; packs)
269     {
270         bool pluginIsDir = p.pluginDir.isDir;
271 
272         // May point to a directory or a single file.
273         // eg: "builds\Windows-64b-VST3\Witty Audio CLIP It-64.vst3"
274         string pluginRelativePath = p.pluginDir.asNormalizedPath.array; 
275 
276         // eg: "Witty Audio Clip It-64.vst"
277         string pluginBaseName = baseName(pluginRelativePath);
278 
279         // A NSIS literal that indicates the absolute path to install in.
280         string outputPath;
281         if (p.format == "VST" || p.format == "FLP") 
282             outputPath =  "$InstDir" ~ formatSectionIdentifier(p);
283         else
284             outputPath = "\"" ~ p.installDir ~ "\"";
285 
286         // Only install the 64-bit package on 64-bit OS
287         content ~= "  ${If} ${SectionIsSelected} ${Sec" ~ p.format ~ "}\n";
288         if(p.is64b)
289             content ~= "    ${AndIf} ${RunningX64}\n";
290             
291         if (p.format == "VST")
292         {
293             string instDirVar = "InstDir" ~ formatSectionIdentifier(p);
294             content ~= format!"    WriteRegStr HKLM \"%s\" \"%s\" \"$%s\"\n"(regProductKey, instDirVar, instDirVar);
295         }
296         else if (p.format == "FLP")
297         {
298             string instDirVar = "InstDir" ~ formatSectionIdentifier(p);
299             content ~= "    SetOutPath $" ~ instDirVar ~ "\n";
300             content ~= format!"    WriteRegStr HKLM \"%s\" \"%s\" \"$%s\"\n"(regProductKey, instDirVar, instDirVar);
301         }
302 
303         // Check that file isn't open.
304         // Only do this when it isn't a directory
305         // (FUTURE: do this for directories too, this doesn't work for FLP, LV2 and AAX).
306         if (!pluginIsDir)
307         {
308             // Build a NSIS string that indicates which file to open to test for "already running" warning.
309             // This seems the only way to concatenate reliably.
310             content ~= "    StrCpy $0 " ~ outputPath ~ "\n";
311             content ~= "    StrCpy $0 \"$0\\" ~ pluginBaseName ~ "\"\n";
312             content ~= "    Push \"" ~ plugin.pluginName ~ "\"\n";
313             content ~= "    Push $0\n";
314             content ~= "    Call checkNotRunning\n";
315         }
316 
317         content ~= "    SetOutPath " ~ outputPath ~ "\n";
318 
319         string recursiveFlag = pluginIsDir ? "/r " : "";
320         content ~= "    File " ~ recursiveFlag ~ "\"" ~ pluginRelativePath ~ "\"\n";
321         content ~= "  ${EndIf}\n";
322     }
323 
324     content ~= format!"    CreateDirectory \"$PROGRAMFILES\\%s\\%s\"\n"(plugin.vendorName, plugin.pluginName);
325     content ~= format!"    WriteUninstaller \"$PROGRAMFILES\\%s\\%s\\Uninstall.exe\"\n"(plugin.vendorName, plugin.pluginName);
326     content ~= format!"    WriteRegStr HKLM \"%s\" \"DisplayName\" \"%s\"\n"(regUninstallKey, plugin.prettyName);
327     content ~= format!"    WriteRegStr HKLM \"%s\" \"UninstallString\" \"$PROGRAMFILES\\%s\\%s\\Uninstall.exe\"\n"(regUninstallKey, plugin.vendorName, plugin.pluginName);
328 
329     content ~= "SectionEnd\n\n";
330 
331     // Uninstaller
332 
333     content ~= "Section \"Uninstall\"\n";
334     content ~= "  ${If} ${RunningX64}\n";
335     content ~= "    SetRegView 64\n";
336     content ~= "  ${EndIf}\n";
337     foreach(p; packs)
338     {
339         bool pluginIsDir = p.pluginDir.isDir;
340 
341         if(p.is64b)
342           content ~= "    ${If} ${RunningX64}\n";
343 
344         if (p.format == "VST")
345         {
346             assert(!pluginIsDir);
347 
348             string instDirVar = "InstDir" ~ formatSectionIdentifier(p);
349             content ~= format!"    ReadRegStr $%s HKLM \"%s\" \"%s\"\n"(instDirVar, regProductKey, instDirVar);
350             content ~= format!"    ${If} $%s != \"\"\n"(instDirVar);
351             content ~= format!"        Delete \"$%s\\%s\"\n"(instDirVar, p.pluginDir.baseName);
352             content ~=        "    ${EndIf}\n";
353         }
354         else if (p.format == "FLP")
355         {
356             assert(pluginIsDir);
357 
358             // Readback installation dir, inside FL Studio directories
359             string instDirVar = "InstDir" ~ formatSectionIdentifier(p);
360             content ~= format!"    ReadRegStr $%s HKLM \"%s\" \"%s\"\n"(instDirVar, regProductKey, instDirVar);
361             content ~= format!"    ${If} $%s != \"\"\n"(instDirVar);
362             content ~= format!"        RMDir /r \"$%s\\%s\"\n"(instDirVar, p.pluginDir.baseName);
363             content ~=        "    ${EndIf}\n";
364         }
365         else if (pluginIsDir)
366         {
367             content ~= format!"    RMDir /r \"%s\\%s\"\n"(p.installDir, p.pluginDir.baseName);
368         }
369         else
370         {
371             content ~= format!"    Delete \"%s\\%s\"\n"(p.installDir, p.pluginDir.baseName);
372         }
373 
374         if(p.is64b)
375           content ~= "    ${EndIf}\n";
376     }
377     content ~= format!"    DeleteRegKey HKLM \"%s\"\n"(regProductKey);
378     content ~= format!"    DeleteRegKey /ifempty HKLM \"%s\"\n"(regVendorKey);
379     content ~= format!"    DeleteRegKey HKLM \"%s\"\n"(regUninstallKey);
380     content ~= format!"    RMDir /r \"$PROGRAMFILES\\%s\\%s\"\n"(plugin.vendorName, plugin.pluginName);
381     content ~= format!"    RMDir \"$PROGRAMFILES\\%s\"\n"(plugin.vendorName);
382     content ~= "SectionEnd\n\n";
383 
384     std.file.write(nsisPath, cast(void[])content);
385 
386     // run makensis on the generated WindowsInstaller.nsi
387     string nsisVerboseFlag = verbose ? "" : "/V1 ";
388     string makeNsiCommand = format("makensis.exe %s%s", nsisVerboseFlag, nsisPath);
389     safeCommand(makeNsiCommand);
390     double sizeOfExe_mb = getSize(outExePath) / (1024.0*1024.0);
391     cwritefln("    => Build OK, binary size = %0.1f mb, available in %s\n".lgreen, sizeOfExe_mb, normalizedPath(outExePath));
392 
393     if (!plugin.hasKeyFileOrDevIdentityWindows)
394     {
395         warning(`Do not distribute an unsigned installer. See: https://github.com/AuburnSounds/Dplug/wiki/Dplug-Installer-Guide`);
396     }
397     else
398     {
399         signExecutableWindows(plugin, outExePath);
400     }
401 }
402 
403 
404 
405 void signExecutableWindows(Plugin plugin, string exePath)
406 {
407     try
408     {
409         string identity;
410         // Using developerIdentity-windows takes precedence over .P12 file and passwords
411         if (plugin.developerIdentityWindows !is null)
412         {
413             // sign using certificate in store (supports cloud signing like Certum)
414             identity = format(`/n %s`, escapeShellArgument(plugin.developerIdentityWindows));
415         }
416         else
417         {
418             // sign using keyfile and password in store (supports key file like Sectigo)
419             identity = format(`/f %s /p %s`, plugin.getKeyFileWindows(), plugin.getKeyPasswordWindows());
420         }
421 
422         enum DEFAULT_TIMESTAMP_SERVER_URL = "http://timestamp.sectigo.com";
423         string timestampURL = plugin.timestampServerURLWindows;
424         if (timestampURL is null)
425         {
426             info(`Using a default timestamp URL. Use "timestampServerURL-windows" in plugin.json to override`);
427             timestampURL = DEFAULT_TIMESTAMP_SERVER_URL;
428         }
429 
430         // use windows signtool to sign the installer for distribution
431         string cmd = format("signtool sign %s /tr %s /td sha256 /fd sha256 /q %s",
432                             identity,
433                             timestampURL,
434                             escapeShellArgument(exePath));
435         safeCommand(cmd);
436         cwriteln("    => OK\n".lgreen);
437     }
438     catch(Exception e)
439     {
440         error(format("Code signature failed! %s", e.msg));
441     }
442 }