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 }