fix(security): track executable modification date for primer detection

Includes the executable's modification date in the version hash to ensure ad-hoc and local recompilations correctly trigger the keychain primer UI, preventing unexpected system prompts on boot.
This commit is contained in:
NimBold
2026-06-10 12:27:48 +03:30
parent fd90055ccd
commit efb4446739
4 changed files with 169 additions and 133 deletions
+7 -1
View File
@@ -283,7 +283,13 @@ final class AppSettings: ObservableObject {
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
let fullVersion = "\(currentVersion).\(currentBuild)"
var execHash = "unknown"
if let execPath = Bundle.main.executablePath,
let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
let modDate = attr[.modificationDate] as? Date {
execHash = String(modDate.timeIntervalSince1970)
}
let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
@@ -7,117 +7,139 @@ struct IntegrationSettingsPane: View {
@State private var showToast = false
var body: some View {
VStack(spacing: 20) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
Form {
Section {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Connect Browser Extension")
.font(.title2.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.subheadline)
VStack(alignment: .leading, spacing: 2) {
Text("Connect Browser Extension")
.font(.title3.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.subheadline)
}
Spacer()
}
Spacer()
.padding(.bottom, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
KeychainAccessCard()
Section {
KeychainAccessCard()
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
HStack(spacing: 12) {
// Step 1
CompactStepCardView(
stepNumber: 1,
title: "Copy Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
Section {
HStack(spacing: 8) {
// Step 1
CompactStepCardView(
stepNumber: 1,
title: "Copy Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
)
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 2
CompactStepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
// Step 2
CompactStepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
)
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 3
CompactStepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
}
.fixedSize(horizontal: false, vertical: true)
Divider()
// Diagnostics
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
// Step 3
CompactStepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
}
.fixedSize(horizontal: false, vertical: true)
} header: {
Text("Setup Guide")
}
.font(.footnote)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
Section {
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
.font(.footnote)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
} header: {
Text("Status")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
Spacer()
}
.padding(24)
.formStyle(.grouped)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
@@ -133,56 +155,56 @@ struct CompactStepCardView: View {
var secondaryAction: (() -> Void)? = nil
var body: some View {
VStack(spacing: 12) {
VStack(spacing: 8) {
HStack(alignment: .top) {
// Step Number Badge
ZStack {
Circle()
.fill(Color.secondary.opacity(0.1))
.frame(width: 24, height: 24)
.frame(width: 20, height: 20)
Text("\(stepNumber)")
.font(.system(.caption, design: .rounded).weight(.bold))
.font(.system(.caption2, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
Spacer()
// Icon
ZStack {
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: 6)
.fill(iconColor.opacity(0.15))
.frame(width: 32, height: 32)
.frame(width: 28, height: 28)
Image(systemName: icon)
.font(.system(size: 16))
.font(.system(size: 14))
.foregroundStyle(iconColor)
}
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text(description)
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Spacer(minLength: 4)
// Action Button
VStack(spacing: 8) {
VStack(spacing: 6) {
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.caption.weight(.medium))
.font(.caption2.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.padding(.vertical, 4)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(6)
.cornerRadius(4)
}
.buttonStyle(.plain)
}
@@ -190,14 +212,14 @@ struct CompactStepCardView: View {
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.caption.weight(.medium))
.font(.caption2.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.padding(.vertical, 4)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(6)
.cornerRadius(4)
.overlay(
RoundedRectangle(cornerRadius: 6)
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -205,15 +227,15 @@ struct CompactStepCardView: View {
}
}
}
.padding(12)
.padding(10)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 10)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -4,38 +4,39 @@ struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
HStack(spacing: 16) {
HStack(spacing: settings.isKeychainAccessGranted ? 12 : 16) {
ZStack {
RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 6 : 10)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: 36, height: 36)
.frame(width: settings.isKeychainAccessGranted ? 28 : 36, height: settings.isKeychainAccessGranted ? 28 : 36)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 18))
.font(.system(size: settings.isKeychainAccessGranted ? 14 : 18))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Keychain Access")
.font(.headline)
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(settings.isKeychainAccessGranted ? "Keychain Access Granted" : "Keychain Access")
.font(settings.isKeychainAccessGranted ? .subheadline.weight(.medium) : .headline)
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .primary)
if !settings.isKeychainAccessGranted {
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 16)
Spacer(minLength: settings.isKeychainAccessGranted ? 8 : 16)
if settings.isKeychainAccessGranted {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline.weight(.medium))
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke")
}
.controlSize(.small)
} else {
Button {
settings.grantKeychainAccess()
@@ -51,14 +52,14 @@ struct KeychainAccessCard: View {
.buttonStyle(.plain)
}
}
.padding(12)
.padding(settings.isKeychainAccessGranted ? 8 : 12)
.background(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
+7
View File
@@ -0,0 +1,7 @@
import Foundation
print(Bundle.main.infoDictionary ?? "No Info.plist")
let execPath = Bundle.main.executablePath
print("Exec path: \(execPath ?? "nil")")
if let path = execPath, let attr = try? FileManager.default.attributesOfItem(atPath: path), let modDate = attr[.modificationDate] as? Date {
print("Mod date: \(modDate)")
}