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