diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad7c35..5794800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### New features diff --git a/Resources/GitHubTemplate.png b/Resources/GitHubTemplate.png new file mode 100644 index 0000000..d045356 Binary files /dev/null and b/Resources/GitHubTemplate.png differ diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 2ae4bfb..4744fb0 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -20,6 +20,7 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME" 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/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png" echo "Packaging Firefox extension..." mkdir -p "$RESOURCES_DIR/FirefoxExtension" diff --git a/Sources/Firelink/Assets.xcassets/GitHub.imageset/Contents.json b/Sources/Firelink/Assets.xcassets/GitHub.imageset/Contents.json new file mode 100644 index 0000000..5f84abb --- /dev/null +++ b/Sources/Firelink/Assets.xcassets/GitHub.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "github-mark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Firelink/Assets.xcassets/GitHub.imageset/github-mark.png b/Sources/Firelink/Assets.xcassets/GitHub.imageset/github-mark.png new file mode 100644 index 0000000..e28a837 Binary files /dev/null and b/Sources/Firelink/Assets.xcassets/GitHub.imageset/github-mark.png differ diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index d340b59..dd30ac6 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -818,6 +818,11 @@ final class DownloadController: ObservableObject { adjusted.credentials?.password = storedPassword } + if adjusted.status == .completed && adjusted.progress != 1 { + adjusted.progress = 1 + shouldRewriteStoredDownloads = true + } + if adjusted.status == .downloading { adjusted.status = .queued adjusted.message = "Recovered after restart. Resuming from partial file." diff --git a/Sources/Firelink/DownloadPropertiesView.swift b/Sources/Firelink/DownloadPropertiesView.swift index 1fc9282..71d14f6 100644 --- a/Sources/Firelink/DownloadPropertiesView.swift +++ b/Sources/Firelink/DownloadPropertiesView.swift @@ -437,11 +437,11 @@ private struct DownloadSummaryHeader: View { .foregroundStyle(statusColor) } - ProgressView(value: item.progress) + ProgressView(value: item.status == .completed ? 1.0 : item.progress) Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) { 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("Speed", item.speedText) summary("ETA", item.etaText) diff --git a/Sources/Firelink/Settings/AboutSettingsPane.swift b/Sources/Firelink/Settings/AboutSettingsPane.swift index afae3fe..279e93c 100644 --- a/Sources/Firelink/Settings/AboutSettingsPane.swift +++ b/Sources/Firelink/Settings/AboutSettingsPane.swift @@ -21,95 +21,103 @@ struct AboutSettingsPane: View { var body: some View { Form { Section { - HStack(alignment: .center, spacing: 14) { + HStack(alignment: .center, spacing: 16) { Image(nsImage: NSApp.applicationIconImage) .resizable() - .frame(width: 56, height: 56) + .frame(width: 64, height: 64) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text("Firelink") - .font(.title2.weight(.semibold)) - Text("Version \(appVersion)") + .font(.title2.weight(.bold)) + Text("Version \(appVersion) (\(buildNumber))") .foregroundStyle(.secondary) Text("A native macOS download manager for fast, organized, segmented transfers.") .font(.caption) .foregroundStyle(.secondary) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } Section("Updates") { - LabeledContent("Status") { - Label(updateChecker.status.message, systemImage: updateStatusSymbol) - .foregroundStyle(updateStatusColor) - } - - if let lastChecked = updateChecker.lastChecked { - LabeledContent("Last checked") { - Text(lastChecked, format: .dateTime.month().day().hour().minute()) - .foregroundStyle(.secondary) - } - } - - HStack { - Button { - Task { - await updateChecker.checkForUpdates(currentVersion: appVersion) + VStack(alignment: .leading, spacing: 12) { + HStack { + Label(updateChecker.status.message, systemImage: updateStatusSymbol) + .foregroundStyle(updateStatusColor) + + Spacer() + + if let lastChecked = updateChecker.lastChecked { + Text("Last checked: \(lastChecked, format: .dateTime.month().day().hour().minute())") + .font(.caption) + .foregroundStyle(.secondary) } - } 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 { - Button { - NSWorkspace.shared.open(releaseURL) - } label: { - Label("Download Latest Version", systemImage: "square.and.arrow.down") + if case .updateAvailable(_, let releaseURL) = updateChecker.status { + Button { + NSWorkspace.shared.open(releaseURL) + } label: { + 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") { - LabeledContent("Created by") { - Text("NimBold") + Section { + VStack(alignment: .leading, spacing: 10) { + 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) } - - LabeledContent("Source") { - 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) + .font(.caption) + .padding(.vertical, 4) } } .formStyle(.grouped) diff --git a/Sources/Firelink/Settings/LocationsSettingsPane.swift b/Sources/Firelink/Settings/LocationsSettingsPane.swift index 8721558..1b0a371 100644 --- a/Sources/Firelink/Settings/LocationsSettingsPane.swift +++ b/Sources/Firelink/Settings/LocationsSettingsPane.swift @@ -7,8 +7,17 @@ struct LocationsSettingsPane: View { var body: some View { Form { Section { - ForEach(DownloadCategory.allCases, id: \.self) { category in - DirectoryPickerRow(category: category) + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { + BulkDirectoryPickerRow() + + GridRow { + Divider() + .gridCellColumns(2) + } + + ForEach(DownloadCategory.allCases, id: \.self) { category in + DirectoryPickerRow(category: category) + } } HStack { @@ -17,6 +26,8 @@ struct LocationsSettingsPane: View { settings.resetDirectories() } } + } footer: { + Text("Folders will be created automatically when applied.") } } .formStyle(.grouped) @@ -31,10 +42,14 @@ struct DirectoryPickerRow: View { @State private var message = "" var body: some View { - LabeledContent { + GridRow(alignment: .firstTextBaseline) { + Label(category.rawValue, systemImage: category.symbolName) + .gridColumnAlignment(.leading) + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { - TextField("Folder path", text: $path) + TextField("", text: $path, prompt: Text("Folder path")) + .labelsHidden() .textFieldStyle(.roundedBorder) .font(.system(.body, design: .monospaced)) .onSubmit { @@ -55,12 +70,12 @@ struct DirectoryPickerRow: View { } } - Text(message.isEmpty ? statusMessage(for: path) : message) - .font(.caption) - .foregroundStyle(message.isEmpty ? .secondary : .primary) + if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty { + Text(displayMessage) + .font(.caption) + .foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary) + } } - } label: { - Label(category.rawValue, systemImage: category.symbolName) } .onAppear { syncPathFromSettings() @@ -127,7 +142,7 @@ struct DirectoryPickerRow: View { message = "Saved." } - private func statusMessage(for path: String) -> String { + private func statusMessage(for path: String) -> String? { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) 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 FileManager.default.isWritableFile(atPath: expanded) - ? "Ready." + ? nil : "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." } }