Compare commits

...

14 Commits

Author SHA1 Message Date
nimbold a375b2e685 feat: integrate Sparkle 2 for secure in-app updates 2026-06-06 15:01:40 +03:30
nimbold 301e337c2e fix: apply proper rounded corner mask to icon 2026-06-06 14:16:53 +03:30
nimbold 986687631d style: remove black corners from icon 2026-06-06 14:14:28 +03:30
nimbold 0714f6e707 style: update app icon 2026-06-06 14:11:21 +03:30
nimbold e25022fbe5 feat(branding): update app icon icns 2026-06-06 08:23:08 +03:30
nimbold 48dfd7c6fa feat(branding): update high-resolution app icon png 2026-06-06 08:21:55 +03:30
nimbold 5e442d1691 feat(branding): update monochrome menu bar icon 2026-06-06 08:21:26 +03:30
nimbold d1fa6dd910 feat(branding): update extension submodule reference 2026-06-06 08:21:08 +03:30
nimbold 27b9004d99 fix: repair app icons 2026-06-06 07:19:14 +03:30
nimbold ac8764bb78 fix: clear completed speeds and icon frame 2026-06-05 20:48:26 +03:30
nimbold 41535eca59 feat: update app icon branding 2026-06-05 20:38:55 +03:30
nimbold 2fb851b138 fix: correct download list row selection and remove build number from version string 2026-06-05 19:46:12 +03:30
nimbold 47eb226160 ci: fix github release title using commit subject instead of tag 2026-06-05 14:05:43 +03:30
nimbold a2135bb539 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.
2026-06-05 13:56:02 +03:30
24 changed files with 384 additions and 272 deletions
+1 -1
View File
@@ -96,5 +96,5 @@ jobs:
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --notes-file release_notes.md --verify-tag
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
fi
+18
View File
@@ -5,6 +5,24 @@ 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.7] - 2026-06-06
### New features
- Replaced the basic in-app update checker with the industry-standard Sparkle 2 framework.
- Added secure, automatic in-app updates using EdDSA cryptographic signatures.
## [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
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
}
}
],
"version" : 3
}
+6
View File
@@ -10,9 +10,15 @@ let package = Package(
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
],
targets: [
.executableTarget(
name: "Firelink",
dependencies: [
.product(name: "Sparkle", package: "Sparkle")
],
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
+1
View File
@@ -50,6 +50,7 @@ Dark mode is shown by default with privacy-safe example downloads. Light mode is
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔄 **Sparkle Updates:** Secure, automatic in-app updates using EdDSA cryptographic signatures.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

+7
View File
@@ -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"
@@ -77,6 +78,12 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
<key>SUPublicEDKey</key>
<string>TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
</dict>
</plist>
PLIST
-113
View File
@@ -1,113 +0,0 @@
import Foundation
@MainActor
final class AppUpdateChecker: ObservableObject {
enum Status: Equatable {
case idle
case checking
case upToDate(String)
case updateAvailable(latestVersion: String, releaseURL: URL)
case unavailable(String)
var message: String {
switch self {
case .idle:
"Check GitHub Releases for the latest Firelink build."
case .checking:
"Checking for updates..."
case .upToDate(let version):
"Firelink is up to date. Latest version: \(version)."
case .updateAvailable(let latestVersion, _):
"Version \(latestVersion) is available."
case .unavailable(let message):
message
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var lastChecked: Date?
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdates(currentVersion: String) async {
status = .checking
lastChecked = Date()
do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
status = .unavailable("Could not read the update server response.")
return
}
guard httpResponse.statusCode == 200 else {
status = .unavailable("No published Firelink release was found.")
return
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.version
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
} else {
status = .upToDate(latestVersion)
}
} catch {
status = .unavailable("Could not check for updates. Try again later.")
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
var version: String {
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
}
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}
private enum VersionComparator {
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
let candidateParts = parts(from: candidate)
let currentParts = parts(from: current)
let count = max(candidateParts.count, currentParts.count)
for index in 0..<count {
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
let currentValue = index < currentParts.count ? currentParts[index] : 0
if candidateValue != currentValue {
return candidateValue > currentValue
}
}
return false
}
private static func parts(from version: String) -> [Int] {
version
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
.split(separator: ".")
.map { component in
let digits = component.prefix(while: \.isNumber)
return Int(digits) ?? 0
}
}
}
+14 -3
View File
@@ -126,6 +126,14 @@ final class Aria2DownloadEngine {
let rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).conf")
do {
let confContent = "rpc-secret=\(rpcSecret)\n"
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
} catch {
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
}
let process = Process()
process.executableURL = executableURL
@@ -134,7 +142,7 @@ final class Aria2DownloadEngine {
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
rpcSecret: rpcSecret
confURL: confURL
)
let inputPipe = Pipe()
@@ -168,6 +176,7 @@ final class Aria2DownloadEngine {
}
process.terminationHandler = { finishedProcess in
try? FileManager.default.removeItem(at: confURL)
completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
@@ -195,6 +204,7 @@ final class Aria2DownloadEngine {
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
try? FileManager.default.removeItem(at: confURL)
throw EngineError.launchFailed(error.localizedDescription)
}
@@ -212,6 +222,7 @@ final class Aria2DownloadEngine {
if process.isRunning {
process.terminate()
}
try? FileManager.default.removeItem(at: confURL)
}
}
@@ -325,9 +336,10 @@ final class Aria2DownloadEngine {
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
rpcPort: Int,
rpcSecret: String
confURL: URL
) throws -> [String] {
var arguments = [
"--conf-path=\(confURL.path)",
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
@@ -344,7 +356,6 @@ final class Aria2DownloadEngine {
"--input-file=-",
"--enable-rpc=true",
"--rpc-listen-port=\(rpcPort)",
"--rpc-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+15
View File
@@ -481,6 +481,7 @@ final class DownloadController: ObservableObject {
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
@@ -818,6 +819,20 @@ final class DownloadController: ObservableObject {
adjusted.credentials?.password = storedPassword
}
if adjusted.status == .completed && adjusted.progress != 1 {
adjusted.progress = 1
shouldRewriteStoredDownloads = true
}
if adjusted.status == .completed &&
(adjusted.speedText != "-" || adjusted.etaText != "-" || adjusted.connectionCount != 0 || adjusted.autoResumeOnLaunch != false) {
adjusted.speedText = "-"
adjusted.etaText = "-"
adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = false
shouldRewriteStoredDownloads = true
}
if adjusted.status == .downloading {
adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file."
@@ -437,14 +437,14 @@ 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)
summary("Speed", item.displaySpeedText)
summary("ETA", item.displayETAText)
}
GridRow {
summary("Live connections", "\(item.connectionCount)")
+6 -6
View File
@@ -77,18 +77,18 @@ struct DownloadTable: View {
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
TableColumn("Speed", value: \.displaySpeedText) { item in
doubleClickableCell(for: item) {
Text(item.speedText)
Text(item.displaySpeedText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
TableColumn("ETA", value: \.displayETAText) { item in
doubleClickableCell(for: item) {
Text(item.etaText)
Text(item.displayETAText)
.lineLimit(1)
.truncationMode(.tail)
}
@@ -164,9 +164,9 @@ struct DownloadTable: View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
.simultaneousGesture(TapGesture(count: 2).onEnded {
performPrimaryAction(for: item)
}
})
}
private func performPrimaryAction(for item: DownloadItem) {
+19 -1
View File
@@ -1,7 +1,18 @@
import SwiftUI
import Sparkle
final class SparkleUpdater: ObservableObject {
let controller: SPUStandardUpdaterController
init(controller: SPUStandardUpdaterController) {
self.controller = controller
}
}
@main
struct FirelinkApp: App {
private let updaterController: SPUStandardUpdaterController
@StateObject private var sparkleUpdater: SparkleUpdater
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@@ -11,6 +22,11 @@ struct FirelinkApp: App {
private let extensionServer: LocalExtensionServer?
init() {
// Initialize Sparkle updater
let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
self.updaterController = updaterController
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater(controller: updaterController))
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
@@ -28,6 +44,7 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.environmentObject(sparkleUpdater)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
@@ -87,11 +104,12 @@ struct FirelinkApp: App {
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
.environmentObject(sparkleUpdater)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.size = NSSize(width: 23, height: 23)
img.isTemplate = true
return img
}() {
+8
View File
@@ -189,6 +189,14 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var rpcPort: Int?
var rpcSecret: String?
var displaySpeedText: String {
status == .downloading ? speedText : "-"
}
var displayETAText: String {
status == .downloading ? etaText : "-"
}
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
}
+51 -131
View File
@@ -2,8 +2,7 @@ import AppKit
import SwiftUI
struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
@EnvironmentObject var sparkleUpdater: SparkleUpdater
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
@@ -21,15 +20,15 @@ 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))
.font(.title2.weight(.bold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
@@ -37,143 +36,64 @@ struct AboutSettingsPane: View {
.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(spacing: 12) {
Button {
sparkleUpdater.controller.checkForUpdates(nil)
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.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)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
@@ -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."
}
}
+50
View File
@@ -0,0 +1,50 @@
import sys
from PIL import Image, ImageDraw
def process_images(src_path):
img = Image.open(src_path).convert("RGBA")
width, height = img.size
# Apply a standard macOS rounded rectangle mask
# macOS standard radius is approx 22.5% of the width
radius = int(width * 0.225)
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
# Apply mask
img.putalpha(mask)
# Save standard png
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
img_1024.save("Resources/AppIcon.png")
# Save Firefox extension icons
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
img_48.save("Extensions/Firefox/icons/icon-48.png")
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
img_128.save("Extensions/Firefox/icons/icon-128.png")
# MenuBarIconTemplate (64x64 monochrome)
data = img.getdata()
new_data = []
for item in data:
r, g, b, a = item
if r > 100 and r > b * 1.5 and a > 0:
alpha = min(255, max(0, int((r - 40) * 1.2)))
new_data.append((0, 0, 0, alpha))
else:
new_data.append((0, 0, 0, 0))
menu_bar_full = Image.new("RGBA", img.size)
menu_bar_full.putdata(new_data)
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
print("Done generating main PNGs")
if __name__ == '__main__':
process_images(sys.argv[1])