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); } }