1 module plugin; 2 3 import std.conv; 4 import std.process; 5 import std.string; 6 import std.file; 7 import std.regex; 8 import std.json; 9 import std.path; 10 import std.stdio; 11 import std.datetime; 12 13 import colorize; 14 import utils; 15 16 enum Compiler 17 { 18 ldc, 19 gdc, 20 dmd, 21 } 22 23 enum Arch 24 { 25 x86, 26 x86_64, 27 universalBinary 28 } 29 30 Arch[] allArchitectureqForThisPlatform() 31 { 32 Arch[] archs = [Arch.x86, Arch.x86_64]; 33 version (OSX) 34 archs ~= [Arch.universalBinary]; // only Mac has universal binaries 35 return archs; 36 } 37 38 string toString(Arch arch) 39 { 40 final switch(arch) with (Arch) 41 { 42 case x86: return "32-bit"; 43 case x86_64: return "64-bit"; 44 case universalBinary: return "Universal-Binary"; 45 } 46 } 47 48 string toString(Compiler compiler) 49 { 50 final switch(compiler) with (Compiler) 51 { 52 case dmd: return "dmd"; 53 case gdc: return "gdc"; 54 case ldc: return "ldc"; 55 } 56 } 57 58 string toStringArchs(Arch[] archs) 59 { 60 string r = ""; 61 foreach(int i, arch; archs) 62 { 63 final switch(arch) with (Arch) 64 { 65 case x86: 66 if (i) r ~= " and "; 67 r ~= "32-bit"; 68 break; 69 case x86_64: 70 if (i) r ~= " and "; 71 r ~= "64-bit"; 72 break; 73 case universalBinary: break; 74 } 75 } 76 return r; 77 } 78 79 bool configIsVST(string config) 80 { 81 return config.length >= 3 && config[0..3] == "VST"; 82 } 83 84 bool configIsAU(string config) 85 { 86 return config.length >= 2 && config[0..2] == "AU"; 87 } 88 89 struct Plugin 90 { 91 string name; // name, extracted from dub.json(eg: 'distort') 92 string CFBundleIdentifierPrefix; 93 string userManualPath; // can be null 94 string licensePath; // can be null 95 string iconPath; // can be null or a path to a (large) .png 96 bool hasGUI; 97 98 string pluginName; // Prettier name, extracted from plugin.json (eg: 'Distorter') 99 string pluginUniqueID; 100 string vendorName; 101 string vendorUniqueID; 102 103 // Available configurations, taken from dub.json 104 string[] configurations; 105 106 // Public version of the plugin 107 // Each release of a plugin should upgrade the version somehow 108 int publicVersionMajor; 109 int publicVersionMinor; 110 int publicVersionPatch; 111 112 113 bool receivesMIDI; 114 bool isSynth; 115 116 117 string prettyName() pure const nothrow 118 { 119 return vendorName ~ " " ~ pluginName; 120 } 121 122 string publicVersionString() pure const nothrow 123 { 124 return to!string(publicVersionMajor) ~ "." ~ to!string(publicVersionMinor) ~ "." ~ to!string(publicVersionMinor); 125 } 126 127 // AU version integer 128 int publicVersionInt() pure const nothrow 129 { 130 return (publicVersionMajor << 16) | (publicVersionMinor << 8) | publicVersionPatch; 131 } 132 133 string makePkgInfo() pure const nothrow 134 { 135 return "BNDL" ~ vendorUniqueID; 136 } 137 138 string copyright() const // Copyright information, copied in the OSX bundle 139 { 140 SysTime time = Clock.currTime(UTC()); 141 return format("Copyright %s, %s", vendorName, time.year); 142 } 143 144 // only a handful of characters are accepter in bundle identifiers 145 static string sanitizeBundleString(string s) pure 146 { 147 string r = ""; 148 foreach(dchar ch; s) 149 { 150 if (ch >= 'A' && ch <= 'Z') 151 r ~= ch; 152 else if (ch >= 'a' && ch <= 'z') 153 r ~= ch; 154 else if (ch == '.') 155 r ~= ch; 156 else 157 r ~= "-"; 158 } 159 return r; 160 } 161 162 // copied from dub logic 163 string targetFileName() pure const nothrow 164 { 165 version(Windows) 166 return name ~ ".dll"; 167 else 168 return "lib" ~ name ~ ".so"; 169 } 170 171 string getVSTBundleIdentifier() pure const 172 { 173 return CFBundleIdentifierPrefix ~ ".vst." ~ sanitizeBundleString(pluginName); 174 } 175 176 string getAUBundleIdentifier() pure const 177 { 178 return CFBundleIdentifierPrefix ~ ".audiounit." ~ sanitizeBundleString(pluginName); 179 } 180 181 string[] getAllConfigurations() 182 { 183 return configurations; 184 } 185 } 186 187 Plugin readPluginDescription() 188 { 189 if (!exists("dub.json")) 190 throw new Exception("Needs a dub.json file. Please launch 'release' in a D project directory."); 191 192 Plugin result; 193 194 enum useDubDescribe = true; 195 196 // Open an eventual plugin.json directly to find keys that DUB doesn't bypass 197 JSONValue dubFile = parseJSON(cast(string)(std.file.read("dub.json"))); 198 199 try 200 { 201 result.name = dubFile["name"].str; 202 } 203 catch(Exception e) 204 { 205 throw new Exception("Missing \"name\" in dub.json (eg: \"myplugin\")"); 206 } 207 208 try 209 { 210 JSONValue[] config = dubFile["configurations"].array(); 211 foreach(c; config) 212 result.configurations ~= c["name"].str; 213 } 214 catch(Exception e) 215 { 216 warning("Couldln't parse configurations names in dub.json."); 217 result.configurations = []; 218 } 219 220 if (!exists("plugin.json")) 221 throw new Exception("Needs a plugin.json file for proper bundling. Please create one next to dub.json."); 222 223 // Open an eventual plugin.json directly to find keys that DUB doesn't bypass 224 JSONValue rawPluginFile = parseJSON(cast(string)(std.file.read("plugin.json"))); 225 226 // Optional keys 227 228 // prettyName is the fancy Manufacturer + Product name that will be displayed as much as possible in: 229 // - bundle name 230 // - renamed executable file names 231 try 232 { 233 result.pluginName = rawPluginFile["pluginName"].str; 234 } 235 catch(Exception e) 236 { 237 info("Missing \"pluginName\" in plugin.json (eg: \"My Compressor\")\n => Using dub.json \"name\" key instead."); 238 result.pluginName = result.name; 239 } 240 241 // Note: release parses it but doesn't need hasGUI 242 try 243 { 244 result.hasGUI = toBool(rawPluginFile["hasGUI"]); 245 } 246 catch(Exception e) 247 { 248 warning("Missing \"hasGUI\" in plugin.json (must be true or false)\n => Using false instead."); 249 result.hasGUI = false; 250 } 251 252 try 253 { 254 result.userManualPath = rawPluginFile["userManualPath"].str; 255 } 256 catch(Exception e) 257 { 258 info("Missing \"userManualPath\" in plugin.json (eg: \"UserManual.pdf\")"); 259 } 260 261 try 262 { 263 result.licensePath = rawPluginFile["licensePath"].str; 264 } 265 catch(Exception e) 266 { 267 info("Missing \"licensePath\" in plugin.json (eg: \"license.txt\")"); 268 } 269 270 try 271 { 272 result.iconPath = rawPluginFile["iconPath"].str; 273 } 274 catch(Exception e) 275 { 276 info("Missing \"iconPath\" in plugin.json (eg: \"gfx/myIcon.png\")"); 277 } 278 279 // Mandatory keys, but with workarounds 280 281 try 282 { 283 result.CFBundleIdentifierPrefix = rawPluginFile["CFBundleIdentifierPrefix"].str; 284 } 285 catch(Exception e) 286 { 287 warning("Missing \"CFBundleIdentifierPrefix\" in plugin.json (eg: \"com.myaudiocompany\")\n => Using \"com.totoaudio\" instead."); 288 result.CFBundleIdentifierPrefix = "com.totoaudio"; 289 } 290 291 try 292 { 293 result.vendorName = rawPluginFile["vendorName"].str; 294 295 } 296 catch(Exception e) 297 { 298 warning("Missing \"vendorName\" in plugin.json (eg: \"Example Corp\")\n => Using \"Toto Audio\" instead."); 299 result.vendorName = "Toto Audio"; 300 } 301 302 try 303 { 304 result.vendorUniqueID = rawPluginFile["vendorUniqueID"].str; 305 } 306 catch(Exception e) 307 { 308 warning("Missing \"vendorUniqueID\" in plugin.json (eg: \"aucd\")\n => Using \"Toto\" instead."); 309 result.vendorUniqueID = "Toto"; 310 } 311 312 if (result.vendorUniqueID.length != 4) 313 throw new Exception("\"vendorUniqueID\" should be a string of 4 characters (eg: \"aucd\")"); 314 315 try 316 { 317 result.pluginUniqueID = rawPluginFile["pluginUniqueID"].str; 318 } 319 catch(Exception e) 320 { 321 warning("Missing \"pluginUniqueID\" provided in plugin.json (eg: \"val8\")\n => Using \"tot0\" instead, change it for a proper release."); 322 result.pluginUniqueID = "tot0"; 323 } 324 325 if (result.pluginUniqueID.length != 4) 326 throw new Exception("\"pluginUniqueID\" should be a string of 4 characters (eg: \"val8\")"); 327 328 // In developpement, should stay at 0.x.y to avoid various AU caches 329 string publicV; 330 try 331 { 332 publicV = rawPluginFile["publicVersion"].str; 333 } 334 catch(Exception e) 335 { 336 warning("no \"publicVersion\" provided in plugin.json (eg: \"1.0.1\")\n => Using \"0.0.0\" instead."); 337 publicV = "0.0.0"; 338 } 339 340 if (auto captures = matchFirst(publicV, regex(`(\d+)\.(\d+)\.(\d+)`))) 341 { 342 result.publicVersionMajor = to!int(captures[1]); 343 result.publicVersionMinor = to!int(captures[2]); 344 result.publicVersionPatch = to!int(captures[3]); 345 } 346 else 347 { 348 throw new Exception("\"publicVersion\" should follow the form x.y.z with 3 integers (eg: \"1.0.0\")"); 349 } 350 351 bool toBoolean(JSONValue value) 352 { 353 if (value.type == JSON_TYPE.TRUE) 354 return true; 355 if (value.type == JSON_TYPE.FALSE) 356 return false; 357 throw new Exception("Expected a boolean"); 358 } 359 360 try 361 { 362 result.isSynth = toBoolean(rawPluginFile["isSynth"]); 363 } 364 catch(Exception e) 365 { 366 warning("no \"isSynth\" provided in plugin.json (eg: \"true\")\n => Using \"false\" instead."); 367 result.isSynth = false; 368 } 369 370 try 371 { 372 result.receivesMIDI = toBoolean(rawPluginFile["receivesMIDI"]); 373 } 374 catch(Exception e) 375 { 376 warning("no \"receivesMIDI\" provided in plugin.json (eg: \"true\")\n => Using \"false\" instead."); 377 result.receivesMIDI = false; 378 } 379 380 return result; 381 } 382 383 bool toBool(JSONValue v) 384 { 385 if (v.type == JSON_TYPE.FALSE) 386 return false; 387 else if (v.type == JSON_TYPE.TRUE) 388 return true; 389 else 390 throw new Exception("expected boolean value"); 391 } 392 393 394 string makePListFile(Plugin plugin, string config, bool hasIcon) 395 { 396 string productVersion = plugin.publicVersionString; 397 string content = ""; 398 399 content ~= `<?xml version="1.0" encoding="UTF-8"?>` ~ "\n"; 400 content ~= `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` ~ "\n"; 401 content ~= `<plist version="1.0">` ~ "\n"; 402 content ~= ` <dict>` ~ "\n"; 403 404 void addKeyString(string key, string value) 405 { 406 content ~= format(" <key>%s</key>\n <string>%s</string>\n", key, value); 407 } 408 409 addKeyString("CFBundleDevelopmentRegion", "English"); 410 411 addKeyString("CFBundleGetInfoString", productVersion ~ ", " ~ plugin.copyright); 412 413 string CFBundleIdentifier; 414 if (configIsVST(config)) 415 CFBundleIdentifier = plugin.getVSTBundleIdentifier(); 416 else if (configIsAU(config)) 417 CFBundleIdentifier = plugin.getAUBundleIdentifier(); 418 else 419 throw new Exception("Configuration name given by --config must start with \"VST\" or \"AU\""); 420 421 // Doesn't seem useful at all 422 //addKeyString("CFBundleName", plugin.prettyName); 423 //addKeyString("CFBundleExecutable", plugin.prettyName); 424 425 addKeyString("CFBundleIdentifier", CFBundleIdentifier); 426 427 addKeyString("CFBundleVersion", productVersion); 428 addKeyString("CFBundleShortVersionString", productVersion); 429 430 431 enum isAudioComponentAPIImplemented = false; 432 433 if (isAudioComponentAPIImplemented && configIsAU(config)) 434 { 435 content ~= " <key>AudioComponents</key>\n"; 436 content ~= " <array>\n"; 437 content ~= " <dict>\n"; 438 content ~= " <key>type</key>\n"; 439 if (plugin.isSynth) 440 content ~= " <string>aumu</string>\n"; 441 else if (plugin.receivesMIDI) 442 content ~= " <string>aumf</string>\n"; 443 else 444 content ~= " <string>aufx</string>\n"; 445 content ~= " <key>subtype</key>\n"; 446 content ~= " <string>dely</string>\n"; 447 content ~= " <key>manufacturer</key>\n"; 448 content ~= " <string>" ~ plugin.vendorUniqueID ~ "</string>\n"; // FUTURE XML escape that 449 content ~= " <key>name</key>\n"; 450 content ~= format(" <string>%s</string>\n", plugin.pluginName); 451 content ~= " <key>version</key>\n"; 452 content ~= format(" <integer>%s</integer>\n", plugin.publicVersionInt()); // correct? 453 content ~= " <key>factoryFunction</key>\n"; 454 content ~= " <string>dplugAUComponentFactoryFunction</string>\n"; 455 content ~= " <key>sandboxSafe</key><true/>\n"; 456 content ~= " </dict>\n"; 457 content ~= " </array>\n"; 458 } 459 460 addKeyString("CFBundleInfoDictionaryVersion", "6.0"); 461 addKeyString("CFBundlePackageType", "BNDL"); 462 addKeyString("CFBundleSignature", plugin.pluginUniqueID); // doesn't matter http://stackoverflow.com/questions/1875912/naming-convention-for-cfbundlesignature-and-cfbundleidentifier 463 464 addKeyString("LSMinimumSystemVersion", "10.7.0"); 465 // content ~= " <key>VSTWindowCompositing</key><true/>\n"; 466 467 if (hasIcon) 468 addKeyString("CFBundleIconFile", "icon"); 469 content ~= ` </dict>` ~ "\n"; 470 content ~= `</plist>` ~ "\n"; 471 return content; 472 } 473 474 475 // return path of newly made icon 476 string makeMacIcon(string pluginName, string pngPath) 477 { 478 string temp = tempDir(); 479 string iconSetDir = buildPath(tempDir(), pluginName ~ ".iconset"); 480 string outputIcon = buildPath(tempDir(), pluginName ~ ".icns"); 481 482 if(!outputIcon.exists) 483 { 484 //string cmd = format("lipo -create %s %s -output %s", path32, path64, exePath); 485 try 486 { 487 safeCommand(format("mkdir %s", iconSetDir)); 488 } 489 catch(Exception e) 490 { 491 cwritefln(" => %s".yellow, e.msg); 492 } 493 safeCommand(format("sips -z 16 16 %s --out %s/icon_16x16.png", pngPath, iconSetDir)); 494 safeCommand(format("sips -z 32 32 %s --out %s/icon_16x16@2x.png", pngPath, iconSetDir)); 495 safeCommand(format("sips -z 32 32 %s --out %s/icon_32x32.png", pngPath, iconSetDir)); 496 safeCommand(format("sips -z 64 64 %s --out %s/icon_32x32@2x.png", pngPath, iconSetDir)); 497 safeCommand(format("sips -z 128 128 %s --out %s/icon_128x128.png", pngPath, iconSetDir)); 498 safeCommand(format("sips -z 256 256 %s --out %s/icon_128x128@2x.png", pngPath, iconSetDir)); 499 safeCommand(format("iconutil --convert icns --output %s %s", outputIcon, iconSetDir)); 500 } 501 return outputIcon; 502 } 503 504 string makeRSRC(Plugin plugin, Arch arch, bool verbose) 505 { 506 string pluginName = plugin.pluginName; 507 cwritefln("*** Generating a .rsrc file for the bundle...".white); 508 string temp = tempDir(); 509 510 string rPath = buildPath(temp, "plugin.r"); 511 512 File rFile = File(rPath, "w"); 513 static immutable string rFileBase = cast(string) import("plugin-base.r"); 514 515 rFile.writefln(`#define PLUG_MFR "%s"`, plugin.vendorName); // no C escaping there, FUTURE 516 rFile.writefln("#define PLUG_MFR_ID '%s'", plugin.vendorUniqueID); 517 rFile.writefln(`#define PLUG_NAME "%s"`, pluginName); // no C escaping there, FUTURE 518 rFile.writefln("#define PLUG_UNIQUE_ID '%s'", plugin.pluginUniqueID); 519 rFile.writefln("#define PLUG_VER %d", plugin.publicVersionInt()); 520 521 rFile.writefln("#define PLUG_IS_INST %s", (plugin.isSynth ? "1" : "0")); 522 rFile.writefln("#define PLUG_DOES_MIDI %s", (plugin.receivesMIDI ? "1" : "0")); 523 524 rFile.writeln(rFileBase); 525 rFile.close(); 526 527 string rsrcPath = buildPath(temp, "plugin.rsrc"); 528 529 string archFlags; 530 final switch(arch) with (Arch) 531 { 532 case x86: archFlags = "-arch i386"; break; 533 case x86_64: archFlags = "-arch x86_64"; break; 534 case universalBinary: archFlags = "-arch i386 -arch x86_64"; break; 535 } 536 537 string verboseFlag = verbose ? " -p" : ""; 538 /* -t BNDL */ 539 safeCommand(format("rez %s%s -o %s -useDF %s", archFlags, verboseFlag, rsrcPath, rPath)); 540 541 542 if (!exists(rsrcPath)) 543 throw new Exception(format("%s wasn't created", rsrcPath)); 544 545 if (getSize(rsrcPath) == 0) 546 throw new Exception(format("%s is an empty file", rsrcPath)); 547 548 cwritefln(" => Written %s bytes.".green, getSize(rsrcPath)); 549 cwriteln(); 550 return rsrcPath; 551 }