初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 核桃派镜像配置编辑器
|
||||
|
||||
`编辑器程序/` 保存 Windows 10/11 x64 可直接运行的绿色单 EXE;`编辑器源代码/` 保存 .NET 8 WinForms 源码。编辑器版本独立于核桃派软件版本,当前为 `1.0.1`,新版本发布时直接覆盖程序目录中的旧版本。
|
||||
|
||||
打开版本化 IMG 后可查看软件版本、账户名、Wi-Fi 和 IPv4 配置,密码默认遮挡。支持二次确认后修改原镜像,以及用 Windows 合法的新名称另存为定制副本;中文、空格和长路径均受支持。保存完成会生成无 BOM UTF-8 的同名 `.sha256` 并重新验证。损坏、截断、产品错误、未知 schema、配置摘要错误或现有镜像摘要不符时禁止保存。
|
||||
|
||||
1.0.1 修复了中文另存文件名被 ASCII 替换成 `?` 后无法重新打开的问题,并将主窗口、输入区域和应用内提示框改为随系统 DPI、显示器和分辨率自适应的大尺寸布局。
|
||||
|
||||
编辑器只修改 IMG 内固定的双槽配置区,不负责向 TF 卡写入镜像。写卡仍使用 Rufus 等原始磁盘写入工具。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 镜像配置编辑器 1.0.1 C# 旧版归档
|
||||
|
||||
本目录是 2026-09-08 将镜像编辑器重写为 Python/PySide6 2.0.0 前的完整快照。内含原 `.NET 8 WinForms` 源码、绿色程序、版本、摘要和原说明。后续构建、测试和镜像发布不得引用本归档。
|
||||
|
||||
- 旧编辑器版本:`1.0.1`
|
||||
- 旧 EXE SHA-256:`bcee5a87b654999b5649948bbed49b9071ebb09e5e9caef5cc8f22331ee482c3`
|
||||
- 原程序文件:`编辑器程序/核桃派镜像配置编辑器.exe`
|
||||
@@ -0,0 +1,131 @@
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
internal enum AppDialogIcon
|
||||
{
|
||||
Information,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
internal sealed class AppDialog : Form
|
||||
{
|
||||
private static readonly Size PreferredLogicalSize = new(640, 320);
|
||||
private static readonly Size MinimumLogicalSize = new(520, 260);
|
||||
private readonly Screen initialScreen;
|
||||
|
||||
private AppDialog(IWin32Window? owner, string message, string title, MessageBoxButtons buttons, AppDialogIcon icon)
|
||||
{
|
||||
initialScreen = owner is null ? Screen.PrimaryScreen! : Screen.FromHandle(owner.Handle);
|
||||
Text = title;
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
Font = new Font("Microsoft YaHei UI", 10F);
|
||||
FormBorderStyle = FormBorderStyle.Sizable;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
ShowInTaskbar = false;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
|
||||
var body = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(24),
|
||||
ColumnCount = 2,
|
||||
RowCount = 2,
|
||||
};
|
||||
body.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
|
||||
var picture = new PictureBox
|
||||
{
|
||||
Image = GetIcon(icon).ToBitmap(),
|
||||
SizeMode = PictureBoxSizeMode.CenterImage,
|
||||
Size = new Size(64, 64),
|
||||
Margin = new Padding(0, 0, 20, 0),
|
||||
Anchor = AnchorStyles.Top,
|
||||
};
|
||||
var text = new TextBox
|
||||
{
|
||||
Text = message,
|
||||
ReadOnly = true,
|
||||
Multiline = true,
|
||||
WordWrap = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = SystemColors.Window,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
Margin = new Padding(0),
|
||||
ShortcutsEnabled = true,
|
||||
};
|
||||
var actions = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
WrapContents = false,
|
||||
Margin = new Padding(0, 20, 0, 0),
|
||||
};
|
||||
|
||||
if (buttons == MessageBoxButtons.YesNo)
|
||||
{
|
||||
var no = Button("否", DialogResult.No);
|
||||
var yes = Button("是", DialogResult.Yes);
|
||||
actions.Controls.Add(no);
|
||||
actions.Controls.Add(yes);
|
||||
AcceptButton = yes;
|
||||
CancelButton = no;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ok = Button("确定", DialogResult.OK);
|
||||
actions.Controls.Add(ok);
|
||||
AcceptButton = ok;
|
||||
CancelButton = ok;
|
||||
}
|
||||
|
||||
body.Controls.Add(picture, 0, 0);
|
||||
body.Controls.Add(text, 1, 0);
|
||||
body.Controls.Add(actions, 0, 1);
|
||||
body.SetColumnSpan(actions, 2);
|
||||
Controls.Add(body);
|
||||
}
|
||||
|
||||
public static DialogResult Show(IWin32Window? owner, string message, string title, MessageBoxButtons buttons, AppDialogIcon icon)
|
||||
{
|
||||
using var dialog = new AppDialog(owner, message, title, buttons, icon);
|
||||
return owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
public static void Information(IWin32Window? owner, string message, string title) => Show(owner, message, title, MessageBoxButtons.OK, AppDialogIcon.Information);
|
||||
public static void Error(IWin32Window? owner, string message, string title) => Show(owner, message, title, MessageBoxButtons.OK, AppDialogIcon.Error);
|
||||
public static DialogResult Confirm(IWin32Window? owner, string message, string title) => Show(owner, message, title, MessageBoxButtons.YesNo, AppDialogIcon.Warning);
|
||||
|
||||
protected override void OnShown(EventArgs eventArgs)
|
||||
{
|
||||
base.OnShown(eventArgs);
|
||||
ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.8, initialScreen);
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiChangedEventArgs eventArgs)
|
||||
{
|
||||
base.OnDpiChanged(eventArgs);
|
||||
BeginInvoke(() => ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.8));
|
||||
}
|
||||
|
||||
private static Button Button(string text, DialogResult result) => new()
|
||||
{
|
||||
Text = text,
|
||||
DialogResult = result,
|
||||
AutoSize = true,
|
||||
MinimumSize = new Size(110, 38),
|
||||
Margin = new Padding(10, 0, 0, 0),
|
||||
};
|
||||
|
||||
private static Icon GetIcon(AppDialogIcon icon) => icon switch
|
||||
{
|
||||
AppDialogIcon.Error => SystemIcons.Error,
|
||||
AppDialogIcon.Warning => SystemIcons.Warning,
|
||||
_ => SystemIcons.Information,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
public sealed class Fat16Disk : IDisposable
|
||||
{
|
||||
private readonly FileStream stream;
|
||||
private readonly long fatOffset;
|
||||
private readonly long rootOffset;
|
||||
private readonly int rootEntries;
|
||||
private readonly int fatBytes;
|
||||
private readonly long dataOffset;
|
||||
private readonly int clusterBytes;
|
||||
|
||||
public Fat16Disk(string path, bool writable)
|
||||
{
|
||||
stream = new FileStream(path, writable ? FileMode.Open : FileMode.Open, writable ? FileAccess.ReadWrite : FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.RandomAccess);
|
||||
var mbr = ReadAt(0, 512);
|
||||
if (mbr[510] != 0x55 || mbr[511] != 0xAA) throw new InvalidDataException("文件没有有效的 MBR 分区表");
|
||||
var partitionLba = BinaryPrimitives.ReadUInt32LittleEndian(mbr.AsSpan(454, 4));
|
||||
var partitionSectors = BinaryPrimitives.ReadUInt32LittleEndian(mbr.AsSpan(458, 4));
|
||||
var partitionType = mbr[450];
|
||||
// WalnutPi labels this BPB-compatible FAT16 volume as 0x0C in the MBR.
|
||||
if (partitionLba == 0 || partitionSectors == 0 || partitionType is not (0x04 or 0x06 or 0x0B or 0x0C or 0x0E)) throw new InvalidDataException("镜像第一分区不是受支持的 FAT 启动分区");
|
||||
var partitionOffset = partitionLba * 512L;
|
||||
if (partitionOffset + partitionSectors * 512L > stream.Length) throw new InvalidDataException("镜像分区表超出文件边界");
|
||||
var bpb = ReadAt(partitionOffset, 512);
|
||||
var bytesPerSector = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(11, 2));
|
||||
var sectorsPerCluster = bpb[13];
|
||||
var reserved = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(14, 2));
|
||||
var fatCount = bpb[16];
|
||||
rootEntries = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(17, 2));
|
||||
var fatSectors = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(22, 2));
|
||||
if (bytesPerSector != 512 || sectorsPerCluster == 0 || fatSectors == 0 || rootEntries == 0) throw new InvalidDataException("镜像第一分区不是受支持的 FAT16");
|
||||
clusterBytes = bytesPerSector * sectorsPerCluster;
|
||||
fatBytes = fatSectors * bytesPerSector;
|
||||
fatOffset = partitionOffset + reserved * bytesPerSector;
|
||||
rootOffset = fatOffset + fatCount * fatBytes;
|
||||
var rootSectors = (rootEntries * 32L + bytesPerSector - 1) / bytesPerSector;
|
||||
dataOffset = rootOffset + rootSectors * bytesPerSector;
|
||||
}
|
||||
|
||||
public byte[] ReadFile(string name)
|
||||
{
|
||||
var entry = Find(name);
|
||||
var fat = ReadAt(fatOffset, fatBytes);
|
||||
var result = new byte[entry.Size];
|
||||
var written = 0;
|
||||
foreach (var cluster in Chain(entry.FirstCluster, fat))
|
||||
{
|
||||
var count = Math.Min(clusterBytes, result.Length - written);
|
||||
ReadAt(ClusterOffset(cluster), result.AsSpan(written, count));
|
||||
written += count;
|
||||
if (written == result.Length) break;
|
||||
}
|
||||
if (written != result.Length) throw new InvalidDataException("镜像配置文件被截断");
|
||||
return result;
|
||||
}
|
||||
|
||||
public void WriteFileRange(string name, int fileOffset, byte[] data)
|
||||
{
|
||||
var entry = Find(name);
|
||||
if (fileOffset < 0 || fileOffset + data.Length > entry.Size) throw new ArgumentOutOfRangeException(nameof(fileOffset));
|
||||
var fat = ReadAt(fatOffset, fatBytes);
|
||||
var chain = Chain(entry.FirstCluster, fat).ToArray();
|
||||
var remaining = data.Length;
|
||||
var sourceOffset = 0;
|
||||
var position = fileOffset;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var chainIndex = position / clusterBytes;
|
||||
var within = position % clusterBytes;
|
||||
if (chainIndex >= chain.Length) throw new InvalidDataException("镜像配置文件簇链不足");
|
||||
var count = Math.Min(remaining, clusterBytes - within);
|
||||
stream.Position = ClusterOffset(chain[chainIndex]) + within;
|
||||
stream.Write(data, sourceOffset, count);
|
||||
position += count; sourceOffset += count; remaining -= count;
|
||||
}
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
private (ushort FirstCluster, int Size) Find(string name)
|
||||
{
|
||||
var expected = Name83(name);
|
||||
var root = ReadAt(rootOffset, rootEntries * 32);
|
||||
for (var index = 0; index < rootEntries; index++)
|
||||
{
|
||||
var entry = root.AsSpan(index * 32, 32);
|
||||
if (entry[0] == 0x00) break;
|
||||
if (entry[0] == 0xE5 || entry[11] == 0x0F) continue;
|
||||
if (entry[..11].SequenceEqual(expected))
|
||||
{
|
||||
return (BinaryPrimitives.ReadUInt16LittleEndian(entry.Slice(26, 2)), BinaryPrimitives.ReadInt32LittleEndian(entry.Slice(28, 4)));
|
||||
}
|
||||
}
|
||||
throw new InvalidDataException($"镜像缺少 {name}");
|
||||
}
|
||||
|
||||
private IEnumerable<ushort> Chain(ushort first, byte[] fat)
|
||||
{
|
||||
var seen = new HashSet<ushort>();
|
||||
var cluster = first;
|
||||
while (cluster is >= 2 and < 0xFFF8)
|
||||
{
|
||||
if (!seen.Add(cluster)) throw new InvalidDataException("FAT16 簇链循环");
|
||||
yield return cluster;
|
||||
cluster = BinaryPrimitives.ReadUInt16LittleEndian(fat.AsSpan(cluster * 2, 2));
|
||||
}
|
||||
}
|
||||
|
||||
private long ClusterOffset(ushort cluster) => dataOffset + (cluster - 2L) * clusterBytes;
|
||||
private byte[] ReadAt(long offset, int count) { var value = new byte[count]; ReadAt(offset, value); return value; }
|
||||
private void ReadAt(long offset, Span<byte> destination) { stream.Position = offset; stream.ReadExactly(destination); }
|
||||
|
||||
private static byte[] Name83(string name)
|
||||
{
|
||||
var parts = name.ToUpperInvariant().Split('.', 2);
|
||||
if (parts[0].Length is < 1 or > 8 || (parts.Length == 2 && parts[1].Length > 3)) throw new ArgumentException("文件名不是 8.3 格式");
|
||||
return Encoding.ASCII.GetBytes(parts[0].PadRight(8) + (parts.Length == 2 ? parts[1] : "").PadRight(3));
|
||||
}
|
||||
|
||||
public void Dispose() => stream.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
public sealed class ImageConfig
|
||||
{
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; set; } = 1;
|
||||
[JsonPropertyName("product")] public string Product { get; set; } = "";
|
||||
[JsonPropertyName("software_version")] public string SoftwareVersion { get; set; } = "";
|
||||
[JsonPropertyName("account")] public AccountConfig Account { get; set; } = new();
|
||||
[JsonPropertyName("wifi")] public WifiConfig Wifi { get; set; } = new();
|
||||
[JsonPropertyName("ipv4")] public Ipv4Config Ipv4 { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AccountConfig
|
||||
{
|
||||
[JsonPropertyName("username")] public string Username { get; set; } = "";
|
||||
[JsonPropertyName("password")] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class WifiConfig
|
||||
{
|
||||
[JsonPropertyName("ssid")] public string Ssid { get; set; } = "";
|
||||
[JsonPropertyName("password")] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class Ipv4Config
|
||||
{
|
||||
[JsonPropertyName("mode")] public string Mode { get; set; } = "dhcp";
|
||||
[JsonPropertyName("address")] public string Address { get; set; } = "";
|
||||
[JsonPropertyName("prefix")] public int Prefix { get; set; }
|
||||
[JsonPropertyName("gateway")] public string Gateway { get; set; } = "";
|
||||
[JsonPropertyName("dns")] public List<string> Dns { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class ImageConfigCodec
|
||||
{
|
||||
public const int FileBytes = 64 * 1024;
|
||||
private const int HeaderBytes = 4096;
|
||||
private const int SlotBytes = (FileBytes - HeaderBytes) / 2;
|
||||
private const int SlotHeaderBytes = 52;
|
||||
private static readonly byte[] FileMagic = Encoding.ASCII.GetBytes("MSCCFG2\0");
|
||||
private static readonly byte[] SlotMagic = Encoding.ASCII.GetBytes("MSCSLOT\0");
|
||||
private static readonly Regex UsernamePattern = new("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant);
|
||||
private static readonly Regex VersionPattern = new("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", RegexOptions.CultureInvariant);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
};
|
||||
|
||||
public static ImageConfig Deserialize(string json)
|
||||
{
|
||||
var config = JsonSerializer.Deserialize<ImageConfig>(json, JsonOptions) ?? throw new InvalidDataException("配置 JSON 为空");
|
||||
Validate(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
public static string Serialize(ImageConfig config)
|
||||
{
|
||||
Validate(config);
|
||||
return JsonSerializer.Serialize(config, JsonOptions);
|
||||
}
|
||||
|
||||
public static ImageConfig Read(byte[] data) => ReadDetailed(data).Config;
|
||||
|
||||
public static (ImageConfig Config, ulong Generation, int Slot) ReadDetailed(byte[] data)
|
||||
{
|
||||
if (data.Length != FileBytes || !data.AsSpan(0, 8).SequenceEqual(FileMagic) || BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(8, 4)) != 1 || BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(12, 4)) != FileBytes)
|
||||
{
|
||||
throw new InvalidDataException("镜像配置区头部无效");
|
||||
}
|
||||
var values = new List<(ImageConfig Config, ulong Generation, int Slot)>();
|
||||
for (var slot = 0; slot < 2; slot++)
|
||||
{
|
||||
var offset = HeaderBytes + slot * SlotBytes;
|
||||
var span = data.AsSpan(offset, SlotBytes);
|
||||
if (!span[..8].SequenceEqual(SlotMagic)) continue;
|
||||
var generation = BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(8, 8));
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(16, 4));
|
||||
if (length <= 0 || length > SlotBytes - SlotHeaderBytes) continue;
|
||||
var payload = span.Slice(SlotHeaderBytes, length).ToArray();
|
||||
if (!SHA256.HashData(payload).AsSpan().SequenceEqual(span.Slice(20, 32))) continue;
|
||||
try
|
||||
{
|
||||
values.Add((Deserialize(Encoding.UTF8.GetString(payload)), generation, slot));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (values.Count == 0) throw new InvalidDataException("镜像配置区没有可恢复的有效副本");
|
||||
return values.OrderByDescending(value => value.Generation).ThenByDescending(value => value.Slot).First();
|
||||
}
|
||||
|
||||
public static (byte[] SlotData, int TargetSlot) EncodeUpdate(byte[] current, ImageConfig config)
|
||||
{
|
||||
Validate(config);
|
||||
var active = ReadDetailed(current);
|
||||
var target = 1 - active.Slot;
|
||||
var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(config, JsonOptions) + "\n");
|
||||
if (payload.Length > SlotBytes - SlotHeaderBytes) throw new InvalidDataException("配置内容过大");
|
||||
var result = new byte[SlotBytes];
|
||||
SlotMagic.CopyTo(result, 0);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(result.AsSpan(8, 8), active.Generation + 1);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(16, 4), payload.Length);
|
||||
SHA256.HashData(payload).CopyTo(result, 20);
|
||||
payload.CopyTo(result, SlotHeaderBytes);
|
||||
return (result, target);
|
||||
}
|
||||
|
||||
public static int SlotOffset(int slot) => HeaderBytes + slot * SlotBytes;
|
||||
|
||||
public static void Validate(ImageConfig config)
|
||||
{
|
||||
if (config.Account is null || config.Wifi is null || config.Ipv4 is null) throw new InvalidDataException("镜像配置缺少必要部分");
|
||||
if (config.SchemaVersion != 1 || config.Product != "matrix-screen-controller-walnutpi") throw new InvalidDataException("镜像产品或配置版本不受支持");
|
||||
if (!VersionPattern.IsMatch(config.SoftwareVersion)) throw new InvalidDataException("软件版本格式无效");
|
||||
if (!UsernamePattern.IsMatch(config.Account.Username)) throw new InvalidDataException("用户名只能使用小写字母、数字、下划线和连字符,且最长 32 个字符");
|
||||
ValidateSecret(config.Account.Password, "账户密码", 8, 128);
|
||||
ValidateText(config.Wifi.Ssid, "Wi-Fi 名称", 1, 32);
|
||||
ValidateSecret(config.Wifi.Password, "Wi-Fi 密码", 8, 63);
|
||||
if (config.Ipv4.Mode == "dhcp")
|
||||
{
|
||||
config.Ipv4.Address = ""; config.Ipv4.Prefix = 0; config.Ipv4.Gateway = ""; config.Ipv4.Dns = new();
|
||||
}
|
||||
else if (config.Ipv4.Mode == "static")
|
||||
{
|
||||
if (!IPAddress.TryParse(config.Ipv4.Address, out var address) || address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) throw new InvalidDataException("静态 IPv4 地址无效");
|
||||
if (!IPAddress.TryParse(config.Ipv4.Gateway, out var gateway) || gateway.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) throw new InvalidDataException("静态 IPv4 网关无效");
|
||||
if (config.Ipv4.Prefix is < 1 or > 32) throw new InvalidDataException("IPv4 前缀必须是 1 到 32");
|
||||
if (!SameSubnet(address, gateway, config.Ipv4.Prefix)) throw new InvalidDataException("静态 IPv4 网关必须与地址处于同一子网");
|
||||
if (config.Ipv4.Dns is null || config.Ipv4.Dns.Count is < 1 or > 4 || config.Ipv4.Dns.Any(item => !IPAddress.TryParse(item, out var parsed) || parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)) throw new InvalidDataException("DNS 必须包含 1 到 4 个 IPv4 地址");
|
||||
}
|
||||
else throw new InvalidDataException("IPv4 模式无效");
|
||||
}
|
||||
|
||||
private static void ValidateSecret(string value, string label, int minimum, int maximum)
|
||||
{
|
||||
if (value is null || value.Length < minimum || value.Length > maximum || value.IndexOfAny(['\0', '\r', '\n']) >= 0) throw new InvalidDataException($"{label}长度或字符无效");
|
||||
}
|
||||
|
||||
private static void ValidateText(string value, string label, int minimum, int maximumBytes)
|
||||
{
|
||||
if (value is null || value.Length < minimum || Encoding.UTF8.GetByteCount(value) > maximumBytes || value.IndexOfAny(['\0', '\r', '\n']) >= 0) throw new InvalidDataException($"{label}长度或字符无效");
|
||||
}
|
||||
|
||||
private static bool SameSubnet(IPAddress address, IPAddress gateway, int prefix)
|
||||
{
|
||||
var addressValue = BinaryPrimitives.ReadUInt32BigEndian(address.GetAddressBytes());
|
||||
var gatewayValue = BinaryPrimitives.ReadUInt32BigEndian(gateway.GetAddressBytes());
|
||||
var mask = prefix == 32 ? uint.MaxValue : uint.MaxValue << (32 - prefix);
|
||||
return (addressValue & mask) == (gatewayValue & mask);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
public static class ImageOperations
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBomStrict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
public static ImageConfig ReadConfig(string imagePath)
|
||||
{
|
||||
imagePath = ImagePathRules.ValidateExistingImage(imagePath);
|
||||
VerifySidecarIfPresent(imagePath);
|
||||
using var disk = new Fat16Disk(imagePath, writable: false);
|
||||
return ImageConfigCodec.Read(disk.ReadFile("MSCCFG.BIN"));
|
||||
}
|
||||
|
||||
public static void WriteConfig(string imagePath, ImageConfig config)
|
||||
{
|
||||
imagePath = ImagePathRules.ValidateExistingImage(imagePath);
|
||||
VerifySidecarIfPresent(imagePath);
|
||||
using var disk = new Fat16Disk(imagePath, writable: true);
|
||||
var current = disk.ReadFile("MSCCFG.BIN");
|
||||
if (ImageConfigCodec.Read(current).SoftwareVersion != config.SoftwareVersion) throw new InvalidDataException("禁止通过编辑器改变软件版本");
|
||||
var update = ImageConfigCodec.EncodeUpdate(current, config);
|
||||
disk.WriteFileRange("MSCCFG.BIN", ImageConfigCodec.SlotOffset(update.TargetSlot), update.SlotData);
|
||||
var verified = ImageConfigCodec.Read(disk.ReadFile("MSCCFG.BIN"));
|
||||
if (!ImageConfigCodec.Serialize(verified).Equals(ImageConfigCodec.Serialize(config), StringComparison.Ordinal)) throw new IOException("配置写后校验失败");
|
||||
}
|
||||
|
||||
public static void WriteSha256(string imagePath, IProgress<int>? progress)
|
||||
{
|
||||
imagePath = ImagePathRules.ValidateExistingImage(imagePath);
|
||||
using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4 * 1024 * 1024, FileOptions.SequentialScan);
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
var buffer = new byte[4 * 1024 * 1024];
|
||||
long completed = 0;
|
||||
int read;
|
||||
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, read);
|
||||
completed += read;
|
||||
progress?.Report((int)(completed * 100 / stream.Length));
|
||||
}
|
||||
var digest = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
progress?.Report(100);
|
||||
|
||||
var sidecar = imagePath + ".sha256";
|
||||
var temporary = sidecar + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
var bytes = Utf8NoBomStrict.GetBytes($"{digest} {Path.GetFileName(imagePath)}\n");
|
||||
using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
{
|
||||
output.Write(bytes);
|
||||
output.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temporary, sidecar, overwrite: true);
|
||||
var stored = ReadSidecarDigest(sidecar, Path.GetFileName(imagePath));
|
||||
if (!stored.Equals(digest, StringComparison.Ordinal)) throw new IOException("镜像 SHA-256 摘要写后校验失败");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifySidecarIfPresent(string imagePath)
|
||||
{
|
||||
var sidecar = imagePath + ".sha256";
|
||||
if (!File.Exists(sidecar)) return;
|
||||
var expected = ReadSidecarDigest(sidecar, Path.GetFileName(imagePath));
|
||||
using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4 * 1024 * 1024, FileOptions.SequentialScan);
|
||||
var actual = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
if (!actual.Equals(expected, StringComparison.Ordinal)) throw new InvalidDataException("镜像内容与 SHA-256 摘要不符,禁止编辑");
|
||||
}
|
||||
|
||||
private static string ReadSidecarDigest(string sidecarPath, string expectedFileName)
|
||||
{
|
||||
if (new FileInfo(sidecarPath).Length > 128 * 1024) throw new InvalidDataException("镜像 SHA-256 摘要文件过大");
|
||||
var bytes = File.ReadAllBytes(sidecarPath);
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) throw new InvalidDataException("镜像 SHA-256 摘要必须使用无 BOM UTF-8 格式");
|
||||
|
||||
string content;
|
||||
try
|
||||
{
|
||||
content = Utf8NoBomStrict.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException exception)
|
||||
{
|
||||
throw new InvalidDataException("镜像 SHA-256 摘要不是有效的 UTF-8 文本", exception);
|
||||
}
|
||||
|
||||
if (content.Length < 68 || content[^1] != '\n' || content.AsSpan(0, content.Length - 1).ContainsAny('\r', '\n'))
|
||||
{
|
||||
throw new InvalidDataException("镜像 SHA-256 摘要文件必须是以 LF 结尾的单行规范格式");
|
||||
}
|
||||
|
||||
var line = content.AsSpan(0, content.Length - 1);
|
||||
var digest = line[..64];
|
||||
if (digest.ContainsAnyExcept("0123456789abcdef") || line[64] != ' ' || line[65] != ' ')
|
||||
{
|
||||
throw new InvalidDataException("镜像 SHA-256 摘要文件格式无效");
|
||||
}
|
||||
|
||||
var storedFileName = line[66..].ToString();
|
||||
if (!storedFileName.Equals(expectedFileName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("镜像 SHA-256 摘要中的文件名与当前镜像不一致");
|
||||
}
|
||||
return digest.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
internal static partial class ImagePathRules
|
||||
{
|
||||
public static string ValidateExistingImage(string imagePath)
|
||||
{
|
||||
var fullPath = ValidateImageNameAndDirectory(imagePath);
|
||||
if (!File.Exists(fullPath)) throw new FileNotFoundException("镜像文件不存在", fullPath);
|
||||
if (Directory.Exists(fullPath + ".sha256")) throw new IOException("同名 SHA-256 摘要路径被文件夹占用");
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
public static string ValidateNewDestination(string sourcePath, string destinationPath)
|
||||
{
|
||||
var source = ValidateExistingImage(sourcePath);
|
||||
var destination = ValidateImageNameAndDirectory(destinationPath);
|
||||
if (destination.Equals(source, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("另存为必须使用与原镜像不同的文件名");
|
||||
if (File.Exists(destination) || Directory.Exists(destination)) throw new IOException("目标镜像已经存在,请使用新的文件名");
|
||||
|
||||
var sidecar = destination + ".sha256";
|
||||
if (File.Exists(sidecar) || Directory.Exists(sidecar)) throw new IOException("目标镜像的同名 SHA-256 摘要已经存在,请使用新的文件名");
|
||||
return destination;
|
||||
}
|
||||
|
||||
public static string? CleanupNewDestination(string destinationPath)
|
||||
{
|
||||
List<string>? failures = null;
|
||||
foreach (var target in new[] { destinationPath + ".sha256", destinationPath })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(target)) File.Delete(target);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failures ??= new List<string>();
|
||||
failures.Add($"{Path.GetFileName(target)}:{exception.Message}");
|
||||
}
|
||||
}
|
||||
return failures is null ? null : string.Join(";", failures);
|
||||
}
|
||||
|
||||
private static string ValidateImageNameAndDirectory(string imagePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imagePath)) throw new ArgumentException("镜像路径不能为空");
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(imagePath);
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
throw new ArgumentException("镜像路径格式无效", exception);
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("镜像文件名不能为空");
|
||||
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || fileName != fileName.TrimEnd(' ', '.'))
|
||||
{
|
||||
throw new ArgumentException("镜像文件名包含 Windows 不允许的字符,或以空格、句点结尾");
|
||||
}
|
||||
if (!Path.GetExtension(fileName).Equals(".img", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("镜像文件名必须以 .img 结尾");
|
||||
if (ReservedWindowsName().IsMatch(Path.GetFileNameWithoutExtension(fileName))) throw new ArgumentException("镜像文件名使用了 Windows 保留名称,请更换名称");
|
||||
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) throw new DirectoryNotFoundException("镜像目标文件夹不存在");
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
[GeneratedRegex("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\\..*)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ReservedWindowsName();
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
private readonly TextBox path = new() { ReadOnly = true, Dock = DockStyle.Fill };
|
||||
private readonly Label version = new() { AutoSize = true, Text = "尚未打开镜像" };
|
||||
private readonly TextBox username = new();
|
||||
private readonly TextBox accountPassword = new() { UseSystemPasswordChar = true };
|
||||
private readonly TextBox ssid = new();
|
||||
private readonly TextBox wifiPassword = new() { UseSystemPasswordChar = true };
|
||||
private readonly ComboBox mode = new() { DropDownStyle = ComboBoxStyle.DropDownList };
|
||||
private readonly TextBox address = new();
|
||||
private readonly NumericUpDown prefix = new() { Minimum = 1, Maximum = 32, Value = 24 };
|
||||
private readonly TextBox gateway = new();
|
||||
private readonly TextBox dns = new();
|
||||
private readonly Button open = new() { Text = "打开镜像…", AutoSize = true, MinimumSize = new Size(130, 38) };
|
||||
private readonly Button save = new() { Text = "修改原镜像", Enabled = false, AutoSize = true };
|
||||
private readonly Button saveAs = new() { Text = "另存为…", Enabled = false, AutoSize = true };
|
||||
private readonly ProgressBar progress = new() { Dock = DockStyle.Fill, Visible = false };
|
||||
private string? imagePath;
|
||||
private ImageConfig? loaded;
|
||||
private bool isBusy;
|
||||
private bool watchingDisplaySettings;
|
||||
|
||||
private static readonly Size PreferredLogicalSize = new(960, 760);
|
||||
private static readonly Size MinimumLogicalSize = new(760, 600);
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
var assemblyVersion = typeof(MainForm).Assembly.GetName().Version;
|
||||
Text = $"核桃派镜像配置编辑器 {assemblyVersion?.Major}.{assemblyVersion?.Minor}.{assemblyVersion?.Build}";
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
Font = new Font("Microsoft YaHei UI", 10F);
|
||||
AllowDrop = true;
|
||||
mode.Items.AddRange(["自动获取(DHCP)", "静态 IPv4"]);
|
||||
mode.SelectedIndexChanged += (_, _) => UpdateNetworkFields();
|
||||
save.Click += async (_, _) => await SaveOriginalAsync();
|
||||
saveAs.Click += async (_, _) => await SaveAsAsync();
|
||||
open.Click += (_, _) => OpenImageFromDialog();
|
||||
DragEnter += (_, eventArgs) => { if (!isBusy && eventArgs.Data?.GetDataPresent(DataFormats.FileDrop) == true) eventArgs.Effect = DragDropEffects.Copy; };
|
||||
DragDrop += (_, eventArgs) => { if (!isBusy && eventArgs.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length == 1) OpenImage(files[0]); };
|
||||
Controls.Add(BuildLayout());
|
||||
}
|
||||
|
||||
private Control BuildLayout()
|
||||
{
|
||||
var outer = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(24), ColumnCount = 1, RowCount = 6, AutoScroll = true };
|
||||
for (var index = 0; index < 6; index++) outer.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
var header = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 2, Margin = new Padding(0, 0, 0, 12) };
|
||||
header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
path.Margin = new Padding(0, 4, 12, 4);
|
||||
open.Margin = new Padding(0);
|
||||
header.Controls.Add(path, 0, 0); header.Controls.Add(open, 1, 0);
|
||||
outer.Controls.Add(header);
|
||||
version.Margin = new Padding(0, 0, 0, 12);
|
||||
outer.Controls.Add(version);
|
||||
outer.Controls.Add(Group("登录账户", [("用户名", username), ("密码", PasswordPanel(accountPassword))]));
|
||||
outer.Controls.Add(Group("Wi-Fi", [("SSID", ssid), ("密码", PasswordPanel(wifiPassword))]));
|
||||
outer.Controls.Add(Group("IPv4", [("方式", mode), ("地址", address), ("前缀", prefix), ("网关", gateway), ("DNS(逗号分隔)", dns)]));
|
||||
var footer = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 3, Margin = new Padding(0, 16, 0, 0) };
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
progress.MinimumSize = new Size(200, 28);
|
||||
save.MinimumSize = new Size(150, 40); save.Margin = new Padding(12, 0, 0, 0);
|
||||
saveAs.MinimumSize = new Size(130, 40); saveAs.Margin = new Padding(12, 0, 0, 0);
|
||||
footer.Controls.Add(progress, 0, 0); footer.Controls.Add(save, 1, 0); footer.Controls.Add(saveAs, 2, 0);
|
||||
outer.Controls.Add(footer);
|
||||
return outer;
|
||||
}
|
||||
|
||||
private static GroupBox Group(string title, (string Label, Control Control)[] rows)
|
||||
{
|
||||
var box = new GroupBox { Text = title, Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(16), Margin = new Padding(0, 0, 0, 12) };
|
||||
var table = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 2, GrowStyle = TableLayoutPanelGrowStyle.AddRows };
|
||||
table.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.Control.Dock = DockStyle.Fill;
|
||||
row.Control.MinimumSize = new Size(320, 0);
|
||||
row.Control.Margin = new Padding(12, 5, 0, 5);
|
||||
table.Controls.Add(new Label { Text = row.Label, AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 7, 12, 7) }, 0, table.RowCount);
|
||||
table.Controls.Add(row.Control, 1, table.RowCount++);
|
||||
}
|
||||
box.Controls.Add(table); return box;
|
||||
}
|
||||
|
||||
private static Control PasswordPanel(TextBox password)
|
||||
{
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 2 };
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
var show = new CheckBox { Text = "显示", AutoSize = true };
|
||||
show.CheckedChanged += (_, _) => password.UseSystemPasswordChar = !show.Checked;
|
||||
password.Dock = DockStyle.Fill;
|
||||
password.MinimumSize = new Size(260, 0);
|
||||
password.Margin = new Padding(0, 0, 12, 0);
|
||||
show.Anchor = AnchorStyles.Left;
|
||||
show.Margin = new Padding(0);
|
||||
panel.Controls.Add(password); panel.Controls.Add(show); return panel;
|
||||
}
|
||||
|
||||
private void OpenImageFromDialog()
|
||||
{
|
||||
using var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = "核桃派镜像 (*.img)|*.img",
|
||||
CheckFileExists = true,
|
||||
CheckPathExists = true,
|
||||
Multiselect = false,
|
||||
ValidateNames = true,
|
||||
};
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) OpenImage(dialog.FileName);
|
||||
}
|
||||
|
||||
private void OpenImage(string filename)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ImageOperations.ReadConfig(filename);
|
||||
ApplyLoadedConfig(Path.GetFullPath(filename), config);
|
||||
}
|
||||
catch (Exception exception) { AppDialog.Error(this, exception.Message, "无法打开镜像"); }
|
||||
}
|
||||
|
||||
private void ApplyLoadedConfig(string filename, ImageConfig config)
|
||||
{
|
||||
imagePath = filename; loaded = config; path.Text = imagePath; version.Text = $"软件版本:{config.SoftwareVersion} 配置校验:正常";
|
||||
username.Text = config.Account.Username; accountPassword.Text = config.Account.Password; ssid.Text = config.Wifi.Ssid; wifiPassword.Text = config.Wifi.Password;
|
||||
mode.SelectedIndex = config.Ipv4.Mode == "static" ? 1 : 0; address.Text = config.Ipv4.Address; prefix.Value = config.Ipv4.Prefix == 0 ? 24 : config.Ipv4.Prefix; gateway.Text = config.Ipv4.Gateway; dns.Text = string.Join(",", config.Ipv4.Dns);
|
||||
save.Enabled = saveAs.Enabled = true; UpdateNetworkFields();
|
||||
}
|
||||
|
||||
private ImageConfig Collect()
|
||||
{
|
||||
if (loaded is null) throw new InvalidOperationException("尚未打开镜像");
|
||||
var config = new ImageConfig
|
||||
{
|
||||
SchemaVersion = loaded.SchemaVersion, Product = loaded.Product, SoftwareVersion = loaded.SoftwareVersion,
|
||||
Account = new AccountConfig { Username = username.Text.Trim(), Password = accountPassword.Text },
|
||||
Wifi = new WifiConfig { Ssid = ssid.Text, Password = wifiPassword.Text },
|
||||
Ipv4 = mode.SelectedIndex == 0
|
||||
? new Ipv4Config { Mode = "dhcp" }
|
||||
: new Ipv4Config { Mode = "static", Address = address.Text.Trim(), Prefix = (int)prefix.Value, Gateway = gateway.Text.Trim(), Dns = dns.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() },
|
||||
};
|
||||
ImageConfigCodec.Validate(config); return config;
|
||||
}
|
||||
|
||||
private async Task SaveOriginalAsync()
|
||||
{
|
||||
if (imagePath is null) return;
|
||||
try
|
||||
{
|
||||
var config = Collect();
|
||||
if (AppDialog.Confirm(this, "这会直接修改当前镜像。建议日常使用“另存为”保留原始包。\n\n确定继续吗?", "确认修改原镜像") != DialogResult.Yes) return;
|
||||
var verified = await WriteAndVerifyAsync(imagePath, config);
|
||||
ApplyLoadedConfig(imagePath, verified);
|
||||
AppDialog.Information(this, "配置已保存,镜像与 SHA-256 摘要均已完成写后校验。", "保存成功");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
AppDialog.Error(this, exception.Message, "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsAsync()
|
||||
{
|
||||
if (imagePath is null) return;
|
||||
using var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = "核桃派镜像 (*.img)|*.img",
|
||||
DefaultExt = "img",
|
||||
AddExtension = true,
|
||||
ValidateNames = true,
|
||||
CheckPathExists = true,
|
||||
OverwritePrompt = false,
|
||||
FileName = Path.GetFileNameWithoutExtension(imagePath) + "-自定义.img",
|
||||
InitialDirectory = Path.GetDirectoryName(imagePath),
|
||||
Title = "请重命名,以便区分设备或使用地点",
|
||||
};
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||
|
||||
string? destination = null;
|
||||
try
|
||||
{
|
||||
var config = Collect();
|
||||
destination = ImagePathRules.ValidateNewDestination(imagePath, dialog.FileName);
|
||||
SetBusy(true);
|
||||
await Task.Run(() => File.Copy(imagePath, destination, overwrite: false));
|
||||
var verified = await WriteAndVerifyAsync(destination, config, alreadyBusy: true);
|
||||
ApplyLoadedConfig(destination, verified);
|
||||
AppDialog.Information(this, "自定义镜像已保存并重新打开,镜像与 SHA-256 摘要均已完成写后校验。", "另存为成功");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
var cleanupError = destination is null ? null : ImagePathRules.CleanupNewDestination(destination);
|
||||
var message = cleanupError is null ? exception.Message : $"{exception.Message}\n\n清理未完成:{cleanupError}";
|
||||
AppDialog.Error(this, message, "另存为失败");
|
||||
}
|
||||
finally { SetBusy(false); }
|
||||
}
|
||||
|
||||
private async Task<ImageConfig> WriteAndVerifyAsync(string target, ImageConfig config, bool alreadyBusy = false)
|
||||
{
|
||||
if (!alreadyBusy) SetBusy(true);
|
||||
try
|
||||
{
|
||||
var reporter = new Progress<int>(value => progress.Value = Math.Clamp(value, 0, 100));
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
ImageOperations.WriteConfig(target, config);
|
||||
ImageOperations.WriteSha256(target, reporter);
|
||||
return ImageOperations.ReadConfig(target);
|
||||
});
|
||||
}
|
||||
finally { if (!alreadyBusy) SetBusy(false); }
|
||||
}
|
||||
|
||||
private void SetBusy(bool value) { isBusy = value; open.Enabled = !value; save.Enabled = saveAs.Enabled = !value && loaded is not null; progress.Visible = value; progress.Value = 0; UseWaitCursor = value; }
|
||||
private void UpdateNetworkFields() { var enabled = mode.SelectedIndex == 1; address.Enabled = prefix.Enabled = gateway.Enabled = dns.Enabled = enabled; }
|
||||
|
||||
protected override void OnShown(EventArgs eventArgs)
|
||||
{
|
||||
base.OnShown(eventArgs);
|
||||
ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.9);
|
||||
if (!watchingDisplaySettings)
|
||||
{
|
||||
SystemEvents.DisplaySettingsChanged += DisplaySettingsChanged;
|
||||
watchingDisplaySettings = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiChangedEventArgs eventArgs)
|
||||
{
|
||||
base.OnDpiChanged(eventArgs);
|
||||
BeginInvoke(() => ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.9));
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs eventArgs)
|
||||
{
|
||||
if (watchingDisplaySettings)
|
||||
{
|
||||
SystemEvents.DisplaySettingsChanged -= DisplaySettingsChanged;
|
||||
watchingDisplaySettings = false;
|
||||
}
|
||||
base.OnFormClosed(eventArgs);
|
||||
}
|
||||
|
||||
private void DisplaySettingsChanged(object? sender, EventArgs eventArgs)
|
||||
{
|
||||
if (!IsDisposed && IsHandleCreated) BeginInvoke(() => ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.9));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>核桃派镜像配置编辑器</AssemblyName>
|
||||
<RootNamespace>MatrixImageEditor</RootNamespace>
|
||||
<Version>1.0.1</Version>
|
||||
<AssemblyVersion>1.0.1.0</AssemblyVersion>
|
||||
<FileVersion>1.0.1.0</FileVersion>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<RuntimeFrameworkVersion>8.0.29</RuntimeFrameworkVersion>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length > 0)
|
||||
{
|
||||
return RunCommand(args);
|
||||
}
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (Environment.UserInteractive && args.Length == 0)
|
||||
{
|
||||
AppDialog.Error(null, exception.Message, "镜像配置编辑器");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine(exception.Message);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunCommand(string[] args)
|
||||
{
|
||||
if (args is ["--validate", var image])
|
||||
{
|
||||
var config = ImageOperations.ReadConfig(image);
|
||||
var publicInfo = new
|
||||
{
|
||||
valid = true,
|
||||
config.SoftwareVersion,
|
||||
config.Account.Username,
|
||||
config.Wifi.Ssid,
|
||||
config.Ipv4.Mode,
|
||||
config.Ipv4.Address,
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(publicInfo));
|
||||
return 0;
|
||||
}
|
||||
if (args is ["--apply", var source, var configPath, var destination])
|
||||
{
|
||||
var sourcePath = ImagePathRules.ValidateExistingImage(source);
|
||||
var destinationPath = ImagePathRules.ValidateNewDestination(sourcePath, destination);
|
||||
var config = ImageConfigCodec.Deserialize(File.ReadAllText(configPath));
|
||||
_ = ImageOperations.ReadConfig(sourcePath);
|
||||
try
|
||||
{
|
||||
File.Copy(sourcePath, destinationPath, overwrite: false);
|
||||
ImageOperations.WriteConfig(destinationPath, config);
|
||||
ImageOperations.WriteSha256(destinationPath, null);
|
||||
_ = ImageOperations.ReadConfig(destinationPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
var cleanupError = ImagePathRules.CleanupNewDestination(destinationPath);
|
||||
if (cleanupError is not null) throw new IOException($"{exception.Message}\n清理失败:{cleanupError}", exception);
|
||||
throw;
|
||||
}
|
||||
Console.WriteLine(destinationPath);
|
||||
return 0;
|
||||
}
|
||||
Console.Error.WriteLine("用法:镜像配置编辑器.exe [--validate 镜像 | --apply 原镜像 配置.json 新镜像]");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 构建镜像配置编辑器
|
||||
|
||||
目标为 Windows 10/11 x64、.NET 8、自包含单文件。版本同时记录在 `VERSION` 与 `MatrixImageEditor.csproj`。
|
||||
|
||||
```powershell
|
||||
dotnet publish .\MatrixImageEditor.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true
|
||||
```
|
||||
|
||||
发布后必须用正式 IMG 覆盖读取、另存为、双槽回退、写后 SHA-256、错误产品、未知 schema、截断和错误摘要;另存名称还必须覆盖中文、空格、长路径、Windows 保留名、既有目标和孤立同名摘要。界面在 100%、150%、200% DPI 及低分辨率、1080p、4K 工作区检查输入框、滚动区和全部应用内提示框。全部通过后再将唯一 EXE、版本与摘要放入 `../编辑器程序/`。源码与程序均不得内置发布默认密码,也不得读取项目凭据文件。
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
internal static class ResponsiveLayout
|
||||
{
|
||||
public static void FitAndCenter(Form form, Size preferredLogical, Size minimumLogical, double workingAreaFraction, Screen? screen = null)
|
||||
{
|
||||
screen ??= Screen.FromControl(form);
|
||||
var workingArea = screen.WorkingArea;
|
||||
var scale = Math.Max(form.DeviceDpi, 96) / 96F;
|
||||
var preferred = Scale(preferredLogical, scale);
|
||||
var minimum = Scale(minimumLogical, scale);
|
||||
var maximum = new Size(
|
||||
Math.Max(320, (int)Math.Floor(workingArea.Width * workingAreaFraction)),
|
||||
Math.Max(240, (int)Math.Floor(workingArea.Height * workingAreaFraction)));
|
||||
|
||||
var width = Math.Min(preferred.Width, maximum.Width);
|
||||
var height = Math.Min(preferred.Height, maximum.Height);
|
||||
form.MinimumSize = new Size(Math.Min(minimum.Width, maximum.Width), Math.Min(minimum.Height, maximum.Height));
|
||||
form.Size = new Size(Math.Max(width, form.MinimumSize.Width), Math.Max(height, form.MinimumSize.Height));
|
||||
form.Location = new Point(
|
||||
workingArea.Left + Math.Max(0, (workingArea.Width - form.Width) / 2),
|
||||
workingArea.Top + Math.Max(0, (workingArea.Height - form.Height) / 2));
|
||||
}
|
||||
|
||||
private static Size Scale(Size logical, float scale) => new(
|
||||
Math.Max(1, (int)Math.Round(logical.Width * scale)),
|
||||
Math.Max(1, (int)Math.Round(logical.Height * scale)));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1.0.1
|
||||
@@ -0,0 +1,9 @@
|
||||
# 镜像配置编辑器 1.0.1
|
||||
|
||||
双击 `核桃派镜像配置编辑器.exe` 即可运行,不需要安装 .NET、写注册表或复制 DLL。支持 Windows 10/11 x64。
|
||||
|
||||
打开 IMG 后可查看账户、Wi-Fi 和 IPv4 设置;密码默认隐藏。“修改原镜像”会先二次确认,“另存为”会提示使用 Windows 合法的新名称并保留原文件。中文、空格和长路径均受支持;保存后会在 IMG 旁生成无 BOM UTF-8 的对应 `.sha256` 并重新验证。主窗口和应用内提示框会随系统缩放、显示器和分辨率自动调整。
|
||||
|
||||
1.0.1 修复了中文另存文件名导致摘要格式错误的问题,并放大、修复了密码输入框和全部应用内对话框。
|
||||
|
||||
本程序不负责写 TF 卡;完成配置后请使用 Rufus 写入 IMG。不要删除或改写正式发布 IMG,日常优先使用“另存为”。
|
||||
@@ -0,0 +1 @@
|
||||
bcee5a87b654999b5649948bbed49b9071ebb09e5e9caef5cc8f22331ee482c3 核桃派镜像配置编辑器.exe
|
||||
@@ -0,0 +1 @@
|
||||
1.0.1
|
||||
Binary file not shown.
Reference in New Issue
Block a user