1 /++
2     Functions that deal with OS- and/or platform-specifics.
3 
4     See_Also:
5         [kameloso.terminal]
6 
7     Copyright: [JR](https://github.com/zorael)
8     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
9 
10     Authors:
11         [JR](https://github.com/zorael)
12  +/
13 module kameloso.platform;
14 
15 private:
16 
17 import std.process : Pid;
18 
19 public:
20 
21 @safe:
22 
23 
24 // currentPlatform
25 /++
26     Returns the string of the name of the current platform, adjusted to include
27     `cygwin` as an alternative next to `win32` and `win64`, as well as embedded
28     terminal consoles like in Visual Studio Code.
29 
30     Example:
31     ---
32     switch (currentPlatform)
33     {
34     case "Cygwin":
35     case "vscode":
36         // Special code for the terminal not being a conventional terminal
37         // (instead acting like a pager)
38         break;
39 
40     default:
41         // Code for normal terminal
42         break;
43     }
44     ---
45 
46     Returns:
47         String name of the current platform.
48  +/
49 auto currentPlatform()
50 {
51     import lu.conv : Enum;
52     import std.process : environment;
53     import std.system : os;
54 
55     enum osName = Enum!(typeof(os)).toString(os);
56 
57     version(Windows)
58     {
59         immutable term = environment.get("TERM", string.init);
60 
61         if (term.length)
62         {
63             try
64             {
65                 import std.process : execute;
66 
67                 // Get the uname and strip the newline
68                 static immutable unameCommand = [ "uname", "-o" ];
69                 immutable uname = execute(unameCommand).output;
70                 return uname.length ? uname[0..$-1] : osName;
71             }
72             catch (Exception _)
73             {
74                 return osName;
75             }
76         }
77         else
78         {
79             return osName;
80         }
81     }
82     else
83     {
84         return environment.get("TERM_PROGRAM", osName);
85     }
86 }
87 
88 
89 // configurationBaseDirectory
90 /++
91     Divines the default configuration file base directory, depending on what
92     platform we're currently running.
93 
94     On non-macOS Posix it defaults to `$XDG_CONFIG_HOME` and falls back to
95     `~/.config` if no `$XDG_CONFIG_HOME` environment variable present.
96 
97     On macOS it defaults to `$HOME/Library/Application Support`.
98 
99     On Windows it defaults to `%APPDATA%`.
100 
101     Returns:
102         A string path to the default configuration file.
103  +/
104 auto configurationBaseDirectory()
105 {
106     import std.process : environment;
107 
108     version(OSX)
109     {
110         import std.path : buildNormalizedPath;
111         return buildNormalizedPath(
112             environment["HOME"],
113             "Library",
114             "Preferences");
115     }
116     else version(Posix)
117     {
118         import std.path : expandTilde;
119 
120         // Assume XDG
121         enum defaultDir = "~/.config";
122         return environment.get("XDG_CONFIG_HOME", defaultDir).expandTilde;
123     }
124     else version(Windows)
125     {
126         // Blindly assume %APPDATA% is defined
127         return environment["APPDATA"];
128     }
129     else
130     {
131         static assert(0, "Unsupported platform, please file a bug.");
132     }
133 }
134 
135 ///
136 unittest
137 {
138     import std.algorithm.searching : endsWith;
139 
140     immutable cfgd = configurationBaseDirectory;
141 
142     version(OSX)
143     {
144         assert(cfgd.endsWith("Library/Preferences"), cfgd);
145     }
146     else version(Posix)
147     {
148         import std.process : environment;
149 
150         environment["XDG_CONFIG_HOME"] = "/tmp";
151         immutable cfgdTmp = configurationBaseDirectory;
152         assert((cfgdTmp == "/tmp"), cfgdTmp);
153 
154         environment.remove("XDG_CONFIG_HOME");
155         immutable cfgdWithout = configurationBaseDirectory;
156         assert(cfgdWithout.endsWith("/.config"), cfgdWithout);
157     }
158     else version(Windows)
159     {
160         assert(cfgd.endsWith("\\Roaming"), cfgd);
161     }
162 }
163 
164 
165 // resourceBaseDirectory
166 /++
167     Divines the default resource base directory, depending on what platform
168     we're currently running.
169 
170     On non-macOS Posix it defaults to `$XDG_DATA_HOME` and falls back to
171     `$HOME/.local/share` if no `$XDG_DATA_HOME` environment variable present.
172 
173     On macOS it defaults to `$HOME/Library/Application Support`.
174 
175     On Windows it defaults to `%LOCALAPPDATA%`.
176 
177     Returns:
178         A string path to the default resource base directory.
179  +/
180 auto resourceBaseDirectory()
181 {
182     import std.process : environment;
183 
184     version(OSX)
185     {
186         import std.path : buildNormalizedPath;
187         return buildNormalizedPath(
188             environment["HOME"],
189             "Library",
190             "Application Support");
191     }
192     else version(Posix)
193     {
194         import std.path : expandTilde;
195         enum defaultDir = "~/.local/share";
196         return environment.get("XDG_DATA_HOME", defaultDir).expandTilde;
197     }
198     else version(Windows)
199     {
200         // Blindly assume %LOCALAPPDATA% is defined
201         return environment["LOCALAPPDATA"];
202     }
203     else
204     {
205         static assert(0, "Unsupported platform, please file a bug.");
206     }
207 }
208 
209 ///
210 unittest
211 {
212     import std.algorithm.searching : endsWith;
213 
214     version(OSX)
215     {
216         immutable rbd = resourceBaseDirectory;
217         assert(rbd.endsWith("Library/Application Support"), rbd);
218     }
219     else version(Posix)
220     {
221         import lu.string : beginsWith;
222         import std.process : environment;
223 
224         environment["XDG_DATA_HOME"] = "/tmp";
225         string rbd = resourceBaseDirectory;
226         assert((rbd == "/tmp"), rbd);
227 
228         environment.remove("XDG_DATA_HOME");
229         rbd = resourceBaseDirectory;
230         assert(rbd.beginsWith("/home/") && rbd.endsWith("/.local/share"));
231     }
232     else version(Windows)
233     {
234         immutable rbd = resourceBaseDirectory;
235         assert(rbd.endsWith("\\Local"), rbd);
236     }
237 }
238 
239 
240 // openInBrowser
241 /++
242     Opens up the passed URL in a web browser.
243 
244     Params:
245         url = URL to open.
246 
247     Returns:
248         A [std.process.Pid|Pid] of the spawned process. Remember to [std.process.wait|wait].
249 
250     Throws:
251         [object.Exception|Exception] if there were no `DISPLAY` environment
252         variable on non-macOS Posix platforms, indicative of no X.org server or
253         Wayland compositor running.
254  +/
255 auto openInBrowser(const string url)
256 {
257     import std.stdio : File;
258 
259     version(Posix)
260     {
261         import std.process : ProcessException, environment, spawnProcess;
262 
263         version(OSX)
264         {
265             enum open = "open";
266         }
267         else
268         {
269             // Assume XDG
270             enum open = "xdg-open";
271 
272             if (!environment.get("DISPLAY", string.init).length &&
273                 !environment.get("WAYLAND_DISPLAY", string.init).length)
274             {
275                 throw new Exception("No graphical interface detected");
276             }
277         }
278 
279         immutable browserExecutable = environment.get("BROWSER", open);
280         string[2] browserCommand = [ browserExecutable, url ];  // mutable
281         auto devNull = File("/dev/null", "r+");
282 
283         try
284         {
285             return spawnProcess(browserCommand[], devNull, devNull, devNull);
286         }
287         catch (ProcessException e)
288         {
289             if (browserExecutable == open) throw e;
290 
291             browserCommand[0] = open;
292             return spawnProcess(browserCommand[], devNull, devNull, devNull);
293         }
294     }
295     else version(Windows)
296     {
297         import std.file : tempDir;
298         import std.format : format;
299         import std.path : buildPath;
300         import std.process : spawnProcess;
301 
302         enum urlBasename = "kameloso-browser.url";
303         immutable urlFileName = buildPath(tempDir, urlBasename);
304 
305         {
306             auto urlFile = File(urlFileName, "w");
307             urlFile.writeln("[InternetShortcut]\nURL=", url);
308         }
309 
310         immutable string[2] browserCommand = [ "explorer", urlFileName ];
311         auto nulFile = File("NUL", "r+");
312         return spawnProcess(browserCommand[], nulFile, nulFile, nulFile);
313     }
314     else
315     {
316         static assert(0, "Unsupported platform, please file a bug.");
317     }
318 }
319 
320 
321 // execvp
322 /++
323     Re-executes the program.
324 
325     Filters out any captive `--set twitch.*` keygen settings from the
326     arguments originally passed to the program, then calls
327     [std.process.execvp|execvp].
328 
329     On Windows, the behaviour is faked using [std.process.spawnProcess|spawnProcess].
330 
331     Params:
332         args = Arguments passed to the program.
333 
334     Returns:
335         On Windows, a [std.process.Pid|Pid] of the spawned process.
336         On Posix, it either exits the program or it throws.
337 
338     Throws:
339         On Posix, [ExecException] on failure.
340         On Windows, [std.process.ProcessException|ProcessException] on failure.
341  +/
342 Pid execvp(/*const*/ string[] args) @system
343 {
344     import kameloso.common : logger;
345     import std.algorithm.comparison : among;
346 
347     if (args.length > 1)
348     {
349         size_t[] toRemove;
350 
351         for (size_t i=1; i<args.length; ++i)
352         {
353             import lu.string : beginsWith, nom;
354             import std.typecons : Flag, No, Yes;
355 
356             if (args[i] == "--set")
357             {
358                 if (args.length <= i+1) continue;  // should never happen
359 
360                 string slice = args[i+1];  // mutable
361 
362                 if (slice.beginsWith("twitch."))
363                 {
364                     immutable setting = slice.nom!(Yes.inherit)('=');
365 
366                     if (setting.among!(
367                         "twitch.keygen",
368                         "twitch.superKeygen",
369                         "twitch.googleKeygen",
370                         "twitch.spotifyKeygen"))
371                     {
372                         toRemove ~= i;
373                         toRemove ~= i+1;
374                         ++i;  // Skip next entry
375                     }
376                 }
377             }
378             else
379             {
380                 string slice = args[i];  // mutable
381                 immutable setting = slice.nom!(Yes.inherit)('=');
382 
383                 if (setting.among!(
384                     "--setup-twitch",
385                     "--get-cacert",
386                     "--get-openssl"))
387                 {
388                     toRemove ~= i;
389                 }
390             }
391         }
392 
393         foreach (immutable i; toRemove)
394         {
395             import std.algorithm.mutation : SwapStrategy, remove;
396             args = args.remove!(SwapStrategy.stable)(i);
397         }
398     }
399 
400     version(Posix)
401     {
402         import std.process : execvp;
403 
404         immutable retval = execvp(args[0], args);
405 
406         // If we're here, the call failed
407         enum message = "execvp failed";
408         throw new ExecException(message, retval);
409     }
410     else version(Windows)
411     {
412         import lu.string : beginsWith;
413         import std.array : Appender;
414         import std.process : ProcessException, spawnProcess;
415 
416         Appender!(char[]) sink;
417         sink.reserve(128);
418 
419         string arg0 = args[0];  // mutable
420         args = args[1..$];  // pop it
421 
422         if (arg0.beginsWith('.') || arg0.beginsWith('/'))
423         {
424             // Seems to be a full path
425         }
426         else if ((arg0.length > 3) && (arg0[1] == ':'))
427         {
428             // May be C:\kameloso.exe and would as such be okay
429         }
430         else
431         {
432             // Powershell won't call binaries in the working directory without ./
433             arg0 = "./" ~ arg0;
434         }
435 
436         for (size_t i; i<args.length; ++i)
437         {
438             import std.format : formattedWrite;
439 
440             if (sink.data.length) sink.put(' ');
441 
442             if ((args.length >= i+1) &&
443                 args[i].among!(
444                     "-H",
445                     "-C",
446                     "--homeChannels",
447                     "--guestChannels",
448                     "--set"))
449             {
450                 // Octothorpes must be encased in single quotes
451                 sink.formattedWrite("%s '%s'", args[i], args[i+1]);
452                 ++i;
453             }
454             else
455             {
456                 sink.put(args[i]);
457             }
458         }
459 
460         const commandLine =
461         [
462             "cmd.exe",
463             "/c",
464             "start",
465             "/min",
466             "powershell",
467             "-c"
468         ] ~ arg0 ~ sink.data.idup;
469         return spawnProcess(commandLine);
470     }
471     else
472     {
473         static assert(0, "Unsupported platform, please file a bug.");
474     }
475 }
476 
477 
478 // ExecException
479 /++
480     Exception thrown when an [std.process.execvp|execvp] action failed.
481  +/
482 final class ExecException : Exception
483 {
484     /++
485         [std.process.execvp|execvp] return value.
486      +/
487     int retval;
488 
489     /// Constructor attaching a return value.
490     this(
491         const string msg,
492         const int retval,
493         const string file = __FILE__,
494         const size_t line = __LINE__,
495         Throwable nextInChain = null) pure nothrow @nogc @safe
496     {
497         this.retval = retval;
498         super(msg, file, line, nextInChain);
499     }
500 
501     /// Passthrough constructor.
502     this(
503         const string msg,
504         const string file = __FILE__,
505         const size_t line = __LINE__,
506         Throwable nextInChain = null) pure nothrow @nogc @safe
507     {
508         super(msg, file, line, nextInChain);
509     }
510 }