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 }