diff --git a/ADB.cs b/ADB.cs index 75be4092..07885329 100644 --- a/ADB.cs +++ b/ADB.cs @@ -72,7 +72,7 @@ private static DeviceData GetCurrentDevice() return _currentDevice; } - public static ProcessOutput RunAdbCommandToString(string command, bool suppressLogging = false) + public static ProcessOutput RunAdbCommandToString(string command, bool suppressLogging = false, int timeoutMs = 0) { if (!File.Exists(adbFilePath)) { @@ -101,7 +101,30 @@ public static ProcessOutput RunAdbCommandToString(string command, bool suppressL } bool isConnectCommand = command.Contains("connect"); - int timeoutMs = isConnectCommand ? 5000 : -1; // 5 second timeout for connect commands + // Large transfers and on-device backup/restore prompts can legitimately run + // for a long time, so they must never be force-killed. Every other shell + // command gets a safety timeout so a wedged device command (e.g. Android's + // `df` blocking on an unresponsive mount) can't freeze the app indefinitely. + bool isLongOp = command.Contains("pull") || command.Contains("push") + || command.Contains("backup") || command.Contains("restore") + || command.Contains("install"); + int effectiveTimeout; + if (timeoutMs != 0) + { + effectiveTimeout = timeoutMs; // caller override (-1 = wait forever) + } + else if (isConnectCommand) + { + effectiveTimeout = 5000; + } + else if (isLongOp) + { + effectiveTimeout = -1; + } + else + { + effectiveTimeout = 45000; + } using (Process adb = new Process()) { @@ -119,21 +142,30 @@ public static ProcessOutput RunAdbCommandToString(string command, bool suppressL try { - if (isConnectCommand) + if (effectiveTimeout > 0) { - // For connect commands, we use async reading with timeout to avoid blocking on TCP timeout + // Async reads + a bounded wait so a wedged device command can't block + // forever (also avoids the classic sequential stdout/stderr deadlock). var outputTask = adb.StandardOutput.ReadToEndAsync(); var errorTask = adb.StandardError.ReadToEndAsync(); - bool exited = adb.WaitForExit(timeoutMs); + bool exited = adb.WaitForExit(effectiveTimeout); if (!exited) { try { adb.Kill(); } catch { } adb.WaitForExit(1000); - output = "Connection timed out"; - error = "cannot connect: Connection timed out"; - Logger.Log($"ADB connect command timed out after {timeoutMs}ms", LogLevel.WARNING); + if (isConnectCommand) + { + output = "Connection timed out"; + error = "cannot connect: Connection timed out"; + } + else + { + output = ""; + error = $"adb command timed out after {effectiveTimeout}ms"; + } + Logger.Log($"ADB command timed out after {effectiveTimeout}ms: {command.Trim()}", LogLevel.WARNING); } else { @@ -144,7 +176,7 @@ public static ProcessOutput RunAdbCommandToString(string command, bool suppressL } else { - // For non-connect commands, read output normally + // Long-running op (transfer/backup/restore/install): read to completion. output = adb.StandardOutput.ReadToEnd(); error = adb.StandardError.ReadToEnd(); } @@ -172,6 +204,78 @@ public static ProcessOutput RunAdbCommandToString(string command, bool suppressL } } + // Returns true if the `adb devices` output lists at least one usable device. + private static bool HasOnlineDevice(string devicesOutput) + { + if (string.IsNullOrEmpty(devicesOutput)) + { + return false; + } + + string[] lines = devicesOutput.Split('\n'); + for (int i = 1; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0) + { + continue; + } + if (line.IndexOf("List of devices", StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + if (line.IndexOf("unauthorized", StringComparison.OrdinalIgnoreCase) >= 0 + || line.IndexOf("offline", StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + if (line.IndexOf("device", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + return false; + } + + // `adb devices` briefly reports nothing while the daemon is (re)starting - which + // happens whenever a different-version adb client (e.g. one in PATH) forces the + // server to kill and restart. Reading that transient empty result as a disconnect + // is what makes the UI flash "No Device Connected". This retries across the restart + // window so a server bounce never registers as a disconnect. Call off the UI thread. + public static ProcessOutput GetDevicesResilient(int maxAttempts = 4, int delayMs = 600) + { + ProcessOutput result = new ProcessOutput("", ""); + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + result = RunAdbCommandToString("devices", suppressLogging: true); + if (HasOnlineDevice(result.Output)) + { + return result; + } + + string combined = (result.Output ?? "") + (result.Error ?? ""); + bool daemonRestarting = + combined.IndexOf("daemon not running", StringComparison.OrdinalIgnoreCase) >= 0 + || combined.IndexOf("daemon started", StringComparison.OrdinalIgnoreCase) >= 0 + || combined.IndexOf("killing", StringComparison.OrdinalIgnoreCase) >= 0 + || combined.IndexOf("doesn't match this client", StringComparison.OrdinalIgnoreCase) >= 0 + || combined.IndexOf("cannot connect to daemon", StringComparison.OrdinalIgnoreCase) >= 0 + || combined.IndexOf("starting now", StringComparison.OrdinalIgnoreCase) >= 0; + + // If the daemon is up and simply reports no device, don't keep waiting. + if (!daemonRestarting && attempt >= 1) + { + break; + } + + if (attempt < maxAttempts - 1) + { + System.Threading.Thread.Sleep(delayMs); + } + } + return result; + } + // Executes a shell command on the device. private static void ExecuteShellCommand(AdbClient client, DeviceData device, string command) { @@ -292,6 +396,25 @@ await Task.Run(() => { Logger.Log($"SideloadWithProgressAsync error: {ex.Message}", LogLevel.ERROR); + // AdvancedSharpAdbClient throws "An unknown error occurred." whenever it cannot + // parse pm install's result - which frequently happens even though the install + // actually succeeded (a connection blip right after install, unusual pm output). + // Verify against the device before treating it as a failure. + try + { + if (VerifyInstalled(path, packagename)) + { + Logger.Log($"{gameName}: install reported '{ex.Message}', but the package is present on the device with a matching version - treating as success.", LogLevel.WARNING); + progressCallback?.Invoke(100, null); + statusCallback?.Invoke(""); + return new ProcessOutput($"{gameName}: Success (verified on device)\n"); + } + } + catch (Exception vex) + { + Logger.Log($"Install verification failed: {vex.Message}", LogLevel.WARNING); + } + // Signature mismatches and version downgrades can be fixed by reinstalling bool isReinstallEligible = ex.Message.Contains("signatures do not match") || ex.Message.Contains("INSTALL_FAILED_VERSION_DOWNGRADE") || @@ -382,6 +505,68 @@ await Task.Run(() => } } + // Confirms an APK actually installed by checking the device, so a benign "unknown error" + // from the install-result parser isn't treated as a real failure. Reads the APK's package + // name + version code with aapt and requires the device to report the same version. + private static bool VerifyInstalled(string apkPath, string knownPackage) + { + try + { + string apkPkg = knownPackage; + string apkVc = null; + + string aapt = Path.Combine(adbFolderPath, "aapt.exe"); + if (File.Exists(aapt) && File.Exists(apkPath)) + { + var psi = new ProcessStartInfo + { + FileName = aapt, + Arguments = $"dump badging \"{apkPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using (var p = Process.Start(psi)) + { + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(10000); + var pm = System.Text.RegularExpressions.Regex.Match(outp, @"package: name='([^']+)'"); + if (string.IsNullOrEmpty(apkPkg) && pm.Success) apkPkg = pm.Groups[1].Value; + var vm = System.Text.RegularExpressions.Regex.Match(outp, @"versionCode='(\d+)'"); + if (vm.Success) apkVc = vm.Groups[1].Value; + } + } + + if (string.IsNullOrEmpty(apkPkg)) return false; + + // Is the package present on the device at all? + string listed = RunAdbCommandToString($"shell pm list packages {apkPkg}", true).Output ?? ""; + if (listed.IndexOf("package:" + apkPkg, StringComparison.OrdinalIgnoreCase) < 0) + { + return false; + } + + // If we know the APK's version code, require the installed one to match so a + // failed *upgrade* (old version still present) is not mistaken for success. + if (!string.IsNullOrEmpty(apkVc)) + { + string devOut = RunAdbCommandToString($"shell \"dumpsys package {apkPkg} | grep versionCode\"", true).Output ?? ""; + var dm = System.Text.RegularExpressions.Regex.Match(devOut, @"versionCode=(\d+)"); + if (dm.Success) + { + return dm.Groups[1].Value == apkVc; + } + } + + return true; // present, versions unknown -> accept + } + catch + { + return false; + } + } + // Copies OBB folder with real-time progress reporting using AdvancedSharpAdbClient public static async Task CopyOBBWithProgressAsync( string localPath, @@ -404,6 +589,15 @@ public static async Task CopyOBBWithProgressAsync( return new ProcessOutput("", "No device connected"); } + // Fast path: rooted headsets running an SSH server take OBBs over SFTP, + // which is far faster than adb push. Returns null when unavailable. + ProcessOutput sftpResult = await SFTP.TryCopyObbWithProgressAsync( + localPath, progressCallback, statusCallback, gameName); + if (sftpResult != null) + { + return sftpResult; + } + var client = GetAdbClient(); string remotePath = $"/sdcard/Android/obb/{folderName}"; @@ -419,7 +613,15 @@ public static async Task CopyOBBWithProgressAsync( long totalBytes = files.Sum(f => new FileInfo(f).Length); long transferredBytes = 0; + // Surface each file in the transfer window (adb push path). + var pushItems = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + foreach (var pf in files) + { + pushItems[pf] = TransferQueue.Add(Path.GetFileName(pf), "Push", new FileInfo(pf).Length); + } + // Throttle UI updates to prevent lag + DateTime pushStartTime = DateTime.UtcNow; DateTime lastProgressUpdate = DateTime.MinValue; float lastReportedPercent = -1; const int ThrottleMs = 100; // Update UI every 100ms @@ -447,10 +649,18 @@ public static async Task CopyOBBWithProgressAsync( long fileSize = fileInfo.Length; long capturedTransferredBytes = transferredBytes; + TransferItem pushItem = pushItems[file]; + TransferQueue.SetState(pushItem, TransferState.Active); + DateTime fileStart = DateTime.UtcNow; + Action progressHandler = (args) => { long totalProgressBytes = capturedTransferredBytes + args.ReceivedBytesSize; + double feSecs = (DateTime.UtcNow - fileStart).TotalSeconds; + double fileMBps = feSecs > 0.1 ? (args.ReceivedBytesSize / 1048576.0) / feSecs : 0; + TransferQueue.Report(pushItem, args.ReceivedBytesSize, fileMBps); + float overallPercent = totalBytes > 0 ? (float)(totalProgressBytes * 100.0 / totalBytes) : 0f; @@ -474,7 +684,9 @@ public static async Task CopyOBBWithProgressAsync( lastProgressUpdate = now2; lastReportedPercent = overallPercent; progressCallback?.Invoke(overallPercent, displayEta); - statusCallback?.Invoke(fileName); + double pushElapsed = (now2 - pushStartTime).TotalSeconds; + double pushMBps = pushElapsed > 0.1 ? (totalProgressBytes / 1048576.0) / pushElapsed : 0; + statusCallback?.Invoke($"{fileName} · {pushMBps:0.0} MB/s"); } }; @@ -492,6 +704,8 @@ await Task.Run(() => }); } + TransferQueue.Report(pushItem, fileSize, 0); + TransferQueue.SetState(pushItem, TransferState.Done); transferredBytes += fileSize; } } diff --git a/AndroidSideloader.csproj b/AndroidSideloader.csproj index b0fb145f..74c01f2d 100644 --- a/AndroidSideloader.csproj +++ b/AndroidSideloader.csproj @@ -139,6 +139,9 @@ packages\AdvancedSharpAdbClient.3.5.15\lib\net45\AdvancedSharpAdbClient.dll + + packages\BouncyCastle.Cryptography.2.4.0\lib\net461\BouncyCastle.Cryptography.dll + packages\Costura.Fody.5.7.0\lib\netstandard1.0\Costura.dll @@ -151,9 +154,15 @@ packages\Microsoft.Web.WebView2.1.0.2478.35\lib\net45\Microsoft.Web.WebView2.Wpf.dll + + packages\Microsoft.Bcl.AsyncInterfaces.1.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll + packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + + packages\SSH.NET.2024.2.0\lib\net462\Renci.SshNet.dll + .\SergeUtils.dll @@ -166,9 +175,30 @@ + + packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + + + packages\System.Formats.Asn1.8.0.1\lib\net462\System.Formats.Asn1.dll + + + packages\System.Memory.4.5.5\lib\net461\System.Memory.dll + + + packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + + + packages\System.Runtime.CompilerServices.Unsafe.4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + packages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll + + packages\System.Threading.Tasks.Extensions.4.5.2\lib\netstandard2.0\System.Threading.Tasks.Extensions.dll + + + packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + @@ -184,6 +214,10 @@ + + + Form + Form @@ -278,6 +312,10 @@ + + + Component + diff --git a/FlexibleMessageBox.cs b/FlexibleMessageBox.cs index bbf8c4a7..81414f65 100644 --- a/FlexibleMessageBox.cs +++ b/FlexibleMessageBox.cs @@ -843,10 +843,23 @@ public static DialogResult Show(IWin32Window owner, string text, string caption, { FlexibleMessageBoxForm flexibleMessageBoxForm = new FlexibleMessageBoxForm { - ShowInTaskbar = false, + // Show in the taskbar and keep the prompt above other windows so it can + // never hide behind another app (e.g. a browser/IDE) and leave the disabled + // main window looking "frozen" while it silently waits for an answer. + ShowInTaskbar = true, + TopMost = true, CaptionText = caption, MessageText = text }; + flexibleMessageBoxForm.Shown += (s, e) => + { + try + { + flexibleMessageBoxForm.BringToFront(); + flexibleMessageBoxForm.Activate(); + } + catch { } + }; flexibleMessageBoxForm.FlexibleMessageBoxFormBindingSource.DataSource = flexibleMessageBoxForm; flexibleMessageBoxForm.titleLabel.Text = caption; diff --git a/MainForm.cs b/MainForm.cs index b5279ca3..6516b9de 100755 --- a/MainForm.cs +++ b/MainForm.cs @@ -52,6 +52,7 @@ public partial class MainForm : Form #endif private readonly ListViewColumnSorter lvwColumnSorter; private static readonly SettingsManager settings = SettingsManager.Instance; + private bool _sftpCredsPrompted = false; private double _totalQueueSizeMB = 0; private double _effectiveQueueSizeMB = 0; private Dictionary _queueEffectiveSizes = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -90,6 +91,7 @@ public partial class MainForm : Form private const int TILE_SPACING = 10; private string freeSpaceText = ""; private string freeSpaceTextDetailed = ""; + private TransferStrip _transferStrip; private int _questStorageProgress = 0; private Color _mirrorPillColor = Color.FromArgb(32, 36, 44); private DateTime _mirrorMenuClosedAt = DateTime.MinValue; @@ -440,13 +442,15 @@ private async void Form1_Load(object sender, EventArgs e) { _ = Logger.Log("Starting AndroidSideloader Application"); - // Hard kill any lingering adb.exe instances to avoid port/handle conflicts - KillAdbProcesses(); - - // ADB initialization in background + // Reuse an already-running adb server instead of killing it. A hard kill would + // tear down any active wireless (adb connect) session and force a reconnect that + // often fails silently. start-server is a no-op when a compatible server is already + // up, so the existing server - and any connected device, wired or wireless - is + // preserved. (Both the bundled adb and any adb in PATH are the same version now, so + // there is no version-mismatch bounce to guard against.) _adbInitTask = Task.Run(() => { - _ = Logger.Log("Attempting to Initialize ADB Server"); + _ = Logger.Log("Initializing ADB server (reusing existing if already running)"); if (File.Exists(Path.Combine(Environment.CurrentDirectory, "platform-tools", "adb.exe"))) { _ = ADB.RunAdbCommandToString("start-server"); @@ -464,6 +468,11 @@ private async void Form1_Load(object sender, EventArgs e) speedLabel.Text = String.Empty; diskLabel.Text = String.Empty; + // Integrated transfer strip: overlays the bottom of the games list while files + // are moving (SFTP / push), themed to match the list, and hides itself when idle. + _transferStrip = new TransferStrip(gamesListView.BackColor, gamesListView.ForeColor); + _transferStrip.AttachTo(this, gamesListView); + settings.MainDir = Environment.CurrentDirectory; settings.Save(); @@ -1093,7 +1102,9 @@ public async Task CheckForDevice() { Devices.Clear(); ADB.DeviceID = GetDeviceID(); - string output = await Task.Run(() => ADB.RunAdbCommandToString("devices").Output); // Run off UI thread + // Resilient: rides through an adb daemon restart so a version-mismatch server + // bounce doesn't briefly clear the device and flash "No Device Connected". + string output = await Task.Run(() => ADB.GetDevicesResilient().Output); // Run off UI thread string[] line = output.Split('\n'); int i = 0; @@ -1243,9 +1254,42 @@ public void changeTitlebarToDevice() } else if (Devices.Count > 0 && Devices[0].Length > 1) // Check if Devices list is not empty and the first device has a valid length { - this.Invoke(() => { Text = "Rookie Sideloader " + Updater.LocalVersion + " | Device Connected: " + Devices[0].Replace("device", String.Empty).Trim(); }); + string deviceTitle = "Rookie Sideloader " + Updater.LocalVersion + " | Device Connected: " + Devices[0].Replace("device", String.Empty).Trim(); + this.Invoke(() => { Text = deviceTitle; }); DeviceConnected = true; nodeviceonstart = false; // Device connected, clear the flag + + // Detect root + SFTP availability in the background and reflect it in the titlebar + _ = Task.Run(async () => + { + await SFTP.DetectAsync(force: true); + try + { + this.Invoke(() => { Text = deviceTitle + SFTP.TitleSuffix; }); + + // A server answered but our credentials didn't work: ask the user + // (once per session) for a password or key file, then retry + if (!SFTP.IsSftpAvailable && SFTP.AuthFailedButServerPresent + && settings.EnableSftpTransfers && !_sftpCredsPrompted) + { + _sftpCredsPrompted = true; + bool retry = false; + this.Invoke(() => + { + using (SftpCredentialsForm dialog = new SftpCredentialsForm()) + { + retry = dialog.ShowDialog(this) == DialogResult.OK; + } + }); + if (retry) + { + await SFTP.DetectAsync(force: true); + this.Invoke(() => { Text = deviceTitle + SFTP.TitleSuffix; }); + } + } + } + catch { } + }); } else { @@ -7846,10 +7890,22 @@ public async void UpdateQuestInfoPanel() { try { - ADB.DeviceID = GetDeviceID(); + // Fetch device info OFF the UI thread. These adb shell calls can block for + // tens of seconds if the adb server is mid-restart (e.g. a version-mismatched + // adb elsewhere bounces the daemon); running them here would freeze the whole UI. + var info = await Task.Run(() => + { + ADB.DeviceID = GetDeviceID(); + return new + { + Model = ADB.RunAdbCommandToString("shell getprop ro.product.model").Output.Trim(), + Firmware = ADB.RunAdbCommandToString("shell getprop ro.build.branch").Output.Trim(), + Storage = ADB.RunAdbCommandToString("shell df /data").Output + }; + }); // Get device model - string deviceModel = ADB.RunAdbCommandToString("shell getprop ro.product.model").Output.Trim(); + string deviceModel = info.Model; if (string.IsNullOrEmpty(deviceModel)) { deviceModel = "No Device Found"; @@ -7857,7 +7913,7 @@ public async void UpdateQuestInfoPanel() bShowStatus = false; } - string firmware = ADB.RunAdbCommandToString("shell getprop ro.build.branch").Output.Trim(); // releases-oculus-14.0-v78 + string firmware = info.Firmware; // releases-oculus-14.0-v78 if (string.IsNullOrEmpty(firmware)) { firmware = string.Empty; @@ -7869,7 +7925,7 @@ public async void UpdateQuestInfoPanel() } // Get storage info - string storageOutput = ADB.RunAdbCommandToString("shell df /data").Output; + string storageOutput = info.Storage; string[] lines = storageOutput.Split('\n'); long totalSpace = 0; @@ -10214,7 +10270,7 @@ private async void timer_DeviceCheck(object sender, EventArgs e) // Run a quick device check in background try { - string output = await Task.Run(() => ADB.RunAdbCommandToString("devices", suppressLogging: true).Output); + string output = await Task.Run(() => ADB.GetDevicesResilient().Output); string[] lines = output.Split('\n'); bool hasDeviceNow = false; diff --git a/SFTP.cs b/SFTP.cs new file mode 100644 index 00000000..e2ccf5d3 --- /dev/null +++ b/SFTP.cs @@ -0,0 +1,720 @@ +using AndroidSideloader.Utilities; +using Renci.SshNet; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace AndroidSideloader +{ + // SFTP fast path for large transfers (OBBs) to rooted headsets running an SSH server. + // adb push tops out well below what the Quest's WiFi/USB link can do; a direct SFTP + // session to an on-device sshd is typically several times faster for multi-GB OBBs. + // Everything here degrades gracefully: if no SSH server is reachable, transfers fall + // back to the regular adb push path automatically. + internal static class SFTP + { + private static readonly SettingsManager settings = SettingsManager.Instance; + + private static readonly object _detectLock = new object(); + private static Task _detectionTask; + private static string _detectedDeviceId; + + // Results of the last detection run + public static bool RootChecked { get; private set; } + public static bool IsRootAvailable { get; private set; } + public static bool IsSftpAvailable { get; private set; } + // An SSH server answered but no configured credential worked - the UI uses + // this to ask the user for a key file or password + public static bool AuthFailedButServerPresent { get; private set; } + public static string Host { get; private set; } + public static int Port { get; private set; } + public static string User { get; private set; } + + private static readonly int[] DefaultPorts = { 22, 8022, 2222 }; + // /storage/emulated/0 is the canonical path; /sdcard is a symlink that some + // sshd mount namespaces cannot resolve, so it is only the fallback. + private static readonly string[] ObbRootCandidates = + { + "/storage/emulated/0/Android/obb", + "/sdcard/Android/obb" + }; + private static string _obbRoot = ObbRootCandidates[0]; + // Upper bound only: SSH.NET clamps each write to the server's negotiated max + private const uint TransferBufferSize = 1048576; + + // Short status for the titlebar, e.g. " | Root ✓ | SFTP ✓ (root@192.168.1.35:22)" + public static string TitleSuffix + { + get + { + string suffix = ""; + if (RootChecked) + { + suffix += IsRootAvailable ? " | Root ✓" : " | Root ✗"; + suffix += IsSftpAvailable ? $" | SFTP ✓ ({User}@{Host}:{Port})" : " | SFTP ✗"; + } + return suffix; + } + } + + public static void InvalidateCache() + { + lock (_detectLock) + { + _detectionTask = null; + _detectedDeviceId = null; + RootChecked = false; + IsRootAvailable = false; + IsSftpAvailable = false; + } + } + + // Runs root + SFTP detection once per connected device; safe to call repeatedly. + public static Task DetectAsync(bool force = false) + { + lock (_detectLock) + { + if (force || _detectionTask == null || _detectedDeviceId != ADB.DeviceID) + { + _detectedDeviceId = ADB.DeviceID; + _detectionTask = Task.Run(() => Detect()); + } + return _detectionTask; + } + } + + private static bool Detect() + { + try + { + IsRootAvailable = DetectRoot(); + RootChecked = true; + Logger.Log($"SFTP: root access {(IsRootAvailable ? "detected (su grants uid=0)" : "not detected")}"); + + IsSftpAvailable = DetectSftp(); + Logger.Log(IsSftpAvailable + ? $"SFTP: server available at {User}@{Host}:{Port} - OBB transfers will use SFTP" + : "SFTP: no usable server found - OBB transfers will use adb push"); + return IsSftpAvailable; + } + catch (Exception ex) + { + Logger.Log($"SFTP: detection failed: {ex.Message}", LogLevel.ERROR); + return false; + } + } + + // 'timeout' guards against Magisk/KernelSU showing a grant prompt and blocking forever + private static bool DetectRoot() + { + ProcessOutput result = ADB.RunAdbCommandToString("shell \"timeout 3 su -c id\"", true); + if (result.Output.Contains("uid=0")) + { + return true; + } + + ProcessOutput which = ADB.RunAdbCommandToString("shell which su", true); + if (!string.IsNullOrWhiteSpace(which.Output)) + { + Logger.Log("SFTP: su binary present but a root shell was not granted", LogLevel.WARNING); + } + return false; + } + + // Resolves the headset's IP: explicit setting > wireless ADB serial > ip route > wlan0 + public static string GetDeviceIp() + { + if (!string.IsNullOrEmpty(settings.SftpHost)) + { + return settings.SftpHost; + } + + string serial = ADB.DeviceID; + if (!string.IsNullOrEmpty(serial)) + { + Match m = Regex.Match(serial, @"^(\d{1,3}(?:\.\d{1,3}){3}):\d+$"); + if (m.Success) + { + return m.Groups[1].Value; + } + } + + ProcessOutput route = ADB.RunAdbCommandToString("shell ip route", true); + Match src = Regex.Match(route.Output, @"\bsrc\s+(\d{1,3}(?:\.\d{1,3}){3})"); + if (src.Success) + { + return src.Groups[1].Value; + } + + ProcessOutput wlan = ADB.RunAdbCommandToString("shell ip -f inet addr show wlan0", true); + Match inet = Regex.Match(wlan.Output, @"inet\s+(\d{1,3}(?:\.\d{1,3}){3})"); + return inet.Success ? inet.Groups[1].Value : null; + } + + private static bool DetectSftp() + { + AuthFailedButServerPresent = false; + + string host = GetDeviceIp(); + if (string.IsNullOrEmpty(host)) + { + Logger.Log("SFTP: could not determine headset IP address", LogLevel.WARNING); + return false; + } + + bool sawServer = false; + int[] ports = settings.SftpPort > 0 ? new[] { settings.SftpPort } : DefaultPorts; + foreach (int port in ports) + { + if (!HasSshBanner(host, port)) + { + continue; + } + sawServer = true; + + if (TryAuthenticate(host, port)) + { + Host = host; + Port = port; + return true; + } + } + + AuthFailedButServerPresent = sawServer; + return false; + } + + // SSH servers send their version banner immediately after accept, so a quick + // connect+read cheaply filters out closed ports and non-SSH services. + private static bool HasSshBanner(string host, int port) + { + try + { + using (TcpClient tcp = new TcpClient()) + { + IAsyncResult ar = tcp.BeginConnect(host, port, null, null); + if (!ar.AsyncWaitHandle.WaitOne(1000) ) + { + return false; + } + tcp.EndConnect(ar); + + NetworkStream stream = tcp.GetStream(); + stream.ReadTimeout = 2000; + byte[] buffer = new byte[255]; + int read = stream.Read(buffer, 0, buffer.Length); + bool isSsh = read >= 4 && System.Text.Encoding.ASCII.GetString(buffer, 0, read).StartsWith("SSH-"); + if (isSsh) + { + Logger.Log($"SFTP: SSH server found on {host}:{port}"); + } + return isSsh; + } + } + catch + { + return false; + } + } + + private static IEnumerable CandidateKeyPaths() + { + if (!string.IsNullOrEmpty(settings.SftpPrivateKeyPath)) + { + yield return settings.SftpPrivateKeyPath; + } + + string sshDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); + yield return Path.Combine(sshDir, "quest_root"); + yield return Path.Combine(sshDir, "id_ed25519"); + yield return Path.Combine(sshDir, "id_rsa"); + } + + private static ConnectionInfo BuildConnectionInfo(string host, int port) + { + string user = string.IsNullOrEmpty(settings.SftpUsername) ? "root" : settings.SftpUsername; + + List methods = new List(); + List keys = new List(); + foreach (string keyPath in CandidateKeyPaths().Distinct().Where(File.Exists)) + { + try + { + keys.Add(new PrivateKeyFile(keyPath)); + } + catch (Exception ex) + { + Logger.Log($"SFTP: could not load key '{keyPath}': {ex.Message}", LogLevel.WARNING); + } + } + if (keys.Count > 0) + { + methods.Add(new PrivateKeyAuthenticationMethod(user, keys.ToArray())); + } + if (!string.IsNullOrEmpty(settings.SftpPassword)) + { + methods.Add(new PasswordAuthenticationMethod(user, settings.SftpPassword)); + + // Many sshd builds are configured for keyboard-interactive instead of + // plain password auth; answer every prompt with the stored password + KeyboardInteractiveAuthenticationMethod interactive = new KeyboardInteractiveAuthenticationMethod(user); + interactive.AuthenticationPrompt += (sender, e) => + { + foreach (Renci.SshNet.Common.AuthenticationPrompt prompt in e.Prompts) + { + prompt.Response = settings.SftpPassword; + } + }; + methods.Add(interactive); + } + // Some rootful sshd builds accept root with no credentials at all + methods.Add(new NoneAuthenticationMethod(user)); + + ConnectionInfo info = new ConnectionInfo(host, port, user, methods.ToArray()) + { + Timeout = TimeSpan.FromSeconds(6) + }; + User = user; + return info; + } + + private static bool TryAuthenticate(string host, int port) + { + try + { + using (SftpClient sftp = new SftpClient(BuildConnectionInfo(host, port))) + { + sftp.Connect(); + string obbRoot = ObbRootCandidates.FirstOrDefault(sftp.Exists); + sftp.Disconnect(); + + if (obbRoot == null) + { + // An sshd whose mount namespace lacks the shared storage would + // silently strand files where no game can read them, so treat as unusable + Logger.Log($"SFTP: authenticated on {host}:{port} but {ObbRootCandidates[0]} is not visible - ignoring this server", LogLevel.WARNING); + return false; + } + _obbRoot = obbRoot; + return true; + } + } + catch (Exception ex) + { + Logger.Log($"SFTP: authentication to {host}:{port} failed: {ex.Message}", LogLevel.WARNING); + return false; + } + } + + // Attempts the OBB copy over SFTP. Returns null when SFTP is disabled, undetected + // or fails, which tells the caller to fall back to the adb push path. + public static async Task TryCopyObbWithProgressAsync( + string localPath, + Action progressCallback = null, + Action statusCallback = null, + string gameName = "") + { + if (!settings.EnableSftpTransfers) + { + return null; + } + + bool available = await DetectAsync(); + if (!available) + { + return null; + } + + try + { + return await Task.Run(() => CopyObbCore(localPath, progressCallback, statusCallback, gameName)); + } + catch (Exception ex) + { + Logger.Log($"SFTP: OBB transfer failed, falling back to adb push: {ex.Message}", LogLevel.ERROR); + InvalidateCache(); + return null; + } + } + + private static ProcessOutput CopyObbCore( + string localPath, + Action progressCallback, + Action statusCallback, + string gameName) + { + string folderName = Path.GetFileName(localPath); + string remotePath = $"{_obbRoot}/{folderName}"; + string quotedRemotePath = "'" + remotePath.Replace("'", "'\\''") + "'"; + + statusCallback?.Invoke($"Preparing: {folderName} (SFTP)"); + progressCallback?.Invoke(0, null); + + string[] files = Directory.GetFiles(localPath, "*", SearchOption.AllDirectories); + long totalBytes = files.Sum(f => new FileInfo(f).Length); + long transferredBytes = 0; // shared across worker threads (Interlocked) + + DateTime startTime = DateTime.UtcNow; + DateTime lastProgressUpdate = DateTime.MinValue; + float lastReportedPercent = -1; + const int ThrottleMs = 100; + EtaEstimator eta = new EtaEstimator(alpha: 0.10, reanchorThreshold: 0.20); + object progressLock = new object(); + + // Recreate the OBB folder fresh, matching the adb push behaviour + using (SshClient ssh = new SshClient(BuildConnectionInfo(Host, Port))) + { + ssh.Connect(); + using (SshCommand cmd = ssh.CreateCommand($"rm -rf {quotedRemotePath} && mkdir -p {quotedRemotePath}")) + { + cmd.CommandTimeout = TimeSpan.FromSeconds(60); + cmd.Execute(); + if (cmd.ExitStatus != 0) + { + throw new Exception($"remote mkdir failed: {cmd.Error}"); + } + } + ssh.Disconnect(); + } + + // Map every local file to its remote path, and pre-create all remote + // subdirectories up front on a single connection. Doing this before the + // parallel phase avoids races on directory creation between workers. + var remoteFiles = new Dictionary(files.Length, StringComparer.Ordinal); + var remoteDirs = new HashSet(StringComparer.Ordinal); + foreach (string file in files) + { + string relativePath = file.Substring(localPath.Length) + .TrimStart('\\', '/') + .Replace('\\', '/'); + string remoteFilePath = $"{remotePath}/{relativePath}"; + remoteFiles[file] = remoteFilePath; + int slash = remoteFilePath.LastIndexOf('/'); + if (slash > 0) + { + remoteDirs.Add(remoteFilePath.Substring(0, slash)); + } + } + if (remoteDirs.Any(d => d != remotePath)) + { + using (SftpClient dirClient = new SftpClient(BuildConnectionInfo(Host, Port))) + { + dirClient.Connect(); + HashSet created = new HashSet { remotePath }; + foreach (string dir in remoteDirs) + { + EnsureRemoteDirectory(dirClient, dir, created); + } + dirClient.Disconnect(); + } + } + + // How many files to push at once. Each worker gets its own SFTP connection + // (a single SSH.NET client is not safe for concurrent uploads) and pulls the + // next file from a shared queue, FileZilla-style, so no more than `degree` + // files transfer at any moment regardless of how many OBB files there are. + int degree = settings.SingleThreadMode ? 1 : Math.Max(1, settings.SftpThreads); + degree = Math.Min(degree, Math.Max(1, files.Length)); + + statusCallback?.Invoke($"Copying: {folderName} (SFTP ×{degree})"); + + // Register every file in the transfer window (largest first) and build the work queue. + string[] ordered = files.OrderByDescending(f => new FileInfo(f).Length).ToArray(); + Dictionary transferItems = new Dictionary(StringComparer.Ordinal); + foreach (string file in ordered) + { + transferItems[file] = TransferQueue.Add(Path.GetFileName(file), "SFTP", new FileInfo(file).Length); + } + System.Collections.Concurrent.ConcurrentQueue workQueue = + new System.Collections.Concurrent.ConcurrentQueue(ordered); + + // Reports overall progress + throughput; called from every worker thread. + Action report = (fileName) => + { + DateTime now = DateTime.UtcNow; + long done = Interlocked.Read(ref transferredBytes); + lock (progressLock) + { + float overallPercent = totalBytes > 0 + ? (float)(done * 100.0 / totalBytes) + : 0f; + overallPercent = Math.Max(0, Math.Min(100, overallPercent)); + + if (totalBytes > 0 && done > 0 && overallPercent < 100) + { + eta.Update(totalUnits: totalBytes, doneUnits: done); + } + TimeSpan? displayEta = eta.GetDisplayEta(); + + bool shouldUpdate = (now - lastProgressUpdate).TotalMilliseconds >= ThrottleMs + || Math.Abs(overallPercent - lastReportedPercent) >= 0.1f; + if (shouldUpdate) + { + lastProgressUpdate = now; + lastReportedPercent = overallPercent; + double elapsed = (now - startTime).TotalSeconds; + double speedMBps = elapsed > 0.1 ? (done / 1048576.0) / elapsed : 0; + progressCallback?.Invoke(overallPercent, displayEta); + statusCallback?.Invoke($"{fileName} · {speedMBps:0.0} MB/s · SFTP"); + } + } + }; + + int failedCount = 0; + Exception lastError = null; + List workers = new List(); + for (int w = 0; w < degree; w++) + { + workers.Add(Task.Run(() => + { + SftpClient sftp = null; + try + { + while (workQueue.TryDequeue(out string file)) + { + string remoteFilePath = remoteFiles[file]; + string fileName = Path.GetFileName(file); + TransferItem item = transferItems[file]; + long fileTotal = new FileInfo(file).Length; + + bool ok = false; + Exception fileError = null; + for (int attempt = 0; attempt < 3 && !ok; attempt++) + { + long fileCounted = 0; + long lastUploaded = 0; + DateTime fileStart = DateTime.UtcNow; + try + { + if (sftp == null || !sftp.IsConnected) + { + try { sftp?.Dispose(); } catch { } + sftp = new SftpClient(BuildConnectionInfo(Host, Port)); + // A wedged channel makes each SFTP write request time out and + // throw instead of blocking UploadFile forever (the softlock). + sftp.OperationTimeout = TimeSpan.FromSeconds(60); + sftp.Connect(); + sftp.BufferSize = TransferBufferSize; + } + + TransferQueue.SetState(item, TransferState.Active); + TransferQueue.Report(item, 0, 0); + + using (FileStream stream = File.OpenRead(file)) + { + sftp.UploadFile(stream, remoteFilePath, true, uploaded => + { + long u = (long)uploaded; + long delta = u - lastUploaded; + lastUploaded = u; + if (delta != 0) + { + Interlocked.Add(ref transferredBytes, delta); + fileCounted += delta; + } + double fe = (DateTime.UtcNow - fileStart).TotalSeconds; + double fileMBps = fe > 0.1 ? (u / 1048576.0) / fe : 0; + TransferQueue.Report(item, u, fileMBps); + report(fileName); + }); + } + + // Verify the file actually landed at the target path with the + // exact size. A "completed" upload that isn't really there (or is + // truncated) is a failure and must be retried, not reported as done. + var attrs = sftp.GetAttributes(remoteFilePath); + if (attrs == null || attrs.Size != fileTotal) + { + throw new Exception($"verification failed for {fileName}: remote size {(attrs != null ? attrs.Size : -1)} != {fileTotal}"); + } + + TransferQueue.Report(item, fileTotal, 0); + TransferQueue.SetState(item, TransferState.Done); + ok = true; + } + catch (Exception ex) + { + fileError = ex; + // undo this attempt's partial contribution to the overall total + if (fileCounted != 0) Interlocked.Add(ref transferredBytes, -fileCounted); + try { sftp?.Dispose(); } catch { } + sftp = null; // force a fresh connection next attempt + Logger.Log($"SFTP: '{fileName}' attempt {attempt + 1}/3 failed: {ex.Message}", LogLevel.WARNING); + if (attempt < 2) System.Threading.Thread.Sleep(500 * (attempt + 1)); + } + } + + if (!ok) + { + string capturedFile = file; + string capturedRemote = remoteFilePath; + TransferItem capturedItem = item; + capturedItem.Retry = () => RetryUpload(capturedItem, capturedFile, capturedRemote); + TransferQueue.SetState(item, TransferState.Failed); + Interlocked.Increment(ref failedCount); + lock (progressLock) { if (lastError == null) lastError = fileError; } + } + } + } + catch (Exception ex) + { + lock (progressLock) { if (lastError == null) lastError = ex; } + } + finally + { + try { sftp?.Dispose(); } catch { } + } + })); + } + Task.WaitAll(workers.ToArray()); + + // If every file failed, the SFTP path is unusable (server gone / auth lost) - throw so + // the caller falls back to adb push. If only some failed, leave them in the transfer + // strip (marked Failed, each with a Retry action) instead of re-pushing the whole set. + if (failedCount >= files.Length && lastError != null) + { + throw lastError; + } + + // Make the freshly-written OBB tree readable by the game. adb push goes through + // the adb daemon which lands app-readable perms; an sshd running as root can + // instead write root-owned/restrictive files. On FUSE-emulated storage these + // calls are effectively no-ops (perms/SELinux labels are synthesized), but where + // the sshd writes through to a real filesystem this is what lets the app open its + // OBB. Best-effort — failures are ignored. + try + { + using (SshClient ssh = new SshClient(BuildConnectionInfo(Host, Port))) + { + ssh.Connect(); + using (SshCommand cmd = ssh.CreateCommand( + $"chmod -R 0777 {quotedRemotePath} 2>/dev/null; restorecon -R {quotedRemotePath} 2>/dev/null; true")) + { + cmd.CommandTimeout = TimeSpan.FromSeconds(30); + cmd.Execute(); + } + ssh.Disconnect(); + } + } + catch (Exception ex) + { + Logger.Log($"SFTP: post-transfer chmod/restorecon skipped: {ex.Message}", LogLevel.WARNING); + } + + progressCallback?.Invoke(100, null); + statusCallback?.Invoke(""); + + double totalSecs = Math.Max(0.1, (DateTime.UtcNow - startTime).TotalSeconds); + Logger.Log($"SFTP: OBB '{folderName}' transferred ({totalBytes / 1048576.0:0.0} MB in {totalSecs:0.0}s = {(totalBytes / 1048576.0) / totalSecs:0.0} MB/s via {degree}× {User}@{Host}:{Port})"); + return new ProcessOutput($"{gameName}: OBB transfer (SFTP): Success\n", ""); + } + + // Re-uploads a single OBB file that previously failed. Runs on its own connection and + // updates the transfer item in place. Wired to the transfer strip's "Retry" action. + public static void RetryUpload(TransferItem item, string localFile, string remoteFilePath) + { + Task.Run(() => + { + try + { + if (!File.Exists(localFile)) + { + Logger.Log($"SFTP retry: local file missing: {localFile}", LogLevel.ERROR); + TransferQueue.SetState(item, TransferState.Failed); + return; + } + + TransferQueue.SetState(item, TransferState.Queued); + TransferQueue.Report(item, 0, 0); + + using (SftpClient sftp = new SftpClient(BuildConnectionInfo(Host, Port))) + { + sftp.OperationTimeout = TimeSpan.FromSeconds(60); + sftp.Connect(); + sftp.BufferSize = TransferBufferSize; + + int slash = remoteFilePath.LastIndexOf('/'); + if (slash > 0) + { + EnsureRemoteDirectory(sftp, remoteFilePath.Substring(0, slash), new HashSet()); + } + + long total = new FileInfo(localFile).Length; + DateTime start = DateTime.UtcNow; + using (FileStream stream = File.OpenRead(localFile)) + { + sftp.UploadFile(stream, remoteFilePath, true, uploaded => + { + long u = (long)uploaded; + double e = (DateTime.UtcNow - start).TotalSeconds; + double mbps = e > 0.1 ? (u / 1048576.0) / e : 0; + TransferQueue.Report(item, u, mbps); + }); + } + + var vattrs = sftp.GetAttributes(remoteFilePath); + if (vattrs == null || vattrs.Size != total) + { + throw new Exception($"verification failed: remote size {(vattrs != null ? vattrs.Size : -1)} != {total}"); + } + + TransferQueue.Report(item, total, 0); + TransferQueue.SetState(item, TransferState.Done); + sftp.Disconnect(); + } + + // best-effort perms fix on the retried file + try + { + string q = "'" + remoteFilePath.Replace("'", "'\\''") + "'"; + using (SshClient ssh = new SshClient(BuildConnectionInfo(Host, Port))) + { + ssh.Connect(); + using (SshCommand cmd = ssh.CreateCommand($"chmod 0777 {q} 2>/dev/null; restorecon {q} 2>/dev/null; true")) + { + cmd.CommandTimeout = TimeSpan.FromSeconds(15); + cmd.Execute(); + } + ssh.Disconnect(); + } + } + catch { } + } + catch (Exception ex) + { + Logger.Log($"SFTP retry failed for {Path.GetFileName(localFile)}: {ex.Message}", LogLevel.ERROR); + TransferQueue.SetState(item, TransferState.Failed); + } + }); + } + + private static void EnsureRemoteDirectory(SftpClient sftp, string remoteDir, HashSet created) + { + if (created.Contains(remoteDir)) + { + return; + } + + string[] segments = remoteDir.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + string current = ""; + foreach (string segment in segments) + { + current += "/" + segment; + if (created.Contains(current)) + { + continue; + } + if (!sftp.Exists(current)) + { + sftp.CreateDirectory(current); + } + created.Add(current); + } + } + } +} diff --git a/SftpCredentialsForm.cs b/SftpCredentialsForm.cs new file mode 100644 index 00000000..314a0010 --- /dev/null +++ b/SftpCredentialsForm.cs @@ -0,0 +1,186 @@ +using AndroidSideloader.Utilities; +using System; +using System.Drawing; +using System.IO; +using System.Windows.Forms; + +namespace AndroidSideloader +{ + // Shown when an SSH server is detected on the headset but none of the stored + // credentials work. Lets the user supply a username plus a password and/or a + // private key file, which are persisted to settings for future sessions. + public class SftpCredentialsForm : Form + { + private static readonly SettingsManager settings = SettingsManager.Instance; + + private TextBox usernameBox; + private TextBox passwordBox; + private TextBox keyPathBox; + private CheckBox disableCheck; + + public SftpCredentialsForm() + { + InitializeLayout(); + } + + private void InitializeLayout() + { + Text = "SFTP Fast Transfers - Credentials"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + ClientSize = new Size(460, 300); + BackColor = settings.BackColor; + ForeColor = settings.FontColor; + Font = settings.FontStyle; + + Label header = new Label + { + Text = "An SSH server was found on your headset, but Rookie could not log in.\n" + + "Enter its credentials to enable fast OBB transfers over SFTP.", + Location = new Point(12, 10), + Size = new Size(436, 50), + ForeColor = settings.FontColor + }; + Controls.Add(header); + + Controls.Add(MakeLabel("Username:", 12, 70)); + usernameBox = MakeTextBox(130, 67, 200); + usernameBox.Text = string.IsNullOrEmpty(settings.SftpUsername) ? "root" : settings.SftpUsername; + Controls.Add(usernameBox); + + Controls.Add(MakeLabel("Password:", 12, 110)); + passwordBox = MakeTextBox(130, 107, 200); + passwordBox.UseSystemPasswordChar = true; + passwordBox.Text = settings.SftpPassword; + Controls.Add(passwordBox); + + Controls.Add(MakeLabel("Key file:", 12, 150)); + keyPathBox = MakeTextBox(130, 147, 240); + keyPathBox.Text = settings.SftpPrivateKeyPath; + Controls.Add(keyPathBox); + + Button browseButton = new Button + { + Text = "...", + Location = new Point(378, 145), + Size = new Size(40, 28), + BackColor = settings.ButtonColor, + ForeColor = settings.FontColor, + FlatStyle = FlatStyle.Flat + }; + browseButton.Click += (s, e) => + { + using (OpenFileDialog dialog = new OpenFileDialog()) + { + dialog.Title = "Select SSH private key"; + string sshDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); + if (Directory.Exists(sshDir)) + { + dialog.InitialDirectory = sshDir; + } + dialog.Filter = "All files (*.*)|*.*"; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + keyPathBox.Text = dialog.FileName; + } + } + }; + Controls.Add(browseButton); + + disableCheck = new CheckBox + { + Text = "Don't ask again (disable SFTP fast transfers)", + Location = new Point(15, 195), + Size = new Size(420, 24), + ForeColor = settings.FontColor + }; + Controls.Add(disableCheck); + + Button okButton = new Button + { + Text = "Save && Retry", + Location = new Point(130, 240), + Size = new Size(120, 34), + BackColor = settings.ButtonColor, + ForeColor = settings.FontColor, + FlatStyle = FlatStyle.Flat + }; + okButton.Click += OkClicked; + Controls.Add(okButton); + + Button cancelButton = new Button + { + Text = "Cancel", + Location = new Point(260, 240), + Size = new Size(100, 34), + BackColor = settings.ButtonColor, + ForeColor = settings.FontColor, + FlatStyle = FlatStyle.Flat, + DialogResult = DialogResult.Cancel + }; + Controls.Add(cancelButton); + + AcceptButton = okButton; + CancelButton = cancelButton; + } + + private Label MakeLabel(string text, int x, int y) + { + return new Label + { + Text = text, + Location = new Point(x, y), + Size = new Size(112, 24), + ForeColor = settings.FontColor + }; + } + + private TextBox MakeTextBox(int x, int y, int width) + { + return new TextBox + { + Location = new Point(x, y), + Size = new Size(width, 28), + BackColor = settings.TextBoxColor, + ForeColor = settings.FontColor, + BorderStyle = BorderStyle.FixedSingle + }; + } + + private void OkClicked(object sender, EventArgs e) + { + if (disableCheck.Checked) + { + settings.EnableSftpTransfers = false; + settings.Save(); + DialogResult = DialogResult.Cancel; + Close(); + return; + } + + string keyPath = keyPathBox.Text.Trim(); + if (!string.IsNullOrEmpty(keyPath) && !File.Exists(keyPath)) + { + _ = MessageBox.Show(this, "The selected key file does not exist.", "SFTP", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (string.IsNullOrEmpty(keyPath) && string.IsNullOrEmpty(passwordBox.Text)) + { + _ = MessageBox.Show(this, "Enter a password or select a private key file.", "SFTP", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + settings.SftpUsername = usernameBox.Text.Trim(); + settings.SftpPassword = passwordBox.Text; + settings.SftpPrivateKeyPath = keyPath; + settings.Save(); + + DialogResult = DialogResult.OK; + Close(); + } + } +} diff --git a/TransferQueue.cs b/TransferQueue.cs new file mode 100644 index 00000000..1448e5a3 --- /dev/null +++ b/TransferQueue.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; + +namespace AndroidSideloader +{ + public enum TransferState { Queued, Active, Done, Failed } + + // A single file transfer surfaced in the integrated transfer strip. + public class TransferItem + { + public string Name; + public string Kind; // "SFTP", "Push", "Pull" + public long TotalBytes; + public long TransferredBytes; + public double SpeedMBps; + public TransferState State = TransferState.Queued; + public DateTime? CompletedUtc; // set when the item reaches Done/Failed, for auto-clear + public Action Retry; // re-attempts this single file; set when it fails + } + + // Thread-safe registry of in-flight file transfers. Producers are the transfer + // workers (background threads); the consumer is the TransferStrip UI timer, which + // polls Snapshot() and auto-removes finished rows via PurgeCompleted(). + public static class TransferQueue + { + private static readonly object _lock = new object(); + private static readonly List _items = new List(); + + public static TransferItem Add(string name, string kind, long totalBytes) + { + TransferItem item = new TransferItem { Name = name, Kind = kind, TotalBytes = totalBytes }; + lock (_lock) + { + _items.Add(item); + } + return item; + } + + public static void SetState(TransferItem item, TransferState state) + { + if (item == null) return; + lock (_lock) + { + item.State = state; + if (state == TransferState.Done || state == TransferState.Failed) + { + item.CompletedUtc = DateTime.UtcNow; + } + } + } + + public static void Report(TransferItem item, long transferred, double speedMBps) + { + if (item == null) return; + lock (_lock) + { + item.TransferredBytes = transferred; + item.SpeedMBps = speedMBps; + if (item.State == TransferState.Queued) + { + item.State = TransferState.Active; + } + } + } + + public static List Snapshot() + { + lock (_lock) + { + return new List(_items); + } + } + + // Auto-clears *successful* rows a short while after they finish, so a "Done" row is + // visible briefly then disappears. Failed rows are kept so the user can retry them. + public static void PurgeCompleted(double doneSeconds) + { + DateTime now = DateTime.UtcNow; + lock (_lock) + { + _items.RemoveAll(i => + i.State == TransferState.Done && + i.CompletedUtc.HasValue && + (now - i.CompletedUtc.Value).TotalSeconds >= doneSeconds); + } + } + + public static void Remove(TransferItem item) + { + if (item == null) return; + lock (_lock) + { + _items.Remove(item); + } + } + + // Clears finished rows on demand (both Done and Failed). + public static void ClearFinished() + { + lock (_lock) + { + _items.RemoveAll(i => i.State == TransferState.Done || i.State == TransferState.Failed); + } + } + } +} diff --git a/TransferStrip.cs b/TransferStrip.cs new file mode 100644 index 00000000..df417b2b --- /dev/null +++ b/TransferStrip.cs @@ -0,0 +1,286 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace AndroidSideloader +{ + // Integrated, collapsible transfer strip. It overlays the bottom of the games list + // whenever files are moving (SFTP / push), themed to match the main window, and hides + // itself when idle. Finished rows auto-clear a couple of seconds after they complete. + // This is a child of the main form, not a separate window. + public class TransferStrip : Panel + { + private readonly ListView _list; + private readonly Timer _timer; + private Control _anchor; // control whose bottom edge we sit on (games list) + private const int ExpandedHeight = 150; + + private readonly Color _bg; + private readonly Color _bgAlt; + private readonly Color _header; + private readonly Color _barBack; + private readonly Color _fg; + private readonly Color _accent = Color.FromArgb(70, 130, 220); + private readonly Color _doneColor = Color.FromArgb(70, 165, 95); + private readonly Color _failColor = Color.FromArgb(200, 80, 80); + + private sealed class BufferedListView : ListView + { + public BufferedListView() { DoubleBuffered = true; } + } + + public TransferStrip(Color themeBack, Color themeFore) + { + _bg = themeBack; + _bgAlt = Shift(themeBack, 8); + _header = Shift(themeBack, 18); + _barBack = Shift(themeBack, 28); + _fg = themeFore; + + DoubleBuffered = true; + BackColor = _header; + Visible = false; + Padding = new Padding(1, 0, 1, 1); + + Label title = new Label + { + Text = " TRANSFERS", + Dock = DockStyle.Top, + Height = 22, + BackColor = _header, + ForeColor = _fg, + TextAlign = ContentAlignment.MiddleLeft, + Font = new Font("Segoe UI", 8.25f, FontStyle.Bold) + }; + + _list = new BufferedListView + { + Dock = DockStyle.Fill, + View = View.Details, + FullRowSelect = true, + GridLines = false, + OwnerDraw = true, + BackColor = _bg, + ForeColor = _fg, + BorderStyle = BorderStyle.None, + HeaderStyle = ColumnHeaderStyle.Nonclickable + }; + _list.Columns.Add("File", 300); + _list.Columns.Add("Size", 80, HorizontalAlignment.Right); + _list.Columns.Add("Progress", 150); + _list.Columns.Add("Speed", 80, HorizontalAlignment.Right); + _list.Columns.Add("Status", 70, HorizontalAlignment.Left); + _list.DrawColumnHeader += OnDrawHeader; + _list.DrawItem += (s, e) => { /* handled per-subitem */ }; + _list.DrawSubItem += OnDrawSubItem; + + Controls.Add(_list); + Controls.Add(title); + + // Right-click menu: retry a failed file, remove a row, or clear finished rows. + ContextMenuStrip menu = new ContextMenuStrip { BackColor = _header, ForeColor = _fg }; + ToolStripMenuItem retryMi = new ToolStripMenuItem("Retry"); + ToolStripMenuItem removeMi = new ToolStripMenuItem("Remove"); + ToolStripMenuItem clearMi = new ToolStripMenuItem("Clear finished"); + retryMi.Click += (s, e) => { TransferItem t = _menuTarget; if (t != null && t.Retry != null) t.Retry(); }; + removeMi.Click += (s, e) => { if (_menuTarget != null) TransferQueue.Remove(_menuTarget); }; + clearMi.Click += (s, e) => TransferQueue.ClearFinished(); + menu.Items.Add(retryMi); + menu.Items.Add(removeMi); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add(clearMi); + menu.Opening += (s, e) => + { + retryMi.Enabled = _menuTarget != null && _menuTarget.State == TransferState.Failed && _menuTarget.Retry != null; + removeMi.Enabled = _menuTarget != null; + }; + _list.ContextMenuStrip = menu; + _list.MouseDown += (s, e) => + { + if (e.Button == MouseButtons.Right) + { + ListViewHitTestInfo hit = _list.HitTest(e.Location); + _menuTarget = hit.Item != null ? hit.Item.Tag as TransferItem : null; + } + }; + + _timer = new Timer { Interval = 250 }; + _timer.Tick += (s, e) => Tick(); + } + + private TransferItem _menuTarget; + + // Attaches the strip to a parent form and anchors it to a control's bottom edge. + public void AttachTo(Control parent, Control anchor) + { + _anchor = anchor; + if (parent != null && !parent.IsDisposed) + { + parent.Controls.Add(this); + BringToFront(); + } + _timer.Start(); + } + + private void Tick() + { + try + { + TransferQueue.PurgeCompleted(2.0); + var items = TransferQueue.Snapshot(); + + if (items.Count == 0) + { + if (Visible) Visible = false; + return; + } + + if (_anchor != null && !_anchor.IsDisposed) + { + int h = Math.Min(ExpandedHeight, Math.Max(64, _anchor.Height - 30)); + SetBounds(_anchor.Left, _anchor.Bottom - h, _anchor.Width, h); + } + + if (!Visible) Visible = true; + BringToFront(); + RefreshRows(items); + } + catch { } + } + + private void RefreshRows(System.Collections.Generic.List items) + { + _list.BeginUpdate(); + while (_list.Items.Count > items.Count) + { + _list.Items.RemoveAt(_list.Items.Count - 1); + } + for (int i = 0; i < items.Count; i++) + { + TransferItem t = items[i]; + ListViewItem row; + if (i < _list.Items.Count) + { + row = _list.Items[i]; + } + else + { + row = new ListViewItem(); + row.SubItems.Add(""); + row.SubItems.Add(""); + row.SubItems.Add(""); + row.SubItems.Add(""); + _list.Items.Add(row); + } + + row.Tag = t; + row.SubItems[0].Text = t.Name; + row.SubItems[1].Text = FormatSize(t.TotalBytes); + double pct = t.TotalBytes > 0 ? (t.TransferredBytes * 100.0 / t.TotalBytes) : 0; + row.SubItems[2].Text = pct.ToString("0.0") + "%"; + row.SubItems[3].Text = t.State == TransferState.Active ? t.SpeedMBps.ToString("0.0") + " MB/s" : ""; + row.SubItems[4].Text = StatusText(t.State); + } + _list.EndUpdate(); + } + + private static string StatusText(TransferState s) + { + switch (s) + { + case TransferState.Queued: return "Queued"; + case TransferState.Active: return "Active"; + case TransferState.Done: return "Done"; + case TransferState.Failed: return "Failed"; + default: return ""; + } + } + + private static string FormatSize(long bytes) + { + if (bytes <= 0) return ""; + double mb = bytes / 1048576.0; + return mb >= 1024 ? (mb / 1024.0).ToString("0.00") + " GB" : mb.ToString("0.0") + " MB"; + } + + private static Color Shift(Color c, int amt) + { + return Color.FromArgb( + Math.Min(255, Math.Max(0, c.R + amt)), + Math.Min(255, Math.Max(0, c.G + amt)), + Math.Min(255, Math.Max(0, c.B + amt))); + } + + private void OnDrawHeader(object sender, DrawListViewColumnHeaderEventArgs e) + { + using (SolidBrush b = new SolidBrush(_header)) + { + e.Graphics.FillRectangle(b, e.Bounds); + } + TextRenderer.DrawText(e.Graphics, e.Header.Text, Font, e.Bounds, Color.Gainsboro, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.LeftAndRightPadding); + using (Pen p = new Pen(Shift(_bg, 40))) + { + e.Graphics.DrawLine(p, e.Bounds.Left, e.Bounds.Bottom - 1, e.Bounds.Right, e.Bounds.Bottom - 1); + } + } + + private void OnDrawSubItem(object sender, DrawListViewSubItemEventArgs e) + { + TransferItem t = e.Item.Tag as TransferItem; + Color rowBg = (e.ItemIndex % 2 == 0) ? _bg : _bgAlt; + using (SolidBrush b = new SolidBrush(rowBg)) + { + e.Graphics.FillRectangle(b, e.Bounds); + } + + if (e.ColumnIndex == 2 && t != null) + { + Rectangle r = e.Bounds; + r.Inflate(-4, -5); + using (SolidBrush bb = new SolidBrush(_barBack)) + { + e.Graphics.FillRectangle(bb, r); + } + double frac = t.TotalBytes > 0 ? Math.Max(0, Math.Min(1, (double)t.TransferredBytes / t.TotalBytes)) : 0; + Rectangle fill = r; + fill.Width = (int)(r.Width * frac); + Color barColor = t.State == TransferState.Failed ? _failColor + : t.State == TransferState.Done ? _doneColor + : _accent; + using (SolidBrush fb = new SolidBrush(barColor)) + { + e.Graphics.FillRectangle(fb, fill); + } + TextRenderer.DrawText(e.Graphics, (frac * 100).ToString("0.0") + "%", Font, e.Bounds, _fg, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } + else + { + TextFormatFlags flags = TextFormatFlags.VerticalCenter | TextFormatFlags.LeftAndRightPadding | TextFormatFlags.EndEllipsis; + if (e.Header != null && e.Header.TextAlign == HorizontalAlignment.Right) + { + flags |= TextFormatFlags.Right; + } + Color txt = _fg; + if (e.ColumnIndex == 4 && t != null) + { + txt = t.State == TransferState.Done ? Color.FromArgb(120, 205, 145) + : t.State == TransferState.Failed ? Color.FromArgb(230, 120, 120) + : t.State == TransferState.Active ? Color.FromArgb(120, 180, 240) + : Color.Gainsboro; + } + TextRenderer.DrawText(e.Graphics, e.SubItem.Text, Font, e.Bounds, txt, flags); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + try { _timer?.Stop(); _timer?.Dispose(); } catch { } + } + base.Dispose(disposing); + } + } +} diff --git a/Utilities/ChunkedDownloader.cs b/Utilities/ChunkedDownloader.cs index 3b048215..7447edac 100644 --- a/Utilities/ChunkedDownloader.cs +++ b/Utilities/ChunkedDownloader.cs @@ -366,8 +366,9 @@ public async Task DownloadAllAsync( } }; - // Determine concurrency - int maxConcurrency = _settings.SingleThreadMode ? 1 : Math.Min(4, files.Count); + // Determine concurrency (files downloaded in parallel). + int desiredThreads = _settings.DownloadThreads > 0 ? _settings.DownloadThreads : 6; + int maxConcurrency = _settings.SingleThreadMode ? 1 : Math.Min(desiredThreads, Math.Max(1, files.Count)); var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); var tasks = new List>(); diff --git a/Utilities/SettingsManager.cs b/Utilities/SettingsManager.cs index d4a2587e..130d17e1 100644 --- a/Utilities/SettingsManager.cs +++ b/Utilities/SettingsManager.cs @@ -123,7 +123,13 @@ public override void WriteJson(JsonWriter writer, Color value, JsonSerializer se public bool CustomDownloadDir { get; set; } = false; public bool CustomBackupDir { get; set; } = false; public string BackupDir { get; set; } = string.Empty; - public bool SingleThreadMode { get; set; } = true; + public bool SingleThreadMode { get; set; } = false; + // Number of download parts fetched in parallel when multithreading is enabled. + // Ignored when SingleThreadMode is on. + public int DownloadThreads { get; set; } = 6; + // Number of OBB files uploaded concurrently over SFTP (FileZilla-style queue). + // Kept modest so the on-device sshd / FUSE storage is not overwhelmed. + public int SftpThreads { get; set; } = 3; public bool VirtualFilesystemCompatibility { get; set; } = false; public bool UpdateSettings { get; set; } = true; public string UUID { get; set; } = Guid.NewGuid().ToString(); @@ -141,6 +147,16 @@ public override void WriteJson(JsonWriter writer, Color value, JsonSerializer se public int GalleryTileSize { get; set; } = 100; public string ConfigUrl { get; set; } = string.Empty; + // SFTP fast transfers (rooted headsets running an SSH server). + // Empty host = auto-detect from ADB; port 0 = probe 22, 8022, 2222; + // empty key path = try ~/.ssh/quest_root, id_ed25519, id_rsa. + public bool EnableSftpTransfers { get; set; } = true; + public string SftpHost { get; set; } = string.Empty; + public int SftpPort { get; set; } = 0; + public string SftpUsername { get; set; } = "root"; + public string SftpPassword { get; set; } = string.Empty; + public string SftpPrivateKeyPath { get; set; } = string.Empty; + // Window state persistence public int WindowX { get; set; } = -1; public int WindowY { get; set; } = -1; @@ -268,7 +284,9 @@ private void CreateDefaultSettings() CustomDownloadDir = false; CustomBackupDir = false; BackupDir = string.Empty; - SingleThreadMode = true; + SingleThreadMode = false; + DownloadThreads = 6; + SftpThreads = 3; VirtualFilesystemCompatibility = false; UpdateSettings = true; UUID = Guid.NewGuid().ToString(); @@ -285,6 +303,12 @@ private void CreateDefaultSettings() UseGalleryView = true; GalleryTileSize = 100; ConfigUrl = string.Empty; + EnableSftpTransfers = true; + SftpHost = string.Empty; + SftpPort = 0; + SftpUsername = "root"; + SftpPassword = string.Empty; + SftpPrivateKeyPath = string.Empty; WindowX = -1; WindowY = -1; WindowWidth = -1; diff --git a/build.cmd b/build.cmd index 713edbcc..456b5ac7 100644 --- a/build.cmd +++ b/build.cmd @@ -11,7 +11,7 @@ IF NOT EXIST %VSWHERE% SET VSWHERE="%ProgramFiles%\Microsoft Visual Studio\Insta SET MSBUILD= IF EXIST %VSWHERE% ( - FOR /F "usebackq delims=" %%P IN (`%VSWHERE% -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe`) DO ( + FOR /F "usebackq delims=" %%P IN (`%VSWHERE% -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe`) DO ( SET MSBUILD="%%P" GOTO :msbuild_found ) diff --git a/packages.config b/packages.config index f998aa6a..262da451 100644 --- a/packages.config +++ b/packages.config @@ -1,24 +1,31 @@  + + + + + + + @@ -27,6 +34,7 @@ + @@ -34,7 +42,9 @@ + + \ No newline at end of file