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:
90
ADB.cs
90
ADB.cs
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
171
GalleryView.cs
171
GalleryView.cs
@@ -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
32
MainForm.Designer.cs
generated
@@ -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;
|
||||
|
||||
2182
MainForm.cs
2182
MainForm.cs
File diff suppressed because it is too large
Load Diff
@@ -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
511
ModernQueuePanel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user