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