Merge pull request #279 from jp64k/RSL-3.0-2

Fixes, QoL and new features... Enhanced DNS/ADB handling, fixed gallery selection, fixed newer than list/blacklist logic, synchronized gallery/list view sorting, modernized download queue UI, added session persistence
This commit is contained in:
Fenopy
2026-01-05 06:53:11 -06:00
committed by GitHub
9 changed files with 2574 additions and 1030 deletions

90
ADB.cs
View File

@@ -72,7 +72,7 @@ namespace AndroidSideloader
return _currentDevice;
}
public static ProcessOutput RunAdbCommandToString(string command)
public static ProcessOutput RunAdbCommandToString(string command, bool suppressLogging = false)
{
command = command.Replace("adb", "");
@@ -85,7 +85,7 @@ namespace AndroidSideloader
command = $" -s {DeviceID} {command}";
}
if (!command.Contains("dumpsys") && !command.Contains("shell pm list packages") && !command.Contains("KEYCODE_WAKEUP"))
if (!suppressLogging && !command.Contains("dumpsys") && !command.Contains("shell pm list packages") && !command.Contains("KEYCODE_WAKEUP"))
{
string logcmd = command;
if (logcmd.Contains(Environment.CurrentDirectory))
@@ -95,6 +95,9 @@ namespace AndroidSideloader
_ = Logger.Log($"Running command: {logcmd}");
}
bool isConnectCommand = command.Contains("connect");
int timeoutMs = isConnectCommand ? 5000 : -1; // 5 second timeout for connect commands
using (Process adb = new Process())
{
adb.StartInfo.FileName = adbFilePath;
@@ -111,19 +114,39 @@ namespace AndroidSideloader
try
{
output = adb.StandardOutput.ReadToEnd();
error = adb.StandardError.ReadToEnd();
}
catch { }
if (command.Contains("connect"))
{
bool graceful = adb.WaitForExit(3000);
if (!graceful)
if (isConnectCommand)
{
adb.Kill();
adb.WaitForExit();
// For connect commands, we use async reading with timeout to avoid blocking on TCP timeout
var outputTask = adb.StandardOutput.ReadToEndAsync();
var errorTask = adb.StandardError.ReadToEndAsync();
bool exited = adb.WaitForExit(timeoutMs);
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);
}
else
{
// Process exited within timeout, safe to read output
output = outputTask.Result;
error = errorTask.Result;
}
}
else
{
// For non-connect commands, read output normally
output = adb.StandardOutput.ReadToEnd();
error = adb.StandardError.ReadToEnd();
}
}
catch (Exception ex)
{
Logger.Log($"Error reading ADB output: {ex.Message}", LogLevel.WARNING);
}
if (error.Contains("ADB_VENDOR_KEYS") && !settings.AdbDebugWarned)
@@ -134,7 +157,7 @@ namespace AndroidSideloader
{
_ = FlexibleMessageBox.Show(Program.form, "There is not enough room on your device to install this package. Please clear AT LEAST 2x the amount of the app you are trying to install.");
}
if (!output.Contains("version") && !output.Contains("KEYCODE_WAKEUP") && !output.Contains("Filesystem") && !output.Contains("package:") && !output.Equals(null))
if (!suppressLogging && !output.Contains("version") && !output.Contains("KEYCODE_WAKEUP") && !output.Contains("Filesystem") && !output.Contains("package:") && !output.Equals(null))
{
_ = Logger.Log(output);
}
@@ -264,18 +287,38 @@ namespace AndroidSideloader
{
Logger.Log($"SideloadWithProgressAsync error: {ex.Message}", LogLevel.ERROR);
if (ex.Message.Contains("INSTALL_FAILED") ||
ex.Message.Contains("signatures do not match"))
// 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") ||
ex.Message.Contains("failed to install");
// For insufficient storage, offer reinstall if it's an upgrade
// As uninstalling old version frees space for the new one
bool isStorageIssue = ex.Message.Contains("INSUFFICIENT_STORAGE");
bool isUpgrade = !string.IsNullOrEmpty(packagename) &&
settings.InstalledApps.Contains(packagename);
if (isStorageIssue && isUpgrade)
{
isReinstallEligible = true;
}
if (isReinstallEligible)
{
bool cancelClicked = false;
if (!settings.AutoReinstall)
{
string message = isStorageIssue
? "Installation failed due to insufficient storage. Since this is an upgrade, Rookie can uninstall the old version first to free up space, then install the new version.\n\nRookie will also attempt to backup your save data and reinstall the game automatically, however some games do not store their saves in an accessible location (less than 5%). Continue with reinstall?"
: "In place upgrade has failed. Rookie will attempt to backup your save data and reinstall the game automatically, however some games do not store their saves in an accessible location (less than 5%). Continue with reinstall?";
string title = isStorageIssue ? "Insufficient Storage" : "In place upgrade failed";
Program.form.Invoke(() =>
{
DialogResult dialogResult1 = FlexibleMessageBox.Show(Program.form,
"In place upgrade has failed. Rookie can attempt to backup your save data and reinstall the game automatically, however some games do not store their saves in an accessible location (less than 5%). Continue with reinstall?",
"In place upgrade failed.", MessageBoxButtons.OKCancel);
message, title, MessageBoxButtons.OKCancel);
if (dialogResult1 == DialogResult.Cancel)
cancelClicked = true;
});
@@ -293,7 +336,7 @@ namespace AndroidSideloader
var packageManager = new PackageManager(client, device);
statusCallback?.Invoke("Backing up save data...");
_ = RunAdbCommandToString($"pull \"/sdcard/Android/data/{MainForm.CurrPCKG}\" \"{Environment.CurrentDirectory}\"");
_ = RunAdbCommandToString($"pull \"/sdcard/Android/data/{packagename}\" \"{Environment.CurrentDirectory}\"");
statusCallback?.Invoke("Uninstalling old version...");
packageManager.UninstallPackage(packagename);
@@ -309,9 +352,9 @@ namespace AndroidSideloader
packageManager.InstallPackage(path, reinstallProgress);
statusCallback?.Invoke("Restoring save data...");
_ = RunAdbCommandToString($"push \"{Environment.CurrentDirectory}\\{MainForm.CurrPCKG}\" /sdcard/Android/data/");
_ = RunAdbCommandToString($"push \"{Environment.CurrentDirectory}\\{packagename}\" /sdcard/Android/data/");
string directoryToDelete = Path.Combine(Environment.CurrentDirectory, MainForm.CurrPCKG);
string directoryToDelete = Path.Combine(Environment.CurrentDirectory, packagename);
if (Directory.Exists(directoryToDelete) && directoryToDelete != Environment.CurrentDirectory)
{
Directory.Delete(directoryToDelete, true);
@@ -322,11 +365,12 @@ namespace AndroidSideloader
}
catch (Exception reinstallEx)
{
return new ProcessOutput($"{gameName}: Reinstall: Failed: {reinstallEx.Message}\n");
return new ProcessOutput("", $"{gameName}: Reinstall Failed: {reinstallEx.Message}\n");
}
}
return new ProcessOutput("", ex.Message);
// Return the error message so it's displayed to the user
return new ProcessOutput("", $"\n{gameName}: {ex.Message}");
}
}

View File

@@ -194,6 +194,9 @@
<Compile Include="ModernProgessBar.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="ModernQueuePanel.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>

View File

@@ -24,6 +24,8 @@ public class FastGalleryPanel : Control
// Sorting
private SortField _currentSortField = SortField.Name;
private SortDirection _currentSortDirection = SortDirection.Ascending;
public SortField CurrentSortField => _currentSortField;
public SortDirection CurrentSortDirection => _currentSortDirection;
private readonly Panel _sortPanel;
private readonly List<Button> _sortButtons;
private Label _sortStatusLabel;
@@ -66,8 +68,8 @@ public class FastGalleryPanel : Control
// Visual constants
private const int CORNER_RADIUS = 10;
private const int THUMB_CORNER_RADIUS = 8;
private const float HOVER_SCALE = 1.07f;
private const float ANIMATION_SPEED = 0.25f;
private const float HOVER_SCALE = 1.08f;
private const float ANIMATION_SPEED = 0.33f;
private const float SCROLL_SMOOTHING = 0.3f;
private const int DELETE_BUTTON_SIZE = 26;
private const int DELETE_BUTTON_MARGIN = 6;
@@ -399,6 +401,14 @@ public class FastGalleryPanel : Control
Invalidate();
}
public void SetSortState(SortField field, SortDirection direction)
{
_currentSortField = field;
_currentSortDirection = direction;
UpdateSortButtonStyles();
ApplySort();
}
private int ParsePopularity(string popStr)
{
if (string.IsNullOrEmpty(popStr))
@@ -773,7 +783,7 @@ public class FastGalleryPanel : Control
else
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.InterpolationMode = InterpolationMode.Bilinear;
}
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
@@ -835,6 +845,7 @@ public class FastGalleryPanel : Control
{
var item = _items[index];
var state = _tileStates.ContainsKey(index) ? _tileStates[index] : new TileAnimationState();
bool isHovered = index == _hoveredIndex;
int baseX = _leftPadding + col * (_tileWidth + _spacing);
int baseY = _spacing + SORT_PANEL_HEIGHT + row * (_tileHeight + _spacing) - scrollY;
@@ -847,80 +858,36 @@ public class FastGalleryPanel : Control
var tileRect = new Rectangle(x, y, scaledW, scaledH);
// Tile background
using (var tilePath = CreateRoundedRectangle(tileRect, CORNER_RADIUS))
{
int brightness = (int)state.BackgroundBrightness;
using (var bgBrush = new SolidBrush(Color.FromArgb(255, brightness, brightness, brightness + 2)))
g.FillPath(bgBrush, tilePath);
if (state.SelectionOpacity > 0.01f)
{
using (var selectionPen = new Pen(Color.FromArgb((int)(255 * state.SelectionOpacity), TileBorderSelected), 3f))
g.DrawPath(selectionPen, tilePath);
}
if (state.BorderOpacity > 0.01f)
{
using (var borderPen = new Pen(Color.FromArgb((int)(200 * state.BorderOpacity), TileBorderHover), 2f))
g.DrawPath(borderPen, tilePath);
}
// Favorite border (golden)
if (state.FavoriteOpacity > 0.5f)
{
using (var favPen = new Pen(Color.FromArgb((int)(180 * state.FavoriteOpacity), TileBorderFavorite), 1.0f))
g.DrawPath(favPen, tilePath);
}
}
// Thumbnail
int thumbPadding = 2;
int thumbHeight = scaledH - (thumbPadding * 2);
var thumbRect = new Rectangle(
x + thumbPadding,
y + thumbPadding,
scaledW - (thumbPadding * 2),
thumbHeight
);
// Base (non-scaled) thumbnail size for stable placeholder text layout
int baseThumbW = _tileWidth - (thumbPadding * 2);
int baseThumbH = _tileHeight - (thumbPadding * 2);
var baseThumbRect = new Rectangle(
thumbRect.X + (thumbRect.Width - baseThumbW) / 2,
thumbRect.Y + (thumbRect.Height - baseThumbH) / 2,
baseThumbW,
baseThumbH
);
string packageName = item.SubItems.Count > 2 ? item.SubItems[2].Text : "";
var thumbnail = GetCachedImage(packageName);
using (var thumbPath = CreateRoundedRectangle(thumbRect, THUMB_CORNER_RADIUS))
using (var tilePath = CreateRoundedRectangle(tileRect, THUMB_CORNER_RADIUS))
{
var oldClip = g.Clip;
g.SetClip(thumbPath, CombineMode.Replace);
g.SetClip(tilePath, CombineMode.Replace);
if (thumbnail != null)
{
InterpolationMode previousMode = g.InterpolationMode;
if (isHovered)
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
float imgRatio = (float)thumbnail.Width / thumbnail.Height;
float rectRatio = (float)thumbRect.Width / thumbRect.Height;
float rectRatio = (float)tileRect.Width / tileRect.Height;
Rectangle drawRect = imgRatio > rectRatio
? new Rectangle(thumbRect.X - ((int)(thumbRect.Height * imgRatio) - thumbRect.Width) / 2, thumbRect.Y, (int)(thumbRect.Height * imgRatio), thumbRect.Height)
: new Rectangle(thumbRect.X, thumbRect.Y - ((int)(thumbRect.Width / imgRatio) - thumbRect.Height) / 2, thumbRect.Width, (int)(thumbRect.Width / imgRatio));
? new Rectangle(x - ((int)(scaledH * imgRatio) - scaledW) / 2, y, (int)(scaledH * imgRatio), scaledH)
: new Rectangle(x, y - ((int)(scaledW / imgRatio) - scaledH) / 2, scaledW, (int)(scaledW / imgRatio));
g.DrawImage(thumbnail, drawRect);
if (isHovered)
g.InterpolationMode = previousMode;
}
else
{
using (var brush = new SolidBrush(Color.FromArgb(35, 35, 40)))
g.FillPath(brush, thumbPath);
// Show game name when thumbnail is missing, centered
var nameRect = new Rectangle(baseThumbRect.X + 10, baseThumbRect.Y, baseThumbRect.Width - 20, baseThumbRect.Height);
g.FillPath(brush, tilePath);
var nameRect = new Rectangle(x + 10, y, scaledW - 20, scaledH);
using (var font = new Font("Segoe UI", 10f, FontStyle.Bold))
{
var sfName = new StringFormat
@@ -929,7 +896,6 @@ public class FastGalleryPanel : Control
LineAlignment = StringAlignment.Center,
Trimming = StringTrimming.EllipsisCharacter
};
using (var text = new SolidBrush(Color.FromArgb(110, 110, 120)))
g.DrawString(item.Text, font, text, nameRect, sfName);
}
@@ -939,12 +905,11 @@ public class FastGalleryPanel : Control
}
// Status badges (left side)
int badgeY = y + thumbPadding + 4;
int badgeY = y + 4;
// Favorite badge
if (state.FavoriteOpacity > 0.5f)
{
DrawBadge(g, "★", x + thumbPadding + 4, badgeY, BadgeFavoriteBg);
DrawBadge(g, "★", x + 4, badgeY, BadgeFavoriteBg);
badgeY += 18;
}
@@ -954,68 +919,56 @@ public class FastGalleryPanel : Control
if (hasUpdate)
{
DrawBadge(g, "UPDATE AVAILABLE", x + thumbPadding + 4, badgeY, Color.FromArgb(180, MainForm.ColorUpdateAvailable.R, MainForm.ColorUpdateAvailable.G, MainForm.ColorUpdateAvailable.B));
DrawBadge(g, "UPDATE AVAILABLE", x + 4, badgeY, Color.FromArgb(180, MainForm.ColorUpdateAvailable.R, MainForm.ColorUpdateAvailable.G, MainForm.ColorUpdateAvailable.B));
badgeY += 18;
}
if (canDonate)
{
DrawBadge(g, "NEWER THAN LIST", x + thumbPadding + 4, badgeY, Color.FromArgb(180, MainForm.ColorDonateGame.R, MainForm.ColorDonateGame.G, MainForm.ColorDonateGame.B));
DrawBadge(g, "NEWER THAN LIST", x + 4, badgeY, Color.FromArgb(180, MainForm.ColorDonateGame.R, MainForm.ColorDonateGame.G, MainForm.ColorDonateGame.B));
badgeY += 18;
}
if (installed || hasUpdate || canDonate)
DrawBadge(g, "INSTALLED", x + thumbPadding + 4, badgeY, BadgeInstalledBg);
DrawBadge(g, "INSTALLED", x + 4, badgeY, BadgeInstalledBg);
// Right-side badges (top-right of thumbnail)
int rightBadgeY = y + thumbPadding + 4;
// Right-side badges
int rightBadgeY = y + 4;
// Size badge (top right) - always visible
if (item.SubItems.Count > 5)
{
string sizeText = item.SubItems[5].Text;
if (!string.IsNullOrEmpty(sizeText))
{
DrawRightAlignedBadge(g, sizeText, x + scaledW - thumbPadding - 4, rightBadgeY, 1.0f);
DrawRightAlignedBadge(g, sizeText, x + scaledW - 4, rightBadgeY, 1.0f);
rightBadgeY += 18;
}
}
// Last updated badge (below size, right aligned) - only on hover with fade
if (state.TooltipOpacity > 0.01f && item.SubItems.Count > 4)
{
string lastUpdated = item.SubItems[4].Text;
string formattedDate = FormatLastUpdated(lastUpdated);
string formattedDate = FormatLastUpdated(item.SubItems[4].Text);
if (!string.IsNullOrEmpty(formattedDate))
{
DrawRightAlignedBadge(g, formattedDate, x + scaledW - thumbPadding - 4, rightBadgeY, state.TooltipOpacity);
}
DrawRightAlignedBadge(g, formattedDate, x + scaledW - 4, rightBadgeY, state.TooltipOpacity);
}
// Delete button (bottom-right of thumbnail) - for installed apps on hover
// Delete button
if (state.DeleteButtonOpacity > 0.01f)
{
DrawDeleteButton(g, x, y, scaledW, thumbHeight, thumbPadding, state.DeleteButtonOpacity, _isHoveringDeleteButton && index == _hoveredIndex);
}
DrawDeleteButton(g, x, y, scaledW, scaledH, 0, state.DeleteButtonOpacity, _isHoveringDeleteButton && index == _hoveredIndex);
// Game name
if (state.TooltipOpacity > 0.01f)
{
int overlayH = 20;
var overlayRect = new Rectangle(thumbRect.X, thumbRect.Bottom - overlayH, thumbRect.Width, overlayH);
var overlayRect = new Rectangle(x, y + scaledH - overlayH, scaledW, overlayH);
// Clip to the exact rounded thumbnail so the overlay corners match perfectly
Region oldClip = g.Clip;
using (var clipPath = CreateRoundedRectangle(thumbRect, THUMB_CORNER_RADIUS))
using (var clipPath = CreateRoundedRectangle(tileRect, THUMB_CORNER_RADIUS))
{
g.SetClip(clipPath, CombineMode.Intersect);
// Slightly overdraw to avoid 1px seams from AA / integer rounding
var fillRect = new Rectangle(overlayRect.X - 1, overlayRect.Y, overlayRect.Width + 2, overlayRect.Height + 1);
using (var overlayBrush = new SolidBrush(Color.FromArgb((int)(180 * state.TooltipOpacity), 0, 0, 0)))
g.FillRectangle(overlayBrush, fillRect);
g.Clip = oldClip;
}
@@ -1029,11 +982,25 @@ public class FastGalleryPanel : Control
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = StringFormatFlags.NoWrap
};
var textRect = new Rectangle(overlayRect.X, overlayRect.Y + 1, overlayRect.Width, overlayRect.Height);
g.DrawString(item.Text, font, brush, textRect, sf);
g.DrawString(item.Text, font, brush, new Rectangle(overlayRect.X, overlayRect.Y + 1, overlayRect.Width, overlayRect.Height), sf);
}
}
// Tile borders
using (var tilePath = CreateRoundedRectangle(tileRect, CORNER_RADIUS))
{
if (state.SelectionOpacity > 0.01f) // Selected border
using (var selectionPen = new Pen(Color.FromArgb((int)(255 * state.SelectionOpacity), TileBorderSelected), 3f))
g.DrawPath(selectionPen, tilePath);
if (state.BorderOpacity > 0.01f) // Hover border
using (var borderPen = new Pen(Color.FromArgb((int)(200 * state.BorderOpacity), TileBorderHover), 2f))
g.DrawPath(borderPen, tilePath);
if (state.FavoriteOpacity > 0.5f) // Favorite border
using (var favPen = new Pen(Color.FromArgb((int)(180 * state.FavoriteOpacity), TileBorderFavorite), 1f))
g.DrawPath(favPen, tilePath);
}
}
private void DrawDeleteButton(Graphics g, int tileX, int tileY, int tileWidth, int thumbHeight, int thumbPadding, float opacity, bool isHovering)
@@ -1152,16 +1119,6 @@ public class FastGalleryPanel : Control
}
}
private string FormatSize(string sizeStr)
{
if (double.TryParse(sizeStr?.Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double mb))
{
double gb = mb / 1024.0;
return gb >= 0.1 ? $"{gb:F2} GB" : $"{mb:F0} MB";
}
return "";
}
private Image GetCachedImage(string packageName)
{
if (string.IsNullOrEmpty(packageName)) return null;
@@ -1399,17 +1356,17 @@ public class FastGalleryPanel : Control
_favoritesCache = new HashSet<string>(SettingsManager.Instance.FavoritedGames, StringComparer.OrdinalIgnoreCase);
}
public void ScrollToPackage(string packageName)
public void ScrollToPackage(string releaseName)
{
if (string.IsNullOrEmpty(packageName) || _items == null || _items.Count == 0)
if (string.IsNullOrEmpty(releaseName) || _items == null || _items.Count == 0)
return;
// Find the index of the item with the matching package name
// Find the index of the item with the matching release name
for (int i = 0; i < _items.Count; i++)
{
var item = _items[i];
if (item.SubItems.Count > 2 &&
item.SubItems[2].Text.Equals(packageName, StringComparison.OrdinalIgnoreCase))
if (item.SubItems.Count > 1 &&
item.SubItems[1].Text.Equals(releaseName, StringComparison.OrdinalIgnoreCase))
{
// Calculate the row this item is in
int row = i / _columns;

32
MainForm.Designer.cs generated
View File

@@ -49,7 +49,6 @@ namespace AndroidSideloader
this.DownloadsIndex = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader()));
this.gamesQueueLabel = new System.Windows.Forms.Label();
this.notesRichTextBox = new System.Windows.Forms.RichTextBox();
this.DragDropLbl = new System.Windows.Forms.Label();
this.lblNotes = new System.Windows.Forms.Label();
this.gamesPictureBox = new System.Windows.Forms.PictureBox();
this.startsideloadbutton_Tooltip = new System.Windows.Forms.ToolTip(this.components);
@@ -202,6 +201,7 @@ namespace AndroidSideloader
//
// gamesQueListBox
//
this.gamesQueListBox.AllowDrop = false;
this.gamesQueListBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.gamesQueListBox.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30)))));
this.gamesQueListBox.BorderStyle = System.Windows.Forms.BorderStyle.None;
@@ -215,10 +215,6 @@ namespace AndroidSideloader
this.gamesQueListBox.Name = "gamesQueListBox";
this.gamesQueListBox.Size = new System.Drawing.Size(266, 192);
this.gamesQueListBox.TabIndex = 9;
this.gamesQueListBox.MouseClick += new System.Windows.Forms.MouseEventHandler(this.gamesQueListBox_MouseClick);
this.gamesQueListBox.DrawItem += new System.Windows.Forms.DrawItemEventHandler(this.gamesQueListBox_DrawItem);
this.gamesQueListBox.DragDrop += new System.Windows.Forms.DragEventHandler(this.Form1_DragDrop);
this.gamesQueListBox.DragEnter += new System.Windows.Forms.DragEventHandler(this.Form1_DragEnter);
//
// devicesComboBox
//
@@ -340,38 +336,24 @@ namespace AndroidSideloader
//
// notesRichTextBox
//
this.notesRichTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
this.notesRichTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.notesRichTextBox.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(24)))), ((int)(((byte)(26)))), ((int)(((byte)(30)))));
this.notesRichTextBox.BorderStyle = System.Windows.Forms.BorderStyle.None;
this.notesRichTextBox.Font = new System.Drawing.Font("Microsoft Sans Serif", 10F);
this.notesRichTextBox.ForeColor = System.Drawing.Color.White;
this.notesRichTextBox.Font = new System.Drawing.Font("Segoe UI", 8.5F, System.Drawing.FontStyle.Italic);
this.notesRichTextBox.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(140)))), ((int)(((byte)(140)))), ((int)(((byte)(140)))));
this.notesRichTextBox.HideSelection = false;
this.notesRichTextBox.Location = new System.Drawing.Point(954, 496);
this.notesRichTextBox.Name = "notesRichTextBox";
this.notesRichTextBox.ReadOnly = true;
this.notesRichTextBox.ScrollBars = System.Windows.Forms.RichTextBoxScrollBars.None;
this.notesRichTextBox.SelectionAlignment = System.Windows.Forms.HorizontalAlignment.Center;
this.notesRichTextBox.ShowSelectionMargin = true;
this.notesRichTextBox.Size = new System.Drawing.Size(265, 192);
this.notesRichTextBox.TabIndex = 10;
this.notesRichTextBox.Text = "Tip: Press F1 to see all shortcuts";
this.notesRichTextBox.Text = "\n\n\n\n\nTip: Press F1 to see all shortcuts\n\nDrag and drop APKs or folders to install";
this.notesRichTextBox.LinkClicked += new System.Windows.Forms.LinkClickedEventHandler(this.notesRichTextBox_LinkClicked);
//
// DragDropLbl
//
this.DragDropLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.DragDropLbl.AutoSize = true;
this.DragDropLbl.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(20)))), ((int)(((byte)(20)))), ((int)(((byte)(20)))));
this.DragDropLbl.DataBindings.Add(new System.Windows.Forms.Binding("ForeColor", global::AndroidSideloader.Properties.Settings.Default, "FontColor", true, System.Windows.Forms.DataSourceUpdateMode.OnPropertyChanged));
this.DragDropLbl.Font = new System.Drawing.Font("Microsoft Sans Serif", 36F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
this.DragDropLbl.ForeColor = global::AndroidSideloader.Properties.Settings.Default.FontColor;
this.DragDropLbl.Location = new System.Drawing.Point(620, 561);
this.DragDropLbl.Name = "DragDropLbl";
this.DragDropLbl.Size = new System.Drawing.Size(320, 55);
this.DragDropLbl.TabIndex = 25;
this.DragDropLbl.Text = "DragDropLBL";
this.DragDropLbl.Visible = false;
//
// lblNotes
//
this.lblNotes.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
@@ -1580,7 +1562,6 @@ namespace AndroidSideloader
this.Controls.Add(this.ULLabel);
this.Controls.Add(this.tableLayoutPanel1);
this.Controls.Add(this.progressDLbtnContainer);
this.Controls.Add(this.DragDropLbl);
this.Controls.Add(this.lblNotes);
this.Controls.Add(this.gamesQueueLabel);
this.Controls.Add(this.gamesQueListBox);
@@ -1642,7 +1623,6 @@ namespace AndroidSideloader
private System.Windows.Forms.PictureBox gamesPictureBox;
private System.Windows.Forms.Label gamesQueueLabel;
private System.Windows.Forms.RichTextBox notesRichTextBox;
private System.Windows.Forms.Label DragDropLbl;
private System.Windows.Forms.Label lblNotes;
public System.Windows.Forms.ComboBox remotesList;
public System.Windows.Forms.ColumnHeader GameNameIndex;

File diff suppressed because it is too large Load Diff

View File

@@ -122,9 +122,31 @@ namespace AndroidSideloader
[DllImport("user32.dll")]
private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
private static extern bool InvalidateRect(IntPtr hWnd, IntPtr lpRect, bool bErase);
[DllImport("gdi32.dll")]
private static extern bool DeleteObject(IntPtr hObject);
private bool _suppressHeader = true;
public bool SuppressHeader
{
get => _suppressHeader;
set
{
if (_suppressHeader == value) return;
_suppressHeader = value;
// Invalidate ListView and header control
_listView.Invalidate();
if (_headerCursor.Handle != IntPtr.Zero)
{
InvalidateRect(_headerCursor.Handle, IntPtr.Zero, true);
}
}
}
[StructLayout(LayoutKind.Sequential)]
private struct HDHITTESTINFO
{
@@ -178,6 +200,57 @@ namespace AndroidSideloader
protected override void WndProc(ref Message m)
{
const int WM_LBUTTONDOWN = 0x0201;
const int WM_LBUTTONUP = 0x0202;
const int WM_LBUTTONDBLCLK = 0x0203;
const int WM_RBUTTONDOWN = 0x0204;
const int WM_RBUTTONUP = 0x0205;
// Block mouse interaction when header is suppressed, but still handle layout
if (_owner._suppressHeader)
{
// Block mouse clicks
if (m.Msg == WM_LBUTTONDOWN || m.Msg == WM_LBUTTONUP || m.Msg == WM_LBUTTONDBLCLK ||
m.Msg == WM_RBUTTONDOWN || m.Msg == WM_RBUTTONUP)
{
m.Result = IntPtr.Zero;
return;
}
if (m.Msg == WM_SETCURSOR)
{
Cursor.Current = Cursors.Default;
m.Result = (IntPtr)1;
return;
}
// Still handle HDM_LAYOUT to maintain custom header height
if (m.Msg == HDM_LAYOUT)
{
base.WndProc(ref m);
try
{
HDLAYOUT hdl = Marshal.PtrToStructure<HDLAYOUT>(m.LParam);
WINDOWPOS wpos = Marshal.PtrToStructure<WINDOWPOS>(hdl.pwpos);
RECT rc = Marshal.PtrToStructure<RECT>(hdl.prc);
wpos.cy = CUSTOM_HEADER_HEIGHT;
rc.top = CUSTOM_HEADER_HEIGHT;
Marshal.StructureToPtr(wpos, hdl.pwpos, false);
Marshal.StructureToPtr(rc, hdl.prc, false);
}
catch { }
m.Result = (IntPtr)1;
return;
}
base.WndProc(ref m);
return;
}
if (m.Msg == WM_ERASEBKGND)
{
m.Result = (IntPtr)1;
@@ -610,6 +683,14 @@ namespace AndroidSideloader
var g = e.Graphics;
int listViewWidth = _listView.ClientSize.Width;
// During loading, just fill with background color
if (_suppressHeader)
{
g.FillRectangle(RowNormalBrush, new Rectangle(0, e.Bounds.Y, listViewWidth, e.Bounds.Height));
e.DrawDefault = false;
return;
}
g.FillRectangle(HeaderBgBrush, e.Bounds);
if (e.ColumnIndex == _listView.Columns.Count - 1)
@@ -1187,6 +1268,28 @@ namespace AndroidSideloader
_listView.RedrawItems(_marqueeSelectedIndex, _marqueeSelectedIndex, true);
}
public void ApplySort(int columnIndex, SortOrder order)
{
if (_columnSorter == null) return;
_columnSorter.SortColumn = columnIndex;
_columnSorter.Order = order;
_listView.BeginUpdate();
try
{
_listView.Sort();
}
finally
{
_listView.EndUpdate();
}
// Invalidate header to update sort indicators
_listView.Invalidate(new Rectangle(0, 0, _listView.ClientSize.Width,
_listView.Font.Height + 8));
}
private static float Ease(float p)
{
p = Clamp01(p);

511
ModernQueuePanel.cs Normal file
View File

@@ -0,0 +1,511 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace AndroidSideloader
{
// Modern download queue panel with drag-reorder, cancel buttons
// and custom scrollbar with auto-scrolling during drag
public sealed class ModernQueuePanel : Control
{
// Layout constants
private const int ItemHeight = 28, ItemMargin = 4, ItemRadius = 5;
private const int XButtonSize = 18, DragHandleWidth = 20, TextPadding = 6;
private const int ScrollbarWidth = 6, ScrollbarWidthHover = 8, ScrollbarMargin = 2;
private const int ScrollbarRadius = 3, MinThumbHeight = 20;
private const int AutoScrollZoneHeight = 30, AutoScrollSpeed = 3;
// Color palette
private static readonly Color BgColor = Color.FromArgb(24, 26, 30);
private static readonly Color ItemBg = Color.FromArgb(32, 36, 44);
private static readonly Color ItemHoverBg = Color.FromArgb(42, 46, 54);
private static readonly Color ItemDragBg = Color.FromArgb(45, 55, 70);
private static readonly Color TextColor = Color.FromArgb(210, 210, 210);
private static readonly Color TextDimColor = Color.FromArgb(140, 140, 140);
private static readonly Color AccentColor = Color.FromArgb(93, 203, 173);
private static readonly Color XButtonBg = Color.FromArgb(55, 60, 70);
private static readonly Color XButtonHoverBg = Color.FromArgb(200, 60, 60);
private static readonly Color GripColor = Color.FromArgb(70, 75, 85);
private static readonly Color ItemDragBorder = Color.FromArgb(55, 65, 80);
private static readonly Color ScrollTrackColor = Color.FromArgb(35, 38, 45);
private static readonly Color ScrollThumbColor = Color.FromArgb(70, 75, 85);
private static readonly Color ScrollThumbHoverColor = Color.FromArgb(90, 95, 105);
private static readonly Color ScrollThumbDragColor = Color.FromArgb(110, 115, 125);
private readonly List<string> _items = new List<string>();
private readonly Timer _autoScrollTimer;
// State tracking
private int _hoveredIndex = -1, _dragIndex = -1, _dropIndex = -1, _scrollOffset;
private bool _hoveringX, _scrollbarHovered, _scrollbarDragging;
private int _scrollDragStartY, _scrollDragStartOffset, _autoScrollDirection;
private Rectangle _scrollThumbRect, _scrollTrackRect;
public event EventHandler<int> ItemRemoved;
public event EventHandler<ReorderEventArgs> ItemReordered;
public ModernQueuePanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = BgColor;
_autoScrollTimer = new Timer { Interval = 16 }; // ~60 FPS
_autoScrollTimer.Tick += (s, e) => HandleAutoScroll();
}
public bool IsDownloading { get; set; }
public int Count => _items.Count;
private int ContentHeight => _items.Count * (ItemHeight + ItemMargin) + ItemMargin;
private int MaxScroll => Math.Max(0, ContentHeight - Height);
private bool ScrollbarVisible => ContentHeight > Height;
public void SetItems(IEnumerable<string> items)
{
_items.Clear();
_items.AddRange(items);
ResetState();
}
private void ResetState()
{
_hoveredIndex = _dragIndex = -1;
ClampScroll();
Invalidate();
}
private void ClampScroll() =>
_scrollOffset = Math.Max(0, Math.Min(MaxScroll, _scrollOffset));
// Auto-scroll when dragging near edges
private void HandleAutoScroll()
{
if (_dragIndex < 0 || _autoScrollDirection == 0)
{
_autoScrollTimer.Stop();
return;
}
int oldOffset = _scrollOffset;
_scrollOffset += _autoScrollDirection * AutoScrollSpeed;
ClampScroll();
if (_scrollOffset != oldOffset)
{
UpdateDropIndex(PointToClient(MousePosition).Y);
Invalidate();
}
}
private void UpdateAutoScroll(int mouseY)
{
if (_dragIndex < 0 || MaxScroll <= 0)
{
StopAutoScroll();
return;
}
_autoScrollDirection = mouseY < AutoScrollZoneHeight && _scrollOffset > 0 ? -1 :
mouseY > Height - AutoScrollZoneHeight && _scrollOffset < MaxScroll ? 1 : 0;
if (_autoScrollDirection != 0 && !_autoScrollTimer.Enabled)
_autoScrollTimer.Start();
else if (_autoScrollDirection == 0)
_autoScrollTimer.Stop();
}
private void StopAutoScroll()
{
_autoScrollDirection = 0;
_autoScrollTimer.Stop();
}
private void UpdateDropIndex(int mouseY) =>
_dropIndex = Math.Max(1, Math.Min(_items.Count, (mouseY + _scrollOffset + ItemHeight / 2) / (ItemHeight + ItemMargin)));
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(BgColor);
if (_items.Count == 0)
{
DrawEmptyState(g);
return;
}
// Draw visible items
for (int i = 0; i < _items.Count; i++)
{
var rect = GetItemRect(i);
if (rect.Bottom >= 0 && rect.Top <= Height)
DrawItem(g, i, rect);
}
// Draw drop indicator and scrollbar
if (_dragIndex >= 0 && _dropIndex >= 0 && _dropIndex != _dragIndex)
DrawDropIndicator(g);
if (ScrollbarVisible)
DrawScrollbar(g);
}
private void DrawEmptyState(Graphics g)
{
using (var brush = new SolidBrush(TextDimColor))
using (var font = new Font("Segoe UI", 8.5f, FontStyle.Italic))
{
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
g.DrawString("Queue is empty", font, brush, ClientRectangle, sf);
}
}
private void DrawItem(Graphics g, int index, Rectangle rect)
{
bool isFirst = index == 0;
bool isDragging = index == _dragIndex;
bool isHovered = !isDragging && index == _hoveredIndex;
Color bg = isDragging ? ItemDragBg : isHovered ? ItemHoverBg : ItemBg;
// Draw item background
using (var path = CreateRoundedRect(rect, ItemRadius))
using (var brush = new SolidBrush(bg))
g.FillPath(brush, path);
// Active download (first item) gets gradient accent and border
if (isFirst)
DrawFirstItemAccent(g, rect);
// Dragged items get subtle highlight border
else if (isDragging)
DrawBorder(g, rect, ItemDragBorder, 0.5f);
// Draw drag handle, text, and close button
if (!isFirst)
DrawDragHandle(g, rect);
DrawItemText(g, index, rect, isFirst);
DrawXButton(g, rect, isHovered && _hoveringX);
}
// Draw gradient accent and border for active download (first item)
private void DrawFirstItemAccent(Graphics g, Rectangle rect)
{
using (var path = CreateRoundedRect(rect, ItemRadius))
using (var gradBrush = new LinearGradientBrush(rect,
Color.FromArgb(60, AccentColor), Color.FromArgb(0, AccentColor), LinearGradientMode.Horizontal))
{
var oldClip = g.Clip;
g.SetClip(path);
g.FillRectangle(gradBrush, rect);
g.Clip = oldClip;
}
DrawBorder(g, rect, AccentColor, 1.5f);
}
private void DrawBorder(Graphics g, Rectangle rect, Color color, float width)
{
using (var path = CreateRoundedRect(rect, ItemRadius))
using (var pen = new Pen(color, width))
g.DrawPath(pen, path);
}
private void DrawDragHandle(Graphics g, Rectangle rect)
{
int cx = rect.X + 8, cy = rect.Y + rect.Height / 2;
using (var brush = new SolidBrush(GripColor))
{
for (int row = -1; row <= 1; row++)
for (int col = 0; col < 2; col++)
g.FillEllipse(brush, cx + col * 4, cy + row * 4 - 1, 2, 2);
}
}
private void DrawItemText(Graphics g, int index, Rectangle rect, bool isFirst)
{
int textLeft = isFirst ? rect.X + TextPadding : rect.X + DragHandleWidth;
int rightPad = ScrollbarVisible ? ScrollbarWidthHover + ScrollbarMargin * 2 : 0;
var textRect = new Rectangle(textLeft, rect.Y, rect.Right - XButtonSize - 6 - textLeft - rightPad, rect.Height);
using (var brush = new SolidBrush(TextColor))
using (var font = new Font("Segoe UI", isFirst ? 8.5f : 8f, isFirst ? FontStyle.Bold : FontStyle.Regular))
{
var sf = new StringFormat
{
Alignment = StringAlignment.Near,
LineAlignment = StringAlignment.Center,
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = StringFormatFlags.NoWrap
};
g.DrawString(_items[index], font, brush, textRect, sf);
}
}
private void DrawXButton(Graphics g, Rectangle itemRect, bool hovered)
{
var xRect = GetXButtonRect(itemRect);
using (var path = CreateRoundedRect(xRect, 3))
using (var brush = new SolidBrush(hovered ? XButtonHoverBg : XButtonBg))
g.FillPath(brush, path);
using (var pen = new Pen(Color.White, 1.4f) { StartCap = LineCap.Round, EndCap = LineCap.Round })
{
int p = 4;
g.DrawLine(pen, xRect.X + p, xRect.Y + p, xRect.Right - p, xRect.Bottom - p);
g.DrawLine(pen, xRect.Right - p, xRect.Y + p, xRect.X + p, xRect.Bottom - p);
}
}
private void DrawDropIndicator(Graphics g)
{
int y = (_dropIndex >= _items.Count ? _items.Count : _dropIndex) * (ItemHeight + ItemMargin) + ItemMargin / 2 - _scrollOffset;
int left = ItemMargin + 2;
int right = Width - ItemMargin - 2 - (ScrollbarVisible ? ScrollbarWidthHover + ScrollbarMargin : 0);
using (var pen = new Pen(AccentColor, 2.5f) { StartCap = LineCap.Round, EndCap = LineCap.Round })
g.DrawLine(pen, left, y, right, y);
}
// Draw custom scrollbar with hover expansion
private void DrawScrollbar(Graphics g)
{
if (MaxScroll <= 0) return;
bool expanded = _scrollbarHovered || _scrollbarDragging;
int sbWidth = expanded ? ScrollbarWidthHover : ScrollbarWidth;
int trackX = Width - ScrollbarWidth - ScrollbarMargin - (expanded ? (ScrollbarWidthHover - ScrollbarWidth) / 2 : 0);
_scrollTrackRect = new Rectangle(trackX, ScrollbarMargin, sbWidth, Height - ScrollbarMargin * 2);
using (var trackBrush = new SolidBrush(Color.FromArgb(40, ScrollTrackColor)))
using (var trackPath = CreateRoundedRect(_scrollTrackRect, ScrollbarRadius))
g.FillPath(trackBrush, trackPath);
// Calculate thumb position and size
int trackHeight = _scrollTrackRect.Height;
int thumbHeight = Math.Max(MinThumbHeight, (int)(trackHeight * ((float)Height / ContentHeight)));
float scrollRatio = MaxScroll > 0 ? (float)_scrollOffset / MaxScroll : 0;
int thumbY = ScrollbarMargin + (int)((trackHeight - thumbHeight) * scrollRatio);
_scrollThumbRect = new Rectangle(trackX, thumbY, sbWidth, thumbHeight);
Color thumbColor = _scrollbarDragging ? ScrollThumbDragColor : _scrollbarHovered ? ScrollThumbHoverColor : ScrollThumbColor;
using (var thumbBrush = new SolidBrush(thumbColor))
using (var thumbPath = CreateRoundedRect(_scrollThumbRect, ScrollbarRadius))
g.FillPath(thumbBrush, thumbPath);
}
private Rectangle GetItemRect(int index)
{
int y = index * (ItemHeight + ItemMargin) + ItemMargin - _scrollOffset;
int w = Width - ItemMargin * 2 - (ScrollbarVisible ? ScrollbarWidthHover + ScrollbarMargin + 2 : 0);
return new Rectangle(ItemMargin, y, w, ItemHeight);
}
private Rectangle GetXButtonRect(Rectangle itemRect) =>
new Rectangle(itemRect.Right - XButtonSize - 3, itemRect.Y + (itemRect.Height - XButtonSize) / 2, XButtonSize, XButtonSize);
private int HitTest(Point pt)
{
for (int i = 0; i < _items.Count; i++)
if (GetItemRect(i).Contains(pt)) return i;
return -1;
}
private bool HitTestScrollbar(Point pt) =>
ScrollbarVisible && new Rectangle(_scrollTrackRect.X - 4, _scrollTrackRect.Y, _scrollTrackRect.Width + 8, _scrollTrackRect.Height).Contains(pt);
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (_scrollbarDragging)
{
HandleScrollbarDrag(e.Y);
return;
}
// Update scrollbar hover state
bool wasHovered = _scrollbarHovered;
_scrollbarHovered = HitTestScrollbar(e.Location);
if (_scrollbarHovered != wasHovered) Invalidate();
if (_scrollbarHovered)
{
Cursor = Cursors.Default;
_hoveredIndex = -1;
_hoveringX = false;
return;
}
// Handle drag operation
if (_dragIndex >= 0)
{
UpdateAutoScroll(e.Y);
int newDrop = Math.Max(1, Math.Min(_items.Count, (e.Y + _scrollOffset + ItemHeight / 2) / (ItemHeight + ItemMargin)));
if (newDrop != _dropIndex) { _dropIndex = newDrop; Invalidate(); }
return;
}
// Update hover state
int hit = HitTest(e.Location);
bool overX = hit >= 0 && GetXButtonRect(GetItemRect(hit)).Contains(e.Location);
if (hit != _hoveredIndex || overX != _hoveringX)
{
_hoveredIndex = hit;
_hoveringX = overX;
Cursor = overX ? Cursors.Hand : hit > 0 ? Cursors.SizeNS : Cursors.Default;
Invalidate();
}
}
private void HandleScrollbarDrag(int mouseY)
{
int trackHeight = Height - ScrollbarMargin * 2;
int thumbHeight = Math.Max(MinThumbHeight, (int)(trackHeight * ((float)Height / ContentHeight)));
int scrollableHeight = trackHeight - thumbHeight;
if (scrollableHeight > 0)
{
float scrollRatio = (float)(mouseY - _scrollDragStartY) / scrollableHeight;
_scrollOffset = _scrollDragStartOffset + (int)(scrollRatio * MaxScroll);
ClampScroll();
Invalidate();
}
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (e.Button != MouseButtons.Left) return;
// Handle scrollbar thumb drag
if (ScrollbarVisible && _scrollThumbRect.Contains(e.Location))
{
_scrollbarDragging = true;
_scrollDragStartY = e.Y;
_scrollDragStartOffset = _scrollOffset;
Capture = true;
return;
}
// Handle scrollbar track click
if (ScrollbarVisible && HitTestScrollbar(e.Location))
{
_scrollOffset += e.Y < _scrollThumbRect.Top ? -Height : Height;
ClampScroll();
Invalidate();
return;
}
int hit = HitTest(e.Location);
if (hit < 0) return;
// Handle close button click
if (GetXButtonRect(GetItemRect(hit)).Contains(e.Location))
{
ItemRemoved?.Invoke(this, hit);
return;
}
// Start drag operation (only for non-first items)
if (hit > 0)
{
_dragIndex = _dropIndex = hit;
Capture = true;
Invalidate();
}
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
StopAutoScroll();
if (_scrollbarDragging)
{
_scrollbarDragging = false;
Capture = false;
Invalidate();
return;
}
// Complete drag reorder operation
if (_dragIndex > 0 && _dropIndex > 0 && _dropIndex != _dragIndex)
{
int from = _dragIndex;
int to = Math.Max(1, _dropIndex > _dragIndex ? _dropIndex - 1 : _dropIndex);
var item = _items[from];
_items.RemoveAt(from);
_items.Insert(to, item);
ItemReordered?.Invoke(this, new ReorderEventArgs(from, to));
}
_dragIndex = _dropIndex = -1;
Capture = false;
Cursor = Cursors.Default;
Invalidate();
}
protected override void OnMouseLeave(EventArgs e)
{
base.OnMouseLeave(e);
if (_dragIndex < 0 && !_scrollbarDragging)
{
_hoveredIndex = -1;
_hoveringX = _scrollbarHovered = false;
Invalidate();
}
}
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
if (MaxScroll <= 0) return;
_scrollOffset -= e.Delta / 4;
ClampScroll();
Invalidate();
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
ClampScroll();
Invalidate();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_autoScrollTimer?.Stop();
_autoScrollTimer?.Dispose();
}
base.Dispose(disposing);
}
private static GraphicsPath CreateRoundedRect(Rectangle rect, int radius)
{
var path = new GraphicsPath();
if (radius <= 0 || rect.Width <= 0 || rect.Height <= 0)
{
path.AddRectangle(rect);
return path;
}
int d = Math.Min(radius * 2, Math.Min(rect.Width, rect.Height));
path.AddArc(rect.X, rect.Y, d, d, 180, 90);
path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);
path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);
path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);
path.CloseFigure();
return path;
}
}
public class ReorderEventArgs : EventArgs
{
public int FromIndex { get; }
public int ToIndex { get; }
public ReorderEventArgs(int from, int to) { FromIndex = from; ToIndex = to; }
}
}

View File

@@ -2,11 +2,13 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace AndroidSideloader.Utilities
{
@@ -20,49 +22,161 @@ namespace AndroidSideloader.Utilities
"raw.githubusercontent.com",
"downloads.rclone.org",
"vrpirates.wiki",
"go.vrpyourself.online",
"github.com"
};
private static readonly ConcurrentDictionary<string, IPAddress> _dnsCache =
new ConcurrentDictionary<string, IPAddress>(StringComparer.OrdinalIgnoreCase);
private static bool _initialized;
private static bool _useFallbackDns;
private static readonly object _lock = new object();
// Local proxy for rclone
private static TcpListener _proxyListener;
private static CancellationTokenSource _proxyCts;
private static int _proxyPort;
private static bool _initialized;
private static bool _proxyRunning;
public static bool UseFallbackDns
{
get { if (!_initialized) Initialize(); return _useFallbackDns; }
}
public static bool UseFallbackDns { get; private set; }
// Gets the proxy URL for rclone to use, or empty string if not needed
public static string ProxyUrl => _proxyRunning ? $"http://127.0.0.1:{_proxyPort}" : string.Empty;
// Called after vrp-public.json is created/updated to test the hostname
// Enable fallback DNS if the hostname fails on system DNS but works with fallback DNS
public static void TestPublicConfigDns()
{
string hostname = GetPublicConfigHostname();
if (string.IsNullOrEmpty(hostname))
return;
lock (_lock)
{
// If already using fallback, just pre-resolve the new hostname
if (UseFallbackDns)
{
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
_dnsCache[hostname] = ip;
Logger.Log($"Pre-resolved public config hostname {hostname} -> {ip}");
}
return;
}
// Test if system DNS can resolve the public config hostname
bool systemDnsWorks = TestHostnameWithSystemDns(hostname);
if (!systemDnsWorks)
{
Logger.Log($"System DNS failed for {hostname}. Testing fallback...", LogLevel.WARNING);
// Test if fallback DNS works for this hostname
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
UseFallbackDns = true;
_dnsCache[hostname] = ip;
Logger.Log($"Enabled fallback DNS for {hostname} -> {ip}", LogLevel.INFO);
ServicePointManager.DnsRefreshTimeout = 0;
// Pre-resolve base hostnames too
PreResolveHostnames(CriticalHostnames);
// Start proxy if not already running
if (!_proxyRunning)
{
StartProxy();
}
}
else
{
Logger.Log($"Both system and fallback DNS failed for {hostname}", LogLevel.ERROR);
}
}
else
{
Logger.Log($"System DNS works for public config hostname: {hostname}");
}
}
}
private static string GetPublicConfigHostname()
{
try
{
string configPath = Path.Combine(Environment.CurrentDirectory, "vrp-public.json");
if (!File.Exists(configPath))
return null;
var config = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(configPath));
if (config != null && config.TryGetValue("baseUri", out string baseUri))
{
return ExtractHostname(baseUri);
}
}
catch (Exception ex)
{
Logger.Log($"Failed to get hostname from vrp-public.json: {ex.Message}", LogLevel.WARNING);
}
return null;
}
private static string[] GetCriticalHostnames()
{
var hostnames = new List<string>(CriticalHostnames);
string host = GetPublicConfigHostname();
if (!string.IsNullOrWhiteSpace(host) && !hostnames.Contains(host))
{
hostnames.Add(host);
Logger.Log($"Added {host} from vrp-public.json to critical hostnames");
}
return hostnames.ToArray();
}
private static string ExtractHostname(string uriString)
{
if (string.IsNullOrWhiteSpace(uriString)) return null;
if (!uriString.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!uriString.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
uriString = "https://" + uriString;
}
if (Uri.TryCreate(uriString, UriKind.Absolute, out Uri uri))
return uri.Host;
// Fallback: manual extraction
string hostname = uriString.Replace("https://", "").Replace("http://", "");
int idx = hostname.IndexOfAny(new[] { '/', ':' });
return idx > 0 ? hostname.Substring(0, idx) : hostname;
}
public static void Initialize()
{
lock (_lock)
{
if (_initialized) return;
Logger.Log("Testing DNS resolution for critical hostnames...");
if (!TestSystemDns())
Logger.Log("Testing DNS resolution for critical hostnames...");
var hostnames = GetCriticalHostnames();
if (TestDns(hostnames, useSystem: true))
{
Logger.Log("System DNS is working correctly.");
}
else
{
Logger.Log("System DNS failed. Testing Cloudflare DNS fallback...", LogLevel.WARNING);
if (TestFallbackDns())
if (TestDns(hostnames, useSystem: false))
{
_useFallbackDns = true;
UseFallbackDns = true;
Logger.Log("Using Cloudflare DNS fallback.", LogLevel.INFO);
PreResolveHostnames();
PreResolveHostnames(hostnames);
ServicePointManager.DnsRefreshTimeout = 0;
// Start local proxy for rclone
StartProxy();
}
else
@@ -70,77 +184,65 @@ namespace AndroidSideloader.Utilities
Logger.Log("Both system and fallback DNS failed.", LogLevel.ERROR);
}
}
else
{
Logger.Log("System DNS is working correctly.");
}
_initialized = true;
}
}
// Cleans up resources. Called on application exit
public static void Cleanup()
{
StopProxy();
}
public static void Cleanup() => StopProxy();
private static void PreResolveHostnames()
private static bool TestHostnameWithSystemDns(string hostname)
{
foreach (string hostname in CriticalHostnames)
try
{
try
{
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
_dnsCache[hostname] = ip;
Logger.Log($"Pre-resolved {hostname} -> {ip}");
}
}
catch (Exception ex)
{
Logger.Log($"Failed to pre-resolve {hostname}: {ex.Message}", LogLevel.WARNING);
}
var addresses = Dns.GetHostAddresses(hostname);
return addresses?.Length > 0;
}
catch
{
return false;
}
}
private static bool TestSystemDns()
private static bool TestDns(string[] hostnames, bool useSystem)
{
foreach (string hostname in CriticalHostnames)
if (useSystem)
{
try
return hostnames.All(h =>
{
var addresses = Dns.GetHostAddresses(hostname);
if (addresses == null || addresses.Length == 0) return false;
}
try { return Dns.GetHostAddresses(h)?.Length > 0; }
catch { return false; }
});
}
return FallbackDnsServers.Any(server =>
{
try { return ResolveWithDns(hostnames[0], server)?.Count > 0; }
catch { return false; }
}
return true;
});
}
private static bool TestFallbackDns()
private static void PreResolveHostnames(string[] hostnames)
{
foreach (string dnsServer in FallbackDnsServers)
foreach (string hostname in hostnames)
{
try
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
var addresses = ResolveWithDns(CriticalHostnames[0], dnsServer);
if (addresses != null && addresses.Count > 0) return true;
_dnsCache[hostname] = ip;
Logger.Log($"Pre-resolved {hostname} -> {ip}");
}
catch { }
}
return false;
}
private static IPAddress ResolveWithFallbackDns(string hostname)
{
foreach (string dnsServer in FallbackDnsServers)
foreach (string server in FallbackDnsServers)
{
try
{
var addresses = ResolveWithDns(hostname, dnsServer);
if (addresses != null && addresses.Count > 0)
return addresses[0];
var addresses = ResolveWithDns(hostname, server);
if (addresses?.Count > 0) return addresses[0];
}
catch { }
}
@@ -149,56 +251,59 @@ namespace AndroidSideloader.Utilities
private static List<IPAddress> ResolveWithDns(string hostname, string dnsServer, int timeoutMs = 5000)
{
byte[] query = BuildDnsQuery(hostname);
using (var udp = new UdpClient())
using (var udp = new UdpClient { Client = { ReceiveTimeout = timeoutMs, SendTimeout = timeoutMs } })
{
udp.Client.ReceiveTimeout = timeoutMs;
udp.Client.SendTimeout = timeoutMs;
byte[] query = BuildDnsQuery(hostname);
udp.Send(query, query.Length, new IPEndPoint(IPAddress.Parse(dnsServer), 53));
IPEndPoint remoteEp = null;
byte[] response = udp.Receive(ref remoteEp);
return ParseDnsResponse(response);
return ParseDnsResponse(udp.Receive(ref remoteEp));
}
}
private static byte[] BuildDnsQuery(string hostname)
{
var ms = new MemoryStream();
var writer = new BinaryWriter(ms);
writer.Write(IPAddress.HostToNetworkOrder((short)new Random().Next(0, ushort.MaxValue)));
writer.Write(IPAddress.HostToNetworkOrder((short)0x0100));
writer.Write(IPAddress.HostToNetworkOrder((short)1));
writer.Write(IPAddress.HostToNetworkOrder((short)0));
writer.Write(IPAddress.HostToNetworkOrder((short)0));
writer.Write(IPAddress.HostToNetworkOrder((short)0));
foreach (string label in hostname.Split('.'))
using (var ms = new MemoryStream())
using (var writer = new BinaryWriter(ms))
{
writer.Write((byte)label.Length);
writer.Write(Encoding.ASCII.GetBytes(label));
writer.Write(IPAddress.HostToNetworkOrder((short)new Random().Next(0, ushort.MaxValue)));
writer.Write(IPAddress.HostToNetworkOrder((short)0x0100)); // Flags
writer.Write(IPAddress.HostToNetworkOrder((short)1)); // Questions
writer.Write(IPAddress.HostToNetworkOrder((short)0)); // Answer RRs
writer.Write(IPAddress.HostToNetworkOrder((short)0)); // Authority RRs
writer.Write(IPAddress.HostToNetworkOrder((short)0)); // Additional RRs
foreach (string label in hostname.Split('.'))
{
writer.Write((byte)label.Length);
writer.Write(Encoding.ASCII.GetBytes(label));
}
writer.Write((byte)0);
writer.Write(IPAddress.HostToNetworkOrder((short)1)); // Type A
writer.Write(IPAddress.HostToNetworkOrder((short)1)); // Class IN
return ms.ToArray();
}
writer.Write((byte)0);
writer.Write(IPAddress.HostToNetworkOrder((short)1));
writer.Write(IPAddress.HostToNetworkOrder((short)1));
return ms.ToArray();
}
private static List<IPAddress> ParseDnsResponse(byte[] response)
{
var addresses = new List<IPAddress>();
if (response.Length < 12) return addresses;
int pos = 12;
while (pos < response.Length && response[pos] != 0) pos += response[pos] + 1;
pos += 5;
int answerCount = (response[6] << 8) | response[7];
for (int i = 0; i < answerCount && pos + 12 <= response.Length; i++)
{
if ((response[pos] & 0xC0) == 0xC0) pos += 2;
else { while (pos < response.Length && response[pos] != 0) pos += response[pos] + 1; pos++; }
pos += (response[pos] & 0xC0) == 0xC0 ? 2 : SkipName(response, pos);
if (pos + 10 > response.Length) break;
ushort type = (ushort)((response[pos] << 8) | response[pos + 1]);
pos += 8;
ushort rdLength = (ushort)((response[pos] << 8) | response[pos + 1]);
pos += 2;
ushort rdLength = (ushort)((response[pos + 8] << 8) | response[pos + 9]);
pos += 10;
if (pos + rdLength > response.Length) break;
if (type == 1 && rdLength == 4)
addresses.Add(new IPAddress(new[] { response[pos], response[pos + 1], response[pos + 2], response[pos + 3] }));
@@ -207,7 +312,73 @@ namespace AndroidSideloader.Utilities
return addresses;
}
#region Local HTTP CONNECT Proxy for rclone
private static int SkipName(byte[] data, int pos)
{
int start = pos;
while (pos < data.Length && data[pos] != 0) pos += data[pos] + 1;
return pos - start + 1;
}
public static IPAddress ResolveHostname(string hostname, bool alwaysTryFallback = false)
{
if (_dnsCache.TryGetValue(hostname, out IPAddress cached))
return cached;
try
{
var addresses = Dns.GetHostAddresses(hostname);
if (addresses?.Length > 0)
{
_dnsCache[hostname] = addresses[0];
return addresses[0];
}
}
catch { }
if (alwaysTryFallback || UseFallbackDns || !_initialized)
{
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
_dnsCache[hostname] = ip;
return ip;
}
}
return null;
}
public static HttpWebRequest CreateWebRequest(string url)
{
var uri = new Uri(url);
if (!UseFallbackDns)
{
try
{
Dns.GetHostAddresses(uri.Host);
return (HttpWebRequest)WebRequest.Create(url);
}
catch
{
if (!_initialized) Initialize();
}
}
if (UseFallbackDns)
{
var ip = ResolveHostname(uri.Host, alwaysTryFallback: true);
if (ip != null)
{
var builder = new UriBuilder(uri) { Host = ip.ToString() };
var request = (HttpWebRequest)WebRequest.Create(builder.Uri);
request.Host = uri.Host;
return request;
}
}
return (HttpWebRequest)WebRequest.Create(url);
}
private static void StartProxy()
{
@@ -246,7 +417,7 @@ namespace AndroidSideloader.Utilities
try
{
var client = await _proxyListener.AcceptTcpClientAsync();
_ = Task.Run(() => HandleProxyClient(client, ct));
_ = HandleProxyClient(client, ct);
}
catch (ObjectDisposedException) { break; }
catch (Exception ex)
@@ -264,10 +435,8 @@ namespace AndroidSideloader.Utilities
using (client)
using (var stream = client.GetStream())
{
client.ReceiveTimeout = 30000;
client.SendTimeout = 30000;
client.ReceiveTimeout = client.SendTimeout = 30000;
// Read the HTTP request
var buffer = new byte[8192];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, ct);
if (bytesRead == 0) return;
@@ -279,19 +448,12 @@ namespace AndroidSideloader.Utilities
string[] requestLine = lines[0].Split(' ');
if (requestLine.Length < 2) return;
string method = requestLine[0];
string target = requestLine[1];
if (method == "CONNECT")
{
if (requestLine[0] == "CONNECT")
// HTTPS proxy - tunnel mode
await HandleConnectRequest(stream, target, ct);
}
await HandleConnectRequest(stream, requestLine[1], ct);
else
{
// HTTP proxy - forward mode
await HandleHttpRequest(stream, request, target, ct);
}
await HandleHttpRequest(stream, request, requestLine[1], ct);
}
}
catch (Exception ex)
@@ -306,14 +468,13 @@ namespace AndroidSideloader.Utilities
// Parse host:port
string[] parts = target.Split(':');
string host = parts[0];
int port = parts.Length > 1 ? int.Parse(parts[1]) : 443;
int port = parts.Length > 1 && int.TryParse(parts[1], out int p) ? p : 443;
// Resolve hostname using our DNS
IPAddress ip = ResolveAnyHostname(host);
// Resolve hostname
IPAddress ip = ResolveHostname(host, alwaysTryFallback: true);
if (ip == null)
{
byte[] errorResponse = Encoding.ASCII.GetBytes("HTTP/1.1 502 Bad Gateway\r\n\r\n");
await clientStream.WriteAsync(errorResponse, 0, errorResponse.Length, ct);
await SendResponse(clientStream, "HTTP/1.1 502 Bad Gateway\r\n\r\n", ct);
return;
}
@@ -326,21 +487,16 @@ namespace AndroidSideloader.Utilities
using (var targetStream = targetClient.GetStream())
{
// Send 200 OK to client
byte[] okResponse = Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection Established\r\n\r\n");
await clientStream.WriteAsync(okResponse, 0, okResponse.Length, ct);
await SendResponse(clientStream, "HTTP/1.1 200 Connection Established\r\n\r\n", ct);
// Tunnel data bidirectionally
var clientToTarget = RelayData(clientStream, targetStream, ct);
var targetToClient = RelayData(targetStream, clientStream, ct);
await Task.WhenAny(clientToTarget, targetToClient);
await Task.WhenAny(RelayData(clientStream, targetStream, ct), RelayData(targetStream, clientStream, ct));
}
}
}
catch (Exception ex)
{
Logger.Log($"CONNECT tunnel error to {host}: {ex.Message}", LogLevel.WARNING);
byte[] errorResponse = Encoding.ASCII.GetBytes("HTTP/1.1 502 Bad Gateway\r\n\r\n");
try { await clientStream.WriteAsync(errorResponse, 0, errorResponse.Length, ct); } catch { }
await SendResponse(clientStream, "HTTP/1.1 502 Bad Gateway\r\n\r\n", ct);
}
}
@@ -349,19 +505,16 @@ namespace AndroidSideloader.Utilities
try
{
var uri = new Uri(url);
IPAddress ip = ResolveAnyHostname(uri.Host);
IPAddress ip = ResolveHostname(uri.Host, alwaysTryFallback: true);
if (ip == null)
{
byte[] errorResponse = Encoding.ASCII.GetBytes("HTTP/1.1 502 Bad Gateway\r\n\r\n");
await clientStream.WriteAsync(errorResponse, 0, errorResponse.Length, ct);
await SendResponse(clientStream, "HTTP/1.1 502 Bad Gateway\r\n\r\n", ct);
return;
}
int port = uri.Port > 0 ? uri.Port : 80;
using (var targetClient = new TcpClient())
{
await targetClient.ConnectAsync(ip, port);
await targetClient.ConnectAsync(ip, uri.Port > 0 ? uri.Port : 80);
using (var targetStream = targetClient.GetStream())
{
// Modify request to use relative path
@@ -380,6 +533,12 @@ namespace AndroidSideloader.Utilities
}
}
private static async Task SendResponse(NetworkStream stream, string response, CancellationToken ct)
{
byte[] bytes = Encoding.ASCII.GetBytes(response);
try { await stream.WriteAsync(bytes, 0, bytes.Length, ct); } catch { }
}
private static async Task RelayData(NetworkStream from, NetworkStream to, CancellationToken ct)
{
byte[] buffer = new byte[8192];
@@ -387,105 +546,9 @@ namespace AndroidSideloader.Utilities
{
int bytesRead;
while ((bytesRead = await from.ReadAsync(buffer, 0, buffer.Length, ct)) > 0)
{
await to.WriteAsync(buffer, 0, bytesRead, ct);
}
}
catch { }
}
#endregion
public static IPAddress ResolveHostname(string hostname)
{
if (_dnsCache.TryGetValue(hostname, out IPAddress cached))
return cached;
try
{
var addresses = Dns.GetHostAddresses(hostname);
if (addresses != null && addresses.Length > 0)
{
_dnsCache[hostname] = addresses[0];
return addresses[0];
}
}
catch { }
if (_useFallbackDns || !_initialized)
{
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
_dnsCache[hostname] = ip;
return ip;
}
}
return null;
}
public static IPAddress ResolveAnyHostname(string hostname)
{
if (_dnsCache.TryGetValue(hostname, out IPAddress cached))
return cached;
try
{
var addresses = Dns.GetHostAddresses(hostname);
if (addresses != null && addresses.Length > 0)
{
_dnsCache[hostname] = addresses[0];
return addresses[0];
}
}
catch { }
var ip = ResolveWithFallbackDns(hostname);
if (ip != null)
{
_dnsCache[hostname] = ip;
return ip;
}
return null;
}
public static HttpWebRequest CreateWebRequest(string url)
{
var uri = new Uri(url);
if (!_useFallbackDns)
{
try
{
Dns.GetHostAddresses(uri.Host);
return (HttpWebRequest)WebRequest.Create(url);
}
catch
{
if (!_initialized) Initialize();
}
}
if (_useFallbackDns)
{
var ip = ResolveHostname(uri.Host);
if (ip == null)
{
ip = ResolveAnyHostname(uri.Host);
}
if (ip != null)
{
var builder = new UriBuilder(uri) { Host = ip.ToString() };
var request = (HttpWebRequest)WebRequest.Create(builder.Uri);
request.Host = uri.Host;
return request;
}
}
return (HttpWebRequest)WebRequest.Create(url);
}
}
}

View File

@@ -138,6 +138,21 @@ namespace AndroidSideloader.Utilities
public string selectedMirror { get; set; } = string.Empty;
public bool TrailersEnabled { get; set; } = true;
public bool UseGalleryView { get; set; } = true;
// Window state persistence
public int WindowX { get; set; } = -1;
public int WindowY { get; set; } = -1;
public int WindowWidth { get; set; } = -1;
public int WindowHeight { get; set; } = -1;
public bool WindowMaximized { get; set; } = false;
// Sort state persistence
public int SortColumn { get; set; } = 0;
public bool SortAscending { get; set; } = true;
// Download queue persistence
public string[] QueuedGames { get; set; } = new string[0];
private SettingsManager()
{
Load();
@@ -263,6 +278,14 @@ namespace AndroidSideloader.Utilities
selectedMirror = string.Empty;
TrailersEnabled = true;
UseGalleryView = true;
WindowX = -1;
WindowY = -1;
WindowWidth = -1;
WindowHeight = -1;
WindowMaximized = false;
SortColumn = 0;
SortAscending = true;
QueuedGames = new string[0];
Save();
Debug.WriteLine("Default settings created.");