初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -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,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