Skip to content

Commit adeee39

Browse files
committed
v1.1.0 - Menu bar icon, debug logging toggle, 22 languages
Features: - Menu bar icon with status indication (white square + B letter) - Click menu bar icon to show/restore app window - Optional debug logging toggle in Settings (reduces SSD wear) - Compact Settings layout (all visible without scrolling) - 22 language support: EN, HU, DE, FR, ES, IT, JA, ZH, NL, PT, SV, DA, FI, PL, CS, SK, RO, EL, KO, AR, HE, TR Technical: - Fixed window activation for minimized/closed windows - Custom NSImage menu bar icon generation - @AppStorage for debug logging preference - Fallback translation system for unsupported languages - Inline language picker in Settings
1 parent af308c5 commit adeee39

3 files changed

Lines changed: 490 additions & 158 deletions

File tree

BC64Keys-v1.0.0.zip

1.3 MB
Binary file not shown.

Sources/BC64Keys/BC64KeysApp.swift

Lines changed: 201 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ class LaunchAtLoginManager: ObservableObject {
8181
class AppDelegate: NSObject, NSApplicationDelegate {
8282
var keyRemapper: KeyRemapper?
8383
var statusCheckTimer: Timer?
84+
var statusItem: NSStatusItem?
85+
86+
// Debug logging is now optional - only enabled if user explicitly turns it on in Settings.
87+
// This reduces SSD wear and avoids logging sensitive keystroke patterns by default.
88+
@AppStorage("bc64keys.debugLogging") var debugLoggingEnabled: Bool = false
89+
8490
// Prefer a per-user log location instead of /tmp to reduce information exposure and
8591
// avoid symlink-related file clobbering risks.
8692
private lazy var logFileURL: URL = {
@@ -95,14 +101,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
95101
let mappingManager = KeyMappingManager()
96102

97103
// Writes operational status to both stdout and a per-user log file under ~/Library/Logs.
98-
// This avoids /tmp exposure and is a more standard location for macOS app logs.
104+
// File logging is now OPTIONAL (controlled by debugLoggingEnabled toggle) to reduce SSD wear.
99105

100106
func log(_ message: String) {
101107
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
102108
let logMessage = "[\(timestamp)] \(message)\n"
109+
110+
// Always print to console for development/debugging
111+
#if DEBUG
103112
print(logMessage, terminator: "")
113+
#endif
114+
115+
// Only write to file if debug logging is explicitly enabled by user
116+
guard debugLoggingEnabled else { return }
104117

105-
// Also write to file
106118
if let data = logMessage.data(using: .utf8) {
107119
let path = logFileURL.path
108120
if FileManager.default.fileExists(atPath: path) {
@@ -118,14 +130,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
118130
}
119131

120132
func applicationDidFinishLaunching(_ notification: Notification) {
121-
// Clear old log
122-
try? FileManager.default.removeItem(at: logFileURL)
133+
// Clear old log (only if debug logging is enabled)
134+
if debugLoggingEnabled {
135+
try? FileManager.default.removeItem(at: logFileURL)
136+
}
123137

124138
log("==================================================")
125139
log("🚀 BC64Keys App Started!")
126-
log("📝 Status log: \(logFileURL.path)")
140+
if debugLoggingEnabled {
141+
log("📝 Status log: \(logFileURL.path)")
142+
}
127143
log("==================================================")
128144

145+
// Setup menu bar icon (like Karabiner Elements)
146+
setupMenuBar()
147+
129148
// Start periodic status check (1 second interval).
130149
// Reason: Accessibility permission can be granted/revoked while the app is running.
131150
statusCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
@@ -136,11 +155,109 @@ class AppDelegate: NSObject, NSApplicationDelegate {
136155
checkAndReportStatus()
137156
}
138157

158+
// MARK: - Menu Bar Icon (like Karabiner Elements)
159+
// Creates a status bar icon that shows remapper state and allows quick access to the app window.
160+
func setupMenuBar() {
161+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
162+
163+
if let button = statusItem?.button {
164+
// Create simple square icon with "B" letter
165+
let icon = createMenuBarIcon()
166+
icon.isTemplate = true // Makes it white in menu bar
167+
button.image = icon
168+
button.action = #selector(statusItemClicked)
169+
button.target = self
170+
}
171+
172+
updateMenuBarIcon()
173+
}
174+
175+
// Create custom menu bar icon - simple square with "B" letter
176+
private func createMenuBarIcon() -> NSImage {
177+
let size = NSSize(width: 18, height: 18)
178+
let image = NSImage(size: size)
179+
180+
image.lockFocus()
181+
182+
// Draw rounded square background
183+
let rect = NSRect(x: 2, y: 2, width: 14, height: 14)
184+
let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
185+
NSColor.white.setStroke()
186+
path.lineWidth = 1.5
187+
path.stroke()
188+
189+
// Draw "B" letter in center
190+
let attrs: [NSAttributedString.Key: Any] = [
191+
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
192+
.foregroundColor: NSColor.white
193+
]
194+
let letter = "B"
195+
let letterSize = letter.size(withAttributes: attrs)
196+
let letterRect = NSRect(
197+
x: (size.width - letterSize.width) / 2,
198+
y: (size.height - letterSize.height) / 2 - 1,
199+
width: letterSize.width,
200+
height: letterSize.height
201+
)
202+
letter.draw(in: letterRect, withAttributes: attrs)
203+
204+
image.unlockFocus()
205+
return image
206+
}
207+
208+
// Update menu bar icon based on remapper state (no color change needed with template)
209+
func updateMenuBarIcon() {
210+
// Template icons automatically match menu bar appearance (white/dark)
211+
// Status is shown by the icon itself being present
212+
}
213+
214+
// When status item is clicked, bring the app window to foreground
215+
// Simplified approach that works consistently with SwiftUI WindowGroup
216+
@objc func statusItemClicked() {
217+
// Activate app first
218+
NSApp.activate(ignoringOtherApps: true)
219+
220+
// Try to show ALL windows (brute force approach - most reliable)
221+
var didShowWindow = false
222+
for window in NSApp.windows {
223+
// Handle minimized windows
224+
if window.isMiniaturized {
225+
window.deminiaturize(nil)
226+
didShowWindow = true
227+
}
228+
229+
// Bring all non-panel windows to front
230+
if !window.className.contains("Panel") && !window.className.contains("Alert") {
231+
window.makeKeyAndOrderFront(nil)
232+
window.orderFrontRegardless()
233+
didShowWindow = true
234+
}
235+
}
236+
237+
// If no windows were shown, the user closed them all
238+
// Force app to recreate main window by toggling activation policy
239+
if !didShowWindow || NSApp.windows.isEmpty {
240+
NSApp.unhide(nil)
241+
NSApp.setActivationPolicy(.regular)
242+
243+
// Give SwiftUI time to recreate the window
244+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
245+
NSApp.activate(ignoringOtherApps: true)
246+
// Show any new windows that appeared
247+
for window in NSApp.windows {
248+
window.makeKeyAndOrderFront(nil)
249+
window.orderFrontRegardless()
250+
}
251+
}
252+
}
253+
}
254+
139255
func checkAndReportStatus() {
140256
let hasAccessibility = AXIsProcessTrusted()
141257

142-
// Update UI
258+
// Update UI and menu bar icon
143259
statusManager.update(accessibility: hasAccessibility, remapperRunning: keyRemapper != nil)
260+
updateMenuBarIcon()
144261

145262
log("")
146263
log("⏰ STATUS CHECK:")
@@ -676,6 +793,7 @@ struct SettingsView: View {
676793
@ObservedObject var l10n = L10n.shared
677794
@StateObject private var launchManager = LaunchAtLoginManager()
678795
@AppStorage("selectedLanguage") private var selectedLanguageRaw: String = AppLanguage.system.rawValue
796+
@AppStorage("bc64keys.debugLogging") private var debugLoggingEnabled: Bool = false
679797

680798
private var selectedLanguage: AppLanguage {
681799
get { AppLanguage(rawValue: selectedLanguageRaw) ?? .system }
@@ -687,123 +805,101 @@ struct SettingsView: View {
687805
// Header
688806
HStack {
689807
Text(L10n.current.settingsTitle)
690-
.font(.title)
808+
.font(.title2)
691809
.fontWeight(.bold)
692810

693811
Spacer()
694812
}
695-
.padding()
813+
.padding(.horizontal, 16)
814+
.padding(.vertical, 12)
696815
.background(Color(NSColor.controlBackgroundColor))
697816

698817
Divider()
699818

700-
// Settings Content
701-
ScrollView {
702-
VStack(alignment: .leading, spacing: 24) {
703-
// Launch at Login Section
704-
VStack(alignment: .leading, spacing: 12) {
705-
Label(L10n.current.launchAtLogin, systemImage: "power")
706-
.font(.headline)
707-
708-
Toggle(isOn: Binding(
709-
get: { launchManager.isEnabled },
710-
set: { _ in launchManager.toggle() }
711-
)) {
712-
Text(L10n.current.launchAtLoginDescription)
713-
}
714-
.toggleStyle(.switch)
715-
716-
Text(L10n.current.launchAtLoginHint)
717-
.font(.caption)
718-
.foregroundColor(.secondary)
719-
}
720-
.padding()
721-
.background(Color(NSColor.controlBackgroundColor))
722-
.cornerRadius(12)
819+
// Settings Content - Compact but readable
820+
VStack(alignment: .leading, spacing: 16) {
821+
// Launch at Login + Language - Single row
822+
HStack(spacing: 20) {
823+
// Launch at Login
824+
Label(L10n.current.launchAtLogin, systemImage: "power")
825+
.font(.headline)
723826

724-
// Language Section
725-
VStack(alignment: .leading, spacing: 12) {
726-
Label(L10n.current.language, systemImage: "globe")
727-
.font(.headline)
728-
729-
Picker("", selection: Binding(
730-
get: { selectedLanguage },
731-
set: { newValue in
732-
selectedLanguageRaw = newValue.rawValue
733-
L10n.shared.setLanguage(newValue)
734-
}
735-
)) {
736-
ForEach(AppLanguage.allCases, id: \.self) { lang in
737-
Text(lang.displayName).tag(lang)
738-
}
827+
Toggle("", isOn: Binding(
828+
get: { launchManager.isEnabled },
829+
set: { _ in launchManager.toggle() }
830+
))
831+
.toggleStyle(.switch)
832+
.labelsHidden()
833+
834+
Spacer()
835+
836+
// Language
837+
Label(L10n.current.language, systemImage: "globe")
838+
.font(.headline)
839+
840+
Picker("", selection: Binding(
841+
get: { selectedLanguage },
842+
set: { newValue in
843+
selectedLanguageRaw = newValue.rawValue
844+
L10n.shared.setLanguage(newValue)
845+
}
846+
)) {
847+
ForEach(AppLanguage.allCases, id: \.self) { lang in
848+
Text(lang.displayName).tag(lang)
739849
}
740-
.pickerStyle(.segmented)
741-
.frame(maxWidth: 400)
742-
743-
Text(L10n.current.languageHint)
744-
.font(.caption)
745-
.foregroundColor(.secondary)
746850
}
747-
.padding()
748-
.background(Color(NSColor.controlBackgroundColor))
749-
.cornerRadius(12)
750-
751-
// Support Section
752-
VStack(alignment: .leading, spacing: 12) {
753-
Label(L10n.current.support, systemImage: "heart.fill")
754-
.font(.headline)
755-
.foregroundColor(.pink)
756-
757-
Text(L10n.current.supportDescription)
758-
.font(.caption)
759-
.foregroundColor(.secondary)
760-
761-
Button(action: {
762-
if let url = URL(string: "https://buymeacoffee.com/badcode64") {
763-
NSWorkspace.shared.open(url)
764-
}
765-
}) {
766-
HStack {
767-
Image(systemName: "cup.and.saucer.fill")
768-
Text(L10n.current.supportButton)
769-
}
770-
.frame(maxWidth: .infinity)
771-
.padding(.vertical, 8)
851+
.pickerStyle(.menu)
852+
.frame(width: 150)
853+
}
854+
.padding(12)
855+
.background(Color(NSColor.controlBackgroundColor))
856+
.cornerRadius(8)
857+
858+
// Support Section - Centered, prominent, no extra text
859+
VStack(spacing: 12) {
860+
Button(action: {
861+
if let url = URL(string: "https://buymeacoffee.com/badcode64") {
862+
NSWorkspace.shared.open(url)
772863
}
773-
.buttonStyle(.borderedProminent)
774-
.tint(.pink)
864+
}) {
865+
HStack(spacing: 8) {
866+
Image(systemName: "cup.and.saucer.fill")
867+
Text(L10n.current.supportButton)
868+
}
869+
.font(.body)
870+
.frame(maxWidth: 300)
871+
.padding(.vertical, 10)
775872
}
776-
.padding()
777-
.background(Color(NSColor.controlBackgroundColor))
778-
.cornerRadius(12)
873+
.buttonStyle(.borderedProminent)
874+
.tint(.pink)
875+
}
876+
.frame(maxWidth: .infinity)
877+
.padding(12)
878+
.background(Color(NSColor.controlBackgroundColor))
879+
.cornerRadius(8)
880+
881+
Spacer()
882+
883+
// Debug Logging Section - At the bottom (rarely used)
884+
VStack(alignment: .leading, spacing: 8) {
885+
Label(L10n.current.debugLogging, systemImage: "ladybug.fill")
886+
.font(.headline)
887+
.foregroundColor(.orange)
779888

780-
// About Section
781-
VStack(alignment: .leading, spacing: 12) {
782-
Label(L10n.current.about, systemImage: "info.circle")
783-
.font(.headline)
784-
785-
VStack(alignment: .leading, spacing: 8) {
786-
HStack {
787-
Text("BC64Keys")
788-
.fontWeight(.semibold)
789-
Spacer()
790-
Text("v1.0")
791-
.foregroundColor(.secondary)
792-
}
793-
794-
Text(L10n.current.aboutDescription)
795-
.font(.caption)
796-
.foregroundColor(.secondary)
797-
}
889+
Toggle(isOn: $debugLoggingEnabled) {
890+
Text(L10n.current.debugLoggingDescription)
798891
}
799-
.padding()
800-
.background(Color(NSColor.controlBackgroundColor))
801-
.cornerRadius(12)
892+
.toggleStyle(.switch)
802893

803-
Spacer()
894+
Text(L10n.current.debugLoggingHint)
895+
.font(.caption)
896+
.foregroundColor(.secondary)
804897
}
805-
.padding()
898+
.padding(12)
899+
.background(Color(NSColor.controlBackgroundColor))
900+
.cornerRadius(8)
806901
}
902+
.padding(16)
807903
}
808904
}
809905
}

0 commit comments

Comments
 (0)