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 }