1 /**
2 Copyright: Copyright (c) 2015-2016 Andrey Penechko.
3 License: $(WEB boost.org/LICENSE_1_0.txt, Boost License 1.0).
4 Authors: Andrey Penechko.
5 */
6 
7 module launcher;
8 
9 import std.experimental.logger;
10 import std.process;
11 import std..string;
12 import std.algorithm;
13 import std.stdio;
14 import std.array;
15 import std.range;
16 public import std.typecons : Flag, Yes, No;
17 import std.file;
18 import std.path;
19 import std.conv : to;
20 
21 import voxelman.world.worlddb;
22 import voxelman.text.messagewindow;
23 import voxelman.text.linebuffer;
24 import voxelman.gui.textedit.messagelog;
25 import gui;
26 
27 enum DEFAULT_PORT = 1234;
28 
29 version(Windows)
30 	enum bool is_Windows = true;
31 else
32 	enum bool is_Windows = false;
33 
34 struct PluginInfo
35 {
36 	string id;
37 	string semver;
38 	string downloadUrl;
39 	bool isEnabled = true;
40 	PluginInfo*[] dependencies;
41 	PluginInfo*[] dependants;
42 
43 	string guiName() { return id; }
44 }
45 
46 struct PluginPack
47 {
48 	string id;
49 	string semver;
50 	string filename;
51 	PluginInfo*[] plugins;
52 	string guiName() { return id; }
53 }
54 
55 enum AppType
56 {
57 	client,
58 	server,
59 	combined
60 }
61 
62 string[] appTypeString = ["client", "server", "combined"];
63 string[] appTypeTitle = ["Client", "Server", "Combined"];
64 
65 enum JobType : int
66 {
67 	run,
68 	compile,
69 	compileAndRun,
70 	test
71 }
72 
73 enum JobState
74 {
75 	build,
76 	run
77 }
78 
79 enum Compiler
80 {
81 	dmd,
82 	ldc,
83 	gdc
84 }
85 
86 enum BuildType
87 {
88 	bt_debug,
89 	bt_release,
90 	bt_release_debug
91 }
92 
93 string[] compilerExeNames = ["dmd", "ldc2", "gdc"];
94 string compilerUiSelectionString = "dmd\0ldc\0gdc\0\0";
95 string[] compilerUiOptions = ["dmd", "ldc", "gdc"];
96 
97 string[] buildTypeSwitches = ["debug", "release", "release-debug"];
98 string buildTypeUiSelectionString = "dbg\0rel\0rel-deb\0\0";
99 string[] buildTypeUiOptions = ["dbg", "rel", "rel-deb"];
100 
101 struct JobParams
102 {
103 	string[string] runParameters;
104 	AppType appType = AppType.client;
105 	Flag!"start" start = Yes.start;
106 	Flag!"build" build = Yes.build;
107 	Flag!"arch64" arch64 = Yes.arch64;
108 	Flag!"nodeps" nodeps = Yes.nodeps;
109 	Flag!"force" force = No.force;
110 	BuildType buildType;
111 	Compiler compiler;
112 	JobType jobType;
113 }
114 
115 struct Job
116 {
117 	JobParams params;
118 	string command;
119 	MessageWindow messageWindow;
120 	MessageLog msglog;
121 	ProcessPipes pipes;
122 
123 	JobState jobState = JobState.build;
124 	string title;
125 	bool autoClose;
126 	void delegate()[] onClose;
127 
128 	bool isRunning;
129 	bool needsClose;
130 	bool needsRestart;
131 	int status;
132 }
133 
134 string jobStateString(Job* job)
135 {
136 	if (!job.isRunning) return "[STOPPED]";
137 	final switch(job.jobState) with(JobState)
138 	{
139 		case build: break;
140 		case run: return "[RUNNING]";
141 	}
142 
143 	final switch(job.params.jobType) with(JobType)
144 	{
145 		case run: return "[INVALID]";
146 		case compile: return "[BUILDING]";
147 		case compileAndRun: return "[BUILDING]";
148 		case test: return "[TESTING]";
149 	}
150 	assert(false);
151 }
152 
153 struct ServerInfo
154 {
155 	string name;
156 	string ip;
157 	ushort port;
158 }
159 
160 struct SaveInfo
161 {
162 	string name;
163 	string displaySize;
164 	string path;
165 	ulong size;
166 
167 	string guiName() { return name; }
168 }
169 
170 immutable buildFolder = "builds/default";
171 immutable configFolder = "config";
172 immutable serversFname = "config/servers.txt";
173 immutable saveFolder = "saves";
174 immutable saveExtention = ".db";
175 
176 struct Launcher
177 {
178 	string pluginFolderPath;
179 	string pluginPackFolderPath;
180 	PluginInfo*[] plugins;
181 	PluginInfo*[string] pluginsById;
182 	PluginPack*[] pluginPacks;
183 	PluginPack*[string] pluginsPacksById;
184 
185 	ServerInfo*[] servers;
186 	SaveInfo*[] saves;
187 	WorldDb worldDb;
188 
189 	Job* clientProcess;
190 	Job* serverProcess;
191 
192 	Job*[] jobs;
193 	size_t numRunningJobs;
194 	LineBuffer appLog;
195 
196 	void init()
197 	{
198 		worldDb = new WorldDb;
199 	}
200 
201 	Job* createJob(JobParams params = JobParams.init)
202 	{
203 		auto job = new Job(params);
204 		job.messageWindow.messageHandler = (string com)=>sendCommand(job,com);
205 		job.msglog.clear;
206 		updateJobType(job);
207 		restartJobState(job);
208 		updateTitle(job);
209 		jobs ~= job;
210 		return job;
211 	}
212 
213 	static void updateTitle(Job* job)
214 	{
215 		job.title = appTypeTitle[job.params.appType];
216 	}
217 
218 	static void updateJobType(Job* job)
219 	{
220 		final switch(job.params.jobType) with(JobType) {
221 			case run:
222 				job.params.build = No.build;
223 				job.params.start = Yes.start;
224 				break;
225 			case compile:
226 				job.params.build = Yes.build;
227 				job.params.start = No.start;
228 				break;
229 			case compileAndRun:
230 				job.params.build = Yes.build;
231 				job.params.start = Yes.start;
232 				break;
233 			case test:
234 				job.params.build = Yes.build;
235 				job.params.start = No.start;
236 				break;
237 		}
238 	}
239 
240 	static void restartJobState(Job* job)
241 	{
242 		final switch(job.params.jobType) with(JobType) {
243 			case run: job.jobState = JobState.run; break;
244 			case compile: job.jobState = JobState.build; break;
245 			case compileAndRun: job.jobState = JobState.build; break;
246 			case test: job.jobState = JobState.build; break;
247 		}
248 	}
249 
250 	void startJob(Job* job)
251 	{
252 		assert(!job.isRunning);
253 		++numRunningJobs;
254 
255 		updateJobType(job);
256 		updateTitle(job);
257 
258 		string command;
259 		string workDir;
260 
261 		if (job.jobState == JobState.build)
262 		{
263 			final switch(job.params.jobType) with(JobType) {
264 				case run: return;
265 				case compile: goto case;
266 				case test: goto case;
267 				case compileAndRun:
268 					command = makeBuildOrTestCommand(job.params);
269 					workDir = "";
270 					break;
271 			}
272 		}
273 		else if (job.jobState == JobState.run) {
274 			command = makeRunCommand(job.params);
275 			workDir = buildFolder;
276 		}
277 
278 		job.msglog.putln(command);
279 
280 		ProcessPipes pipes = pipeShell(command, Redirect.all, null, Config.suppressConsole, workDir);
281 
282 		job.command = command;
283 		job.pipes = pipes;
284 
285 		job.isRunning = true;
286 		job.needsClose = false;
287 		job.needsRestart = false;
288 		job.status = 0;
289 	}
290 
291 	size_t stopProcesses()
292 	{
293 		foreach(job; jobs)
294 			job.pipes.pid.kill;
295 		return jobs.length;
296 	}
297 
298 	bool anyProcessesRunning() @property
299 	{
300 		return numRunningJobs > 0;
301 	}
302 
303 	void update()
304 	{
305 		foreach(job; jobs) logPipes(job);
306 
307 		foreach(job; jobs)
308 		{
309 			if (job.isRunning)
310 			{
311 				auto res = job.pipes.pid.tryWait();
312 				if (res.terminated)
313 				{
314 					--numRunningJobs;
315 					job.isRunning = false;
316 					job.status = res.status;
317 
318 					bool success = job.status == 0;
319 					bool doneBuild = job.jobState == JobState.build;
320 					bool needsStart = job.params.start;
321 					if (doneBuild)
322 					{
323 						onJobBuildCompletion(job, job.status == 0);
324 					}
325 					if (success && doneBuild && needsStart)
326 					{
327 						job.jobState = JobState.run;
328 						startJob(job);
329 					}
330 				}
331 			}
332 
333 			if (!job.isRunning && job.needsRestart)
334 			{
335 				job.messageWindow.lineBuffer.clear();
336 				job.msglog.clear;
337 				restartJobState(job);
338 				startJob(job);
339 			}
340 
341 			job.needsRestart = false;
342 
343 			if (!job.isRunning && job.autoClose)
344 			{
345 				job.needsClose = true;
346 			}
347 		}
348 
349 		Job*[] newJobs;
350 		foreach(job; jobs)
351 		{
352 			if (job.needsClose && !job.isRunning)
353 			{
354 				foreach(handler; job.onClose)
355 					handler();
356 			}
357 			else
358 			{
359 				job.needsClose = false;
360 				newJobs ~= job;
361 			}
362 		}
363 		jobs = newJobs;
364 	}
365 
366 	void setRootPath(string pluginFolder, string pluginPackFolder, string toolFolder)
367 	{
368 		pluginFolderPath = pluginFolder;
369 		pluginPackFolderPath = pluginPackFolder;
370 	}
371 
372 	void refresh()
373 	{
374 		clear();
375 		readPlugins();
376 		readPluginPacks();
377 		readServers();
378 		refreshSaves();
379 	}
380 
381 	void clear()
382 	{
383 		plugins = null;
384 		pluginsById = null;
385 		pluginPacks = null;
386 		pluginsPacksById = null;
387 		servers = null;
388 	}
389 
390 	void refreshSaves() {
391 		saves = null;
392 		readSaves();
393 	}
394 
395 	void readPlugins()
396 	{
397 		if (!exists(pluginFolderPath)) return;
398 		foreach (entry; dirEntries(pluginFolderPath, SpanMode.depth))
399 		{
400 			if (entry.isFile && baseName(entry.name) == "plugininfo.d")
401 			{
402 				string fileData = cast(string)read(entry.name);
403 				auto p = readPluginInfo(fileData);
404 				plugins ~= p;
405 				pluginsById[p.id] = p;
406 			}
407 		}
408 	}
409 
410 	void readPluginPacks()
411 	{
412 		foreach (entry; dirEntries(pluginPackFolderPath, SpanMode.depth))
413 		{
414 			if (entry.isFile && entry.name.extension == ".txt")
415 			{
416 				string fileData = cast(string)read(entry.name);
417 				auto pack = readPluginPack(fileData);
418 				pack.filename = entry.name.absolutePath.buildNormalizedPath;
419 				pluginPacks ~= pack;
420 				pluginsPacksById[pack.id] = pack;
421 			}
422 		}
423 	}
424 
425 	void readServers()
426 	{
427 		import std.regex : matchFirst, regex;
428 		if (!exists(serversFname)) return;
429 		string serversData = cast(string)read(serversFname);
430 		foreach(line; serversData.lineSplitter)
431 		{
432 			auto serverInfoStr = matchFirst(line, regex(`(?P<ip>[^:]*):(?P<port>\d{1,5})\s*(?P<name>.*)`, "s"));
433 			auto sinfo = new ServerInfo;
434 			sinfo.ip = serverInfoStr["ip"].toCString;
435 			sinfo.port = to!ushort(serverInfoStr["port"]);
436 			sinfo.name = serverInfoStr["name"].toCString;
437 			infof("%s", *sinfo);
438 			servers ~= sinfo;
439 		}
440 	}
441 
442 	void addServer(ServerInfo server)
443 	{
444 		auto info = new ServerInfo();
445 		*info = server;
446 		servers ~= info;
447 		saveServers();
448 	}
449 
450 	void removeServer(size_t serverIndex)
451 	{
452 		servers = remove(servers, serverIndex);
453 		saveServers();
454 	}
455 
456 	void saveServers()
457 	{
458 		import std.exception;
459 		try
460 		{
461 			auto f = File(serversFname, "w");
462 			foreach(server; servers)
463 			{
464 				f.writefln("%s:%s %s", server.ip, server.port, server.name);
465 			}
466 		}
467 		catch(ErrnoException e)
468 		{
469 			error(e);
470 		}
471 	}
472 
473 	// returns new save index
474 	size_t createSave(string name)
475 	{
476 		if (!exists(saveFolder))
477 		{
478 			mkdirRecurse(saveFolder);
479 		}
480 		auto saveFilename = buildPath(saveFolder, name~saveExtention).absolutePath;
481 		infof("create %s", saveFilename);
482 		worldDb.open(saveFilename);
483 		worldDb.close();
484 		refreshSaves();
485 		foreach(i, save; saves)
486 		{
487 			if (save.name == name)
488 				return i;
489 		}
490 
491 		return 0;
492 	}
493 
494 	void readSaves()
495 	{
496 		if (!exists(saveFolder)) return;
497 		foreach (entry; dirEntries(saveFolder, SpanMode.shallow))
498 		{
499 			if (entry.isFile && extension(entry.name) == saveExtention)
500 			{
501 				string name = entry.name.baseName.stripExtension.toCString;
502 				ulong fileSize = entry.size;
503 				string displaySize = formatFileSize(fileSize);
504 				saves ~= new SaveInfo(name, displaySize, entry.name.absolutePath, fileSize);
505 			}
506 		}
507 	}
508 
509 	void deleteSave(size_t saveIndex)
510 	{
511 		auto save = saves[saveIndex];
512 		infof("delete %s", *save);
513 		try	std.file.remove(save.path);
514 		catch (FileException e) warningf("error deleting save '%s': %s", save.path, e.msg);
515 		saves = remove(saves, saveIndex);
516 	}
517 
518 	void connect(ServerInfo* server, PluginPack* pack)
519 	{
520 		if (!clientProcess)
521 		{
522 			JobParams params;
523 			params.runParameters["pack"] = pack.id;
524 			params.appType = AppType.client;
525 			params.jobType = JobType.run;
526 			clientProcess = createJob(params);
527 			clientProcess.autoClose = true;
528 			clientProcess.onClose ~= &onClientClose;
529 			startJob(clientProcess);
530 		}
531 		sendCommand(clientProcess, format("connect --ip=%s --port=%s", server.ip, server.port));
532 	}
533 
534 	Job* startCombined(PluginPack* pack, SaveInfo* save)
535 	{
536 		if (!clientProcess)
537 		{
538 			JobParams params;
539 			params.runParameters["pack"] = pack.id;
540 			params.runParameters["world_name"] = save.name;
541 			params.appType = AppType.combined;
542 			params.jobType = JobType.run;
543 			clientProcess = createJob(params);
544 			clientProcess.autoClose = true;
545 			clientProcess.onClose ~= &onClientClose;
546 			startJob(clientProcess);
547 			infof("%s", clientProcess.command);
548 			return clientProcess;
549 		}
550 		return null;
551 	}
552 
553 	Job* startServer(PluginPack* pack, SaveInfo* save)
554 	{
555 		if (!serverProcess)
556 		{
557 			JobParams params;
558 			params.runParameters["pack"] = pack.id;
559 			params.runParameters["world_name"] = save.name;
560 			params.appType = AppType.server;
561 			params.jobType = JobType.run;
562 			serverProcess = createJob(params);
563 			serverProcess.autoClose = true;
564 			serverProcess.onClose ~= &onServerClose;
565 			startJob(serverProcess);
566 			infof("$> %s", serverProcess.command);
567 			return serverProcess;
568 		}
569 		return null;
570 	}
571 
572 	void onClientClose()
573 	{
574 		clientProcess = null;
575 		refresh();
576 	}
577 
578 	void onServerClose()
579 	{
580 		serverProcess = null;
581 	}
582 }
583 
584 string makeBuildOrTestCommand(JobParams params)
585 {
586 	if (params.jobType == JobType.test)
587 		return makeTestCommand(params);
588 	else
589 		return makeBuildCommand(params);
590 }
591 
592 string makeRunCommand(JobParams params)
593 {
594 	string command = format("voxelman --app=%s --console_log", appTypeString[params.appType]);
595 
596 	foreach(paramName, paramValue; params.runParameters)
597 	{
598 		if (paramValue)
599 			command ~= format(` --%s="%s"`, paramName, paramValue);
600 		else
601 			command ~= format(" --%s", paramName);
602 	}
603 
604 	command ~= '\0';
605 	return command[0..$-1];
606 }
607 
608 string makeBuildCommand(JobParams params)
609 {
610 	string arch;
611 	if (params.compiler == Compiler.dmd && is_Windows)
612 		arch = params.arch64 ? `--arch=x86_64` : `--arch=x86_mscoff`;
613 	else
614 		arch = params.arch64 ? `--arch=x86_64` : `--arch=x86`;
615 
616 	immutable deps = params.nodeps ? ` --nodeps` : ``;
617 	immutable doForce = params.force ? ` --force` : ``;
618 	immutable buildType = buildTypeSwitches[params.buildType];
619 	immutable compiler = format(`--compiler=%s`, compilerExeNames[params.compiler]);
620 	return format("dub build -q %s %s --config=exe%s%s --build=%s\0", arch, compiler, deps, doForce, buildType)[0..$-1];
621 }
622 
623 string makeTestCommand(JobParams params)
624 {
625 	immutable arch = params.arch64 ? `--arch=x86_64` : `--arch=x86`;
626 	immutable deps = params.nodeps ? ` --nodeps` : ``;
627 	immutable doForce = params.force ? ` --force` : ``;
628 	immutable compiler = format(`--compiler=%s`, compilerExeNames[params.compiler]);
629 	return format("dub test -q %s %s %s %s\0", arch, compiler, deps, doForce)[0..$-1];
630 }
631 
632 void onJobBuildCompletion(Job* job, bool success)
633 {
634 	if (job.params.jobType != JobType.test)
635 	{
636 		auto msg = success ? "Compilation successful" : "Compilation failed";
637 		job.messageWindow.putln(msg);
638 		job.msglog.putln(msg);
639 	}
640 }
641 
642 void sendCommand(Job* job, string command)
643 {
644 	if (!job.isRunning) return;
645 	job.pipes.stdin.rawWrite(command);
646 	job.pipes.stdin.rawWrite("\n");
647 }
648 
649 void logPipes(Job* job)
650 {
651 	import std.exception : ErrnoException;
652 	import std.utf : UTFException;
653 	if (!job.isRunning) return;
654 
655 	try
656 	{
657 		foreach(pipe; only(job.pipes.stdout, job.pipes.stderr))
658 		{
659 			auto size = pipe.size;
660 			if (size > 0)
661 			{
662 				char[1024] buf;
663 				size_t charsToRead = min(pipe.size, buf.length);
664 				char[] data = pipe.rawRead(buf[0..charsToRead]);
665 				job.messageWindow.lineBuffer.put(data);
666 				job.msglog.put(data);
667 			}
668 		}
669 	}
670 	catch(ErrnoException e)
671 	{	// Ignore e
672 		// It happens only when both launcher and child process is 32bit
673 		// and child crashes with access violation (in opengl call for example).
674 		// exception std.exception.ErrnoException@std\stdio.d(920):
675 		// Could not seek in file `HANDLE(32C)' (Invalid argument)
676 	}
677 	catch(UTFException e)
678 	{	// Ignore e
679 	}
680 }
681 
682 PluginInfo* readPluginInfo(string fileData)
683 {
684 	import std.regex : matchFirst, regex;
685 
686 	auto pinfo = new PluginInfo;
687 
688 	auto idCapture = matchFirst(fileData, regex(`id\s*=\s*"(?P<id>[^"]*)"`, "s"));
689 	pinfo.id = idCapture["id"].toCString;
690 
691 	auto semverCapture = matchFirst(fileData, regex(`semver\s*=\s*"(?P<semver>[^"]*)"`, "s"));
692 	pinfo.semver = semverCapture["semver"].toCString;
693 
694 	return pinfo;
695 }
696 
697 PluginPack* readPluginPack(string fileData)
698 {
699 	import std.array : empty;
700 	import std.regex : matchFirst, regex;
701 	import std..string : lineSplitter;
702 
703 	auto pack = new PluginPack;
704 
705 	auto input = fileData.lineSplitter;
706 
707 	if (!input.empty) {
708 		auto packInfo = matchFirst(input.front, regex(`(?P<id>.*) (?P<semver>.*)`, "m"));
709 		pack.id = packInfo["id"].toCString;
710 		pack.semver = packInfo["semver"].toCString;
711 		input.popFront;
712 	}
713 
714 	foreach(line; input)
715 	{
716 		if (line.empty)
717 			continue;
718 
719 		auto pluginInfo = matchFirst(line, regex(`(?P<id>.*) (?P<semver>.*)`, "m"));
720 		auto pinfo = new PluginInfo;
721 		pinfo.id = pluginInfo["id"].toCString;
722 		pinfo.semver = pluginInfo["semver"].toCString;
723 		pack.plugins ~= pinfo;
724 	}
725 
726 	return pack;
727 }
728 
729 string formatFileSize(ulong fileSize)
730 {
731 	import voxelman.text.scale;
732 	int scale = calcScale(fileSize);
733 	double scaledSize = scaled(fileSize, scale);
734 	auto prec = stepPrecision(scaledSize);
735 	string unitPrefix = scales[scale];
736 	return format("%.*f %sB", prec, scaledSize, unitPrefix);
737 }
738 
739 string toCString(in const(char)[] s)
740 {
741 	import std.exception : assumeUnique;
742 	auto copy = new char[s.length + 1];
743 	copy[0..s.length] = s[];
744 	copy[s.length] = '\0';
745 	return assumeUnique(copy[0..s.length]);
746 }
747 
748 string fromCString(char[] str)
749 {
750 	char[] chars = str.ptr.fromStringz();
751 	return chars.ptr[0..chars.length+1].idup[0..$-1];
752 }