using System.Diagnostics;
namespace DodoSSH.Client.App.Platform;
///
/// Opens a local copy of a remote file for editing, without running it.
///
///
///
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
/// shell's: what opens a .conf is a registry association, a desktop entry or a Launch Services binding
/// depending on which desktop this is. The shell decides when; see
/// TransfersViewModel.EditRemoteAsync.
///
///
/// ◆ "Open" is not "edit", and for a file somebody else named the difference is code execution. The
/// file comes from a host, and whoever can write to that host chooses its name. Handing deploy.bat,
/// notes.hta or run.command to the desktop's default action runs it, on this machine, as this
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
/// type's own edit verb where it has one (Notepad, for .bat, .cmd, .js,
/// .vbs, .ps1 and .reg) and Notepad where it has not; on macOS it is open -t,
/// which is the default text editor whatever the type. Everything else still goes to its association, which
/// is the point of the feature — an nginx.conf should open in whatever this person edits configs with.
///
///
/// Linux is the exception: there is no portable "the text editor", so a .desktop entry — the one type
/// xdg-open may launch rather than open — is refused with a sentence instead. A shell script is opened
/// by every mainstream desktop's file associations in an editor, not run.
///
///
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
///
///
internal static class LocalEditor
{
///
/// Extensions Windows runs rather than opens, beyond whatever PATHEXT lists on this machine.
///
///
/// PATHEXT covers the console's notion of executable (.exe, .bat, .js,
/// .vbs…) and is read as well, because an interpreter's installer may add its own. These are the
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
/// items, and the script hosts that installers do not register there.
///
private static readonly string[] WindowsRunsThese =
[
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
];
/// Extensions Launch Services runs, or installs, rather than opens.
private static readonly string[] MacRunsThese =
[
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
".tool", ".webloc", ".workflow",
];
/// Opens for editing.
///
/// This desktop would run the file and has no editor to offer in its place.
///
internal static Task OpenAsync(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var opened = Process.Start(StartInfoFor(path));
return Task.CompletedTask;
}
/// What to start so that opens in an editor.
internal static ProcessStartInfo StartInfoFor(string path)
{
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
{
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
{
return edit;
}
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
}
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
{
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
}
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
{
throw new InvalidOperationException(
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
+ "Download it and open it in an editor instead.");
}
return new ProcessStartInfo(path) { UseShellExecute = true };
}
/// Whether Windows' default action for this file is to run it.
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
{
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
}
/// Whether the file's last extension is one of these, ignoring case.
///
/// Trailing dots and spaces are dropped first, because Windows drops them too: run.bat. is
/// run.bat by the time anything acts on it.
///
private static bool HasExtension(string path, IEnumerable extensions)
{
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
return extension.Length > 0
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
}