feat: redesign About pane and resolve download properties UI bugs

- Added transparent GitHub icon to About pane.

- Compacted About pane layout and consolidated footer.

- Fixed build script to correctly copy bundled image assets.

- Resolved progress bar state issues in Download Properties.

- Fixed text overlap bugs in Settings.
This commit is contained in:
nimbold
2026-06-05 13:56:02 +03:30
parent c18aa6fdaf
commit a2135bb539
9 changed files with 261 additions and 79 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.6] - 2026-06-05
### New features
- Added the official transparent GitHub icon to the Source Code link in the About page.
### Changes
- Compacted the About settings pane to reduce vertical padding, placing the app identity and updates prominently at the top.
- Consolidated developer, credits, and legal links into a single unified footer section in the About pane.
### Fixes
- Fixed a build script bug that prevented bundled images (like the GitHub icon) from being copied into the final app bundle.
## [0.5.5] - 2026-06-05 ## [0.5.5] - 2026-06-05
### New features ### New features
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+1
View File
@@ -20,6 +20,7 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME" cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns" cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png" cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
echo "Packaging Firefox extension..." echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension" mkdir -p "$RESOURCES_DIR/FirefoxExtension"
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "github-mark.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -818,6 +818,11 @@ final class DownloadController: ObservableObject {
adjusted.credentials?.password = storedPassword adjusted.credentials?.password = storedPassword
} }
if adjusted.status == .completed && adjusted.progress != 1 {
adjusted.progress = 1
shouldRewriteStoredDownloads = true
}
if adjusted.status == .downloading { if adjusted.status == .downloading {
adjusted.status = .queued adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file." adjusted.message = "Recovered after restart. Resuming from partial file."
@@ -437,11 +437,11 @@ private struct DownloadSummaryHeader: View {
.foregroundStyle(statusColor) .foregroundStyle(statusColor)
} }
ProgressView(value: item.progress) ProgressView(value: item.status == .completed ? 1.0 : item.progress)
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) { Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) {
GridRow { GridRow {
summary("Progress", item.progress.formatted(.percent.precision(.fractionLength(0)))) summary("Progress", (item.status == .completed ? 1.0 : item.progress).formatted(.percent.precision(.fractionLength(0))))
summary("Size", ByteFormatter.string(item.sizeBytes)) summary("Size", ByteFormatter.string(item.sizeBytes))
summary("Speed", item.speedText) summary("Speed", item.speedText)
summary("ETA", item.etaText) summary("ETA", item.etaText)
@@ -21,95 +21,103 @@ struct AboutSettingsPane: View {
var body: some View { var body: some View {
Form { Form {
Section { Section {
HStack(alignment: .center, spacing: 14) { HStack(alignment: .center, spacing: 16) {
Image(nsImage: NSApp.applicationIconImage) Image(nsImage: NSApp.applicationIconImage)
.resizable() .resizable()
.frame(width: 56, height: 56) .frame(width: 64, height: 64)
.accessibilityHidden(true) .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Firelink") Text("Firelink")
.font(.title2.weight(.semibold)) .font(.title2.weight(.bold))
Text("Version \(appVersion)") Text("Version \(appVersion) (\(buildNumber))")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.") Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.padding(.vertical, 4) .padding(.vertical, 8)
} }
Section("Updates") { Section("Updates") {
LabeledContent("Status") { VStack(alignment: .leading, spacing: 12) {
Label(updateChecker.status.message, systemImage: updateStatusSymbol) HStack {
.foregroundStyle(updateStatusColor) Label(updateChecker.status.message, systemImage: updateStatusSymbol)
} .foregroundStyle(updateStatusColor)
if let lastChecked = updateChecker.lastChecked { Spacer()
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute()) if let lastChecked = updateChecker.lastChecked {
.foregroundStyle(.secondary) Text("Last checked: \(lastChecked, format: .dateTime.month().day().hour().minute())")
} .font(.caption)
} .foregroundStyle(.secondary)
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
} }
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
} }
Spacer() HStack(spacing: 12) {
} Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
if case .updateAvailable(_, let releaseURL) = updateChecker.status { if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button { Button {
NSWorkspace.shared.open(releaseURL) NSWorkspace.shared.open(releaseURL)
} label: { } label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down") Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
} else {
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
}
} }
.buttonStyle(.borderedProminent)
} }
.padding(.vertical, 4)
} }
Section("Developer") { Section {
LabeledContent("Created by") { VStack(alignment: .leading, spacing: 10) {
Text("NimBold") HStack {
Text("Created by NimBold")
Spacer()
Link(destination: projectURL) {
HStack(spacing: 4) {
if let imgPath = Bundle.main.path(forResource: "GitHubTemplate", ofType: "png"),
let nsImage = NSImage(contentsOfFile: imgPath) {
Image(nsImage: nsImage)
.resizable()
.renderingMode(.template)
.frame(width: 14, height: 14)
.foregroundStyle(.secondary)
}
Text("Source Code")
}
}
.buttonStyle(.plain)
}
HStack {
Text("Powered by")
Link("aria2", destination: aria2URL)
Spacer()
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. All rights reserved.")
.font(.caption2)
.foregroundStyle(.secondary)
} }
.font(.caption)
LabeledContent("Source") { .padding(.vertical, 4)
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
@@ -7,8 +7,17 @@ struct LocationsSettingsPane: View {
var body: some View { var body: some View {
Form { Form {
Section { Section {
ForEach(DownloadCategory.allCases, id: \.self) { category in Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
DirectoryPickerRow(category: category) BulkDirectoryPickerRow()
GridRow {
Divider()
.gridCellColumns(2)
}
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
} }
HStack { HStack {
@@ -17,6 +26,8 @@ struct LocationsSettingsPane: View {
settings.resetDirectories() settings.resetDirectories()
} }
} }
} footer: {
Text("Folders will be created automatically when applied.")
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
@@ -31,10 +42,14 @@ struct DirectoryPickerRow: View {
@State private var message = "" @State private var message = ""
var body: some View { var body: some View {
LabeledContent { GridRow(alignment: .firstTextBaseline) {
Label(category.rawValue, systemImage: category.symbolName)
.gridColumnAlignment(.leading)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("Folder path", text: $path) TextField("", text: $path, prompt: Text("Folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.onSubmit { .onSubmit {
@@ -55,12 +70,12 @@ struct DirectoryPickerRow: View {
} }
} }
Text(message.isEmpty ? statusMessage(for: path) : message) if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
.font(.caption) Text(displayMessage)
.foregroundStyle(message.isEmpty ? .secondary : .primary) .font(.caption)
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
}
} }
} label: {
Label(category.rawValue, systemImage: category.symbolName)
} }
.onAppear { .onAppear {
syncPathFromSettings() syncPathFromSettings()
@@ -127,7 +142,7 @@ struct DirectoryPickerRow: View {
message = "Saved." message = "Saved."
} }
private func statusMessage(for path: String) -> String { private func statusMessage(for path: String) -> String? {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "Enter a folder path." } guard !trimmed.isEmpty else { return "Enter a folder path." }
@@ -139,10 +154,136 @@ struct DirectoryPickerRow: View {
return "This path points to a file, not a folder." return "This path points to a file, not a folder."
} }
return FileManager.default.isWritableFile(atPath: expanded) return FileManager.default.isWritableFile(atPath: expanded)
? "Ready." ? nil
: "Firelink cannot write to this folder." : "Firelink cannot write to this folder."
} }
return "Folder will be created when applied." return nil
}
private func isErrorMessage(_ message: String) -> Bool {
message == "This path points to a file, not a folder." ||
message.hasPrefix("Could not create folder:") ||
message == "Firelink cannot write to this folder." ||
message == "Enter a folder path."
}
}
struct BulkDirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
@State private var path = ""
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label("All Categories", systemImage: "folder.fill.badge.plus")
.gridColumnAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Base folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.onSubmit {
applyPath()
}
Button {
applyPath()
} label: {
Label("Apply", systemImage: "checkmark")
}
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
if !message.isEmpty {
Text(message)
.font(.caption)
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
} else {
Text("Automatically creates all category folders in the selected path.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
if panel.runModal() == .OK, let url = panel.url {
path = url.path
applyPath()
}
}
private func applyPath() {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
message = "Enter a base folder path."
return
}
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
guard isDirectory.boolValue else {
message = "This path points to a file, not a folder."
return
}
} else {
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: expanded, isDirectory: true),
withIntermediateDirectories: true
)
} catch {
message = "Could not create folder: \(error.localizedDescription)"
return
}
}
guard FileManager.default.isWritableFile(atPath: expanded) else {
message = "Firelink cannot write to this folder."
return
}
for category in DownloadCategory.allCases {
let categoryPath = (expanded as NSString).appendingPathComponent(category.rawValue)
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: categoryPath, isDirectory: true),
withIntermediateDirectories: true
)
settings.setDirectory(categoryPath, for: category)
} catch {
message = "Could not create category folder \(category.rawValue): \(error.localizedDescription)"
return
}
}
message = "Created all categories in base folder."
path = ""
}
private func isErrorMessage(_ message: String) -> Bool {
message == "This path points to a file, not a folder." ||
message.hasPrefix("Could not create folder:") ||
message.hasPrefix("Could not create category folder") ||
message == "Firelink cannot write to this folder." ||
message == "Enter a base folder path."
} }
} }