diff --git a/Models/AgentKeyAssignment.swift b/Models/AgentKeyAssignment.swift index 61db4e9..3de11ea 100644 --- a/Models/AgentKeyAssignment.swift +++ b/Models/AgentKeyAssignment.swift @@ -48,7 +48,7 @@ struct CodexThreadDescriptor: Identifiable, Codable, Hashable, Sendable { if !title.isEmpty { return title } if !preview.isEmpty { return preview } let shortID = String(id.prefix(8)) - return isSubagent ? "Subagent · \(shortID)" : "Sitzung · \(shortID)" + return isSubagent ? "Subagent · \(shortID)" : AppLanguage.text("Sitzung · \(shortID)", "Session · \(shortID)") } var projectName: String? { diff --git a/Models/AppLanguage.swift b/Models/AppLanguage.swift index 3102da1..5531d4f 100644 --- a/Models/AppLanguage.swift +++ b/Models/AppLanguage.swift @@ -37,4 +37,25 @@ enum AppLanguage: String, CaseIterable, Identifiable { static func text(_ german: String, _ english: String) -> String { current.text(german, english) } + + /// Looks a German source string up in the selected language's + /// `Localizable.strings`, the same table SwiftUI's `Text("…")` reads. + /// + /// Needed wherever a translation has to be produced as a plain `String` at + /// runtime — catalog data decoded from JSON, `NSMenu` titles, values fed + /// into `Text(verbatim:)` — because those never pass through + /// `LocalizedStringKey`. Falls back to the German source when no entry + /// exists, so an untranslated string still renders. + static func localized(_ german: String) -> String { + current.localized(german) + } + + func localized(_ german: String) -> String { + guard self != .german else { return german } + guard + let path = Bundle.module.path(forResource: rawValue, ofType: "lproj"), + let bundle = Bundle(path: path) + else { return german } + return bundle.localizedString(forKey: german, value: german, table: nil) + } } diff --git a/Models/CodexActionCatalog.swift b/Models/CodexActionCatalog.swift index dbb3665..39731f8 100644 --- a/Models/CodexActionCatalog.swift +++ b/Models/CodexActionCatalog.swift @@ -70,11 +70,35 @@ struct CodexActionCatalog { self.document = document } - var actions: [CodexActionDefinition] { document.actions } + /// The catalog JSON ships its user-facing text in German (see + /// `Resources/CodexActions.json` and `Resources/ClaudeActions.json`). This + /// is the single place that text is translated: every read path — + /// `actions`, `categories`, `action(id:)` and the `KeyboardAction`s derived + /// from them — goes through here, so anything that groups, sorts or + /// filters by `category` keeps comparing like with like. + /// + /// Only the four display fields are touched; `id`, `icon`, `shortcut`, + /// `deviceMacro`, `deepLink`, `modifiers`, `execution` and `compatibleWith` + /// stay exactly as decoded. Translating on read rather than once at decode + /// time means switching the app language takes effect immediately, without + /// rebuilding the catalog. + private static func localized(_ action: CodexActionDefinition) -> CodexActionDefinition { + var localized = action + localized.title = AppLanguage.localized(action.title) + localized.description = AppLanguage.localized(action.description) + localized.category = AppLanguage.localized(action.category) + localized.availabilityNote = action.availabilityNote.map(AppLanguage.localized) + // Most shortcuts are pure key symbols, but a few spell out a gesture + // ("⌘F17 halten"), so they need the same treatment. + localized.shortcut = action.shortcut.map(AppLanguage.localized) + return localized + } + + var actions: [CodexActionDefinition] { document.actions.map(Self.localized) } var categories: [String] { Array(Set(actions.map(\.category))).sorted() } func action(id: String) -> CodexActionDefinition? { - actions.first(where: { $0.id == id }) + document.actions.first(where: { $0.id == id }).map(Self.localized) } func keyboardAction(id: String) -> KeyboardAction? { diff --git a/Models/DeviceModels.swift b/Models/DeviceModels.swift index ada7fd4..effd456 100644 --- a/Models/DeviceModels.swift +++ b/Models/DeviceModels.swift @@ -67,7 +67,10 @@ struct USBInterface: Codable, Hashable, Identifiable { var endpoints: Int var summary: String { - "Interface \(number) · Klasse \(interfaceClass)/\(subclass)/\(protocolCode) · \(endpoints) Endpoint\(endpoints == 1 ? "" : "s")" + AppLanguage.text( + "Interface \(number) · Klasse \(interfaceClass)/\(subclass)/\(protocolCode) · \(endpoints) Endpoint\(endpoints == 1 ? "" : "s")", + "Interface \(number) · class \(interfaceClass)/\(subclass)/\(protocolCode) · \(endpoints) endpoint\(endpoints == 1 ? "" : "s")" + ) } } diff --git a/Models/KeyboardAction.swift b/Models/KeyboardAction.swift index 214ee94..974f0e5 100644 --- a/Models/KeyboardAction.swift +++ b/Models/KeyboardAction.swift @@ -160,8 +160,11 @@ struct KeyboardAction: Codable, Hashable, Identifiable { /// Labels for built-in actions are stored in profiles for backwards /// compatibility, but must follow the current app language at display - /// time. Custom action labels deliberately remain exactly as the user - /// entered them. + /// time. The stored `label` is therefore never rewritten — every kind that + /// can be reconstructed from other fields is rebuilt here, and anything + /// else (catalog titles, the factory `macOS` profile's German labels, the + /// `Pet anzeigen` migration) is looked up in the strings table. A label the + /// user typed themselves has no entry there and passes through untouched. var displayLabel: String { return switch kind { case .disabled: @@ -173,7 +176,7 @@ struct KeyboardAction: Codable, Hashable, Identifiable { case .textSubmission: submittedText.map { AppLanguage.text("„\($0)“ absenden", "Submit “\($0)”") } ?? label default: - label + AppLanguage.localized(label) } } diff --git a/Models/MacropadProfile.swift b/Models/MacropadProfile.swift index 8971156..1b1728a 100644 --- a/Models/MacropadProfile.swift +++ b/Models/MacropadProfile.swift @@ -835,7 +835,13 @@ enum ProfileFactory { action: KeyboardAction(kind: .singleKey, label: key.uppercased(), icon: "keyboard", deviceMacro: key) ) } - return MacropadProfile(name: "Sichere F13–F21-Belegung", controls: controls) + // Generated fresh on each call and never matched against (only + // "Codex", "Claude" and "Codex · Reasoning triggers" are looked up by + // name), so this one is safe to localize at creation time. + return MacropadProfile( + name: AppLanguage.text("Sichere F13–F21-Belegung", "Safe F13–F21 mapping"), + controls: controls + ) } static func macOS() -> MacropadProfile { diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 8b108e0..92f93f6 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -144,3 +144,236 @@ "Nur für Tastatur-/Codex-Shortcuts verfügbar. Diese Tippen-Aktion kann die App nicht als Halten-Zweitaktion nachbilden." = "Available only for keyboard and Codex shortcuts. The app cannot reproduce this tap action as a secondary hold action."; "Beim Halten nimmt Agent Micro zuerst eine kopierte Sitzungs-ID. Ist keine passende ID in der Zwischenablage, wird der zuletzt aktive, noch freie Chat verwendet." = "When held, Agent Micro first uses a copied session ID. If the clipboard contains no matching ID, it uses the most recently active unassigned chat."; "Tippen/Halten sendet die Aktionen selbst. Erlaube Agent Micro die Bedienungshilfen, sonst bleibt die Zweitbelegung wirkungslos." = "Tap/hold sends the actions itself. Grant Agent Micro Accessibility permission or the secondary assignment will have no effect."; + +/* Added by the full bilingual sweep: view labels, model/service status + text and the German values carried in the action catalogue JSON. */ +"Diagnose öffnen" = "Open Diagnostics"; +"Einstellungen öffnen" = "Open Settings"; +"Layer verwalten" = "Manage layers"; +"Agent Micro öffnen" = "Open Agent Micro"; +"Beenden" = "Quit"; +"Shortcut durchsuchen" = "Search shortcuts"; +"Anderen Thread zuweisen" = "Assign a different thread"; +"Profile" = "Profiles"; +"Diktat" = "Dictation"; +"Claude Live-Status" = "Claude live status"; +"Sicherheitsmodell" = "Security model"; +"Standardmäßig aus. Ohne Hooks liefert `claude agents --json` bereits Läuft und Bereit – aber weder „Eingabe erforderlich“ noch Abschluss oder Fehler. Aktivieren trägt fünf Hook-Einträge (Notification, Stop, SubagentStop, UserPromptSubmit, PreToolUse) in dein globales ~/.claude/settings.json ein, die nur eine Statuszeile pro Ereignis protokollieren – sie greifen nie in eine laufende Session ein und beeinflussen kein Ergebnis. Diese Hooks gelten für jede Claude-Code-Session auf diesem Rechner, nicht nur für am Pad zugewiesene Agenten. Vorhandene eigene Hooks für diese Ereignisse bleiben unverändert; Deaktivieren entfernt ausschließlich die von Agent Micro eingetragenen Einträge wieder." = "Off by default. Without hooks, `claude agents --json` already reports Running and Ready — but neither “Input required” nor completion or failure. Enabling this adds five hook entries (Notification, Stop, SubagentStop, UserPromptSubmit, PreToolUse) to your global ~/.claude/settings.json. They log one status line per event, never interfere with a running session, and never affect its result. These hooks apply to every Claude Code session on this Mac, not only agents assigned to the pad. Existing custom hooks for these events remain unchanged; disabling removes only the entries added by Agent Micro."; +"Statusspur (lokal, datensparsam)" = "Status trace (local, minimal data)"; +"Prozess- und App-Logs" = "Process and app logs"; +"Rohe IORegistry-Ausgabe" = "Raw IORegistry output"; +"Verbindung" = "Connection"; +"Geladene Threads" = "Loaded threads"; +"Tastenzuordnungen" = "Key assignments"; +"Agent Micro-Protokoll" = "Agent Micro protocol"; +"Live-Automationspfad" = "Live automation path"; +"Links testen" = "Test left"; +"Rechts testen" = "Test right"; +"Automationsstatus" = "Automation status"; +"Letzter Encoder-Input" = "Last dial input"; +"Fähigkeiten" = "Capabilities"; +"Gedrückte Controls" = "Pressed controls"; +"Letztes Ereignis" = "Last event"; +"USB- und HID-Gerät" = "USB and HID device"; +"Hersteller" = "Manufacturer"; +"Produktstring" = "Product string"; +"Seriennummer" = "Serial number"; +"Wechselt zum ausgewählten Bereich" = "Switches to the selected area"; +"Schaltet Grundlicht ein oder aus" = "Turns base lighting on or off"; +"Aktion suchen" = "Search actions"; +"Agent-Zuordnung entfernen" = "Remove agent assignment"; +"Tippen: öffnen" = "Tap: open"; +"Kurzes Tippen öffnet den aktuell zugewiesenen Chat." = "A short tap opens the currently assigned chat."; +"Halten: auswählen" = "Hold: choose"; +"Taste halten, mit dem Drehrad einen Chat wählen und zum Zuweisen loslassen." = "Hold the key, choose a chat with the dial, and release to assign."; +"Aktion durchsuchen" = "Search actions"; +"Andere Aktion" = "Different action"; +"Alle Trigger sind belegt. Entferne zuerst eine andere Codex-Aktion." = "All triggers are in use. Remove another Codex action first."; +"Belegt – nächster" = "In use – next"; +"Für frühere Einrichtungen: Auswählen › Bereits eingerichtet und den bereits in Codex verwendeten Trigger einmal übernehmen." = "For earlier setups: Select › Already set up, then apply the trigger already used in Codex once."; +"Bereits eingerichtet" = "Already set up"; +"Aktionen durchsuchen" = "Search actions"; +"Anderes Bedienelement auswählen" = "Select a different control"; +"Dieses Bedienelement löst nichts aus." = "This control does not trigger anything."; +"Eigene oder profilinterne Belegung." = "Custom or profile-internal assignment."; +"Auswahl am Pad oben · Bearbeitung hier darunter" = "Select on the pad above · edit below"; +"Darstellung" = "Appearance"; +"Geräteausdruck" = "Device expression"; +"Lokaler Befehl" = "Local command"; +"Individuelle LED verfügbar" = "Per-key LED available"; +"Dieses Modell bestätigt keine individuelle Tastenfarbe. Globaler Modus steht im Gerätebereich." = "This model does not confirm per-key colors. The global mode is available in the device area."; +"Auswählen und darunter neu belegen" = "Select, then reassign below"; +"Bedienelement am Pad auswählen" = "Select a control on the pad"; +"Belegung" = "Assignment"; +"Grundlicht" = "Base lighting"; +"Reaktionen" = "Reactions"; +"Grundlichtmodus" = "Base lighting mode"; +"Blinkdauer" = "Blink duration"; +"Pulsdauer" = "Pulse duration"; +"Bis" = "To"; +"Von" = "From"; +"Helligkeit" = "Brightness"; +"Dauer" = "Duration"; +"Effektdauer" = "Effect duration"; +"Aktiver Layer" = "Active layer"; +"Layer-Bestätigung" = "Layer confirmation"; +"Bestätigung testen" = "Test confirmation"; +"Reaktion testen" = "Test reaction"; +"Optionen schließen" = "Close options"; +"Helligkeit, Dauer und Wiederholungen" = "Brightness, duration, and repeats"; +"Helligkeit und Dauer einstellen" = "Set brightness and duration"; +"Als aktiven Layer auswählen" = "Set as active layer"; +"Umbenennen" = "Rename"; +"Layer duplizieren" = "Duplicate layer"; +"Layer löschen" = "Delete layer"; +"Nur für Tastatur-/Codex-Shortcuts verfügbar. Die aktuelle Belegung kann die App nicht zusammen mit einer Halte-Aktion ausführen." = "Available only for keyboard and Codex shortcuts. The app cannot run the current assignment together with a hold action."; +"Aktiviere den Schalter, um beim Halten eine zusätzliche Aktion zu senden." = "Turn on the switch to send an additional action on hold."; +"Die Halte-Aktion wird von Agent Micro ausgelöst. Erlaube die Bedienungshilfen, sonst bleibt sie wirkungslos." = "The hold action is triggered by Agent Micro. Grant Accessibility permission or it will have no effect."; +"Kopieren" = "Copy"; +"Einfügen" = "Paste"; +"Rückgängig" = "Undo"; +"Alles auswählen" = "Select all"; +"Suchen" = "Find"; +"Sichern" = "Save"; +"Lautstärke −" = "Volume −"; +"Stumm" = "Mute"; +"Lautstärke +" = "Volume +"; +"C (approximativ)" = "C (approximate)"; +"Aufwand −" = "Effort −"; +"Aufwand +" = "Effort +"; +"Modellwahl umschalten" = "Toggle model selection"; +"Pet anzeigen" = "Show pet"; +"Codex Agent auswählen" = "Select Codex agent"; +"Claude Agent auswählen" = "Select Claude agent"; +"Öffnet die Befehlsübersicht." = "Opens the command palette."; +"Allgemein" = "General"; +"Öffnet die Codex-Einstellungen; erneutes Drücken schließt sie." = "Opens Codex settings; pressing again closes them."; +"F13 ist ein privater Pad-Trigger. Agent Micro sendet ⌘, beim ersten und Esc beim zweiten Druck." = "F13 is a private pad trigger. Agent Micro sends ⌘, on the first press and Esc on the second."; +"Öffnet die konfigurierbare Shortcut-Übersicht." = "Opens the configurable shortcut overview."; +"Öffnet einen lokalen Workspace." = "Opens a local workspace."; +"Erhöht die Darstellungsgröße in Codex." = "Increases the display size in Codex."; +"Verringert die Darstellungsgröße in Codex." = "Decreases the display size in Codex."; +"Geht im App-Verlauf zurück." = "Goes back in the app history."; +"Navigation" = "Navigation"; +"Geht im App-Verlauf vor." = "Goes forward in the app history."; +"Blendet die Seitenleiste ein oder aus." = "Shows or hides the sidebar."; +"Wechselt zum vorherigen Chat." = "Switches to the previous chat."; +"Wechselt zum nächsten Chat." = "Switches to the next chat."; +"Durchsucht gespeicherte Chats." = "Searches saved chats."; +"Sucht im aktuell geöffneten Chat." = "Searches within the currently open chat."; +"Öffnet einen Quick Chat auf macOS." = "Opens a Quick Chat on macOS."; +"Chat und Tasks" = "Chat and tasks"; +"Erstellt einen neuen lokalen Chat." = "Creates a new local chat."; +"Zum Diktieren halten" = "Hold to dictate"; +"Hält die systemweite Codex-Spracheingabe exakt so lange aktiv, wie die Pad-Taste gedrückt bleibt." = "Keeps Codex's system-wide voice input active for exactly as long as the pad key stays pressed."; +"Startet oder beendet die Spracheingabe im Composer per Tastendruck (ohne Halten)." = "Starts or stops voice input in the composer with a single press (without holding)."; +"Öffnet den Review-Tab." = "Opens the review tab."; +"Review und Code" = "Review and code"; +"Blendet das Review-Panel ein oder aus." = "Shows or hides the review panel."; +"Blendet das untere Panel ein oder aus." = "Shows or hides the bottom panel."; +"Terminal und Panels" = "Terminal and panels"; +"Blendet das Terminal ein oder aus." = "Shows or hides the terminal."; +"Leert den sichtbaren Terminalinhalt." = "Clears the visible terminal content."; +"Öffnet die Skills-Übersicht über den dokumentierten Codex-Link." = "Opens the Skills overview via the documented Codex link."; +"Codex-Bereiche" = "Codex areas"; +"Öffnet Scheduled mit dem Create-Flow über den dokumentierten Codex-Link." = "Opens Scheduled with the create flow via the documented Codex link."; +"Öffnet die dokumentierten Verbindungs-Einstellungen." = "Opens the documented connection settings."; +"Verringert den Reasoning-Aufwand direkt um eine Stufe." = "Decreases reasoning effort by one level directly."; +"Modell und Reasoning" = "Model and reasoning"; +"F22 ist der Hardware-Trigger; Agent Micro übersetzt ihn in den Codex-Shortcut F18." = "F22 is the hardware trigger; Agent Micro translates it into the Codex shortcut F18."; +"Erhöht den Reasoning-Aufwand direkt um eine Stufe." = "Increases reasoning effort by one level directly."; +"F24 ist der Hardware-Trigger; Agent Micro übersetzt ihn in den Codex-Shortcut F19." = "F24 is the hardware trigger; Agent Micro translates it into the Codex shortcut F19."; +"Durchläuft die verfügbaren Codex-Reasoning-Stufen in ihrer App-Reihenfolge." = "Cycles through the available Codex reasoning levels in the app's order."; +"Reale registrierte Action ohne Standardtaste." = "A real registered action with no default key."; +"Öffnet die Codex-Modell- und Reasoning-Auswahl; erneutes Drücken schließt sie." = "Opens the Codex model and reasoning picker; pressing again closes it."; +"F14 ist ein privater Pad-Trigger. Agent Micro sendet ⌃⇧M beim ersten und Esc beim zweiten Druck." = "F14 is a private pad trigger. Agent Micro sends ⌃⇧M on the first press and Esc on the second."; +"Sendet den aktuellen Composer-Inhalt mit Enter." = "Sends the current composer content with Enter."; +"Genehmigen" = "Approve"; +"Beantwortet eine offene Codex-Anfrage (Befehl ausführen, Datei ändern) direkt vom Pad aus mit Zustimmen." = "Approves an open Codex request (run a command, change a file) straight from the pad."; +"Genehmigung" = "Approval"; +"Wirkt nur, solange Codex tatsächlich auf eine Entscheidung wartet; sonst tut die Taste nichts." = "Only works while Codex is actually waiting for a decision; otherwise the key does nothing."; +"Ablehnen" = "Decline"; +"Beantwortet eine offene Codex-Anfrage (Befehl ausführen, Datei ändern) direkt vom Pad aus mit Ablehnen." = "Declines an open Codex request (run a command, change a file) straight from the pad."; +"Fügt Dateien oder Ordner zum Composer hinzu." = "Adds files or folders to the composer."; +"Fügt Fotos zum Composer hinzu." = "Adds photos to the composer."; +"Schaltet den Voice Mode." = "Toggles voice mode."; +"Schaltet den verfügbaren Fast-Service-Tier." = "Toggles the available fast service tier."; +"Schaltet den Planmodus." = "Toggles plan mode."; +"Genehmigt die aktuell fokussierte Anfrage." = "Approves the currently focused request."; +"Lehnt die aktuell fokussierte Anfrage ab." = "Declines the currently focused request."; +"Archiviert den aktuellen Task." = "Archives the current task."; +"Setzt den aktuellen Task in einem neuen Thread fort." = "Continues the current task in a new thread."; +"Kopiert den aktuellen Chat als Markdown." = "Copies the current chat as Markdown."; +"Öffnet einen temporären Side Chat; erneutes Drücken schließt ihn." = "Opens a temporary side chat; pressing again closes it."; +"Weise Open Side Chat in Codex einmal F15 zu. Agent Micro sendet beim zweiten Druck Esc zum Schließen." = "Assign F15 to Open Side Chat in Codex once. Agent Micro sends Esc on the second press to close it."; +"Öffnet den aktuellen Thread in einem neuen Fenster." = "Opens the current thread in a new window."; +"Wechselt zum nächsten Tab." = "Switches to the next tab."; +"Wechselt zum vorherigen Tab." = "Switches to the previous tab."; +"Pinnt oder löst den aktuellen Thread." = "Pins or unpins the current thread."; +"Öffnet den eingebauten Browser-Tab." = "Opens the built-in browser tab."; +"Blendet das Browser-Panel ein oder aus." = "Shows or hides the browser panel."; +"Startet den in Codex registrierten Commit-Flow." = "Starts the commit flow registered in Codex."; +"Startet den in Codex registrierten Pull-Request-Flow." = "Starts the pull request flow registered in Codex."; +"Startet den vollständigen Git-Review-Flow." = "Starts the full Git review flow."; +"Öffnet die lokal registrierte Skills-Action." = "Opens the locally registered Skills action."; +"Neben dem stabilen codex://skills-Link als konfigurierbare Action registriert." = "Registered as a configurable action alongside the stable codex://skills link."; +"Öffnet die registrierte Task-Verwaltung." = "Opens the registered task management."; +"Öffnet die registrierten MCP-Einstellungen." = "Opens the registered MCP settings."; +"Macht die letzte App-Aktion rückgängig." = "Undoes the last app action."; +"Wiederholt die letzte App-Aktion." = "Redoes the last app action."; +"Öffnet die registrierte Theme-Action." = "Opens the registered theme action."; +"Öffnet den registrierten Feedback-Flow." = "Opens the registered feedback flow."; +"Öffnet oder schließt das Pet-Overlay in Codex." = "Opens or closes the pet overlay in Codex."; +"Pets" = "Pets"; +"Nur in Codex verfügbar. Entspricht der registrierten Aktion „Open the pet overlay“." = "Available in Codex only. Matches the registered “Open the pet overlay” action."; +"Diktieren (Push-to-Talk)" = "Dictate (push to talk)"; +"Hält Claude Codes Spracheingabe, solange die Pad-Taste gedrückt bleibt." = "Holds Claude Code's voice input for as long as the pad key stays pressed."; +"Chat und Sessions" = "Chat and sessions"; +"Wirkt nur, solange Claude Desktop das Chat-Eingabefeld fokussiert hat – anders als Codex' systemweiter Diktier-Shortcut." = "Only works while Claude Desktop has the chat input field focused — unlike Codex's system-wide dictation shortcut."; +"Shortcut-Übersicht" = "Shortcut overview"; +"Öffnet die Liste aller Tastenkombinationen." = "Opens the list of all keyboard shortcuts."; +"Neue Session" = "New session"; +"Erstellt eine neue Session." = "Creates a new session."; +"Session schließen" = "Close session"; +"Schließt die aktuelle Session." = "Closes the current session."; +"Nächste Session" = "Next session"; +"Wechselt zur nächsten Session." = "Switches to the next session."; +"Alternativ Ctrl+Tab." = "Alternatively Ctrl+Tab."; +"Vorherige Session" = "Previous session"; +"Wechselt zur vorherigen Session." = "Switches to the previous session."; +"Alternativ Ctrl+Shift+Tab." = "Alternatively Ctrl+Shift+Tab."; +"Laufende Antwort stoppen" = "Stop the running response"; +"Unterbricht die aktuell laufende Antwort." = "Interrupts the response currently running."; +"Nachricht senden" = "Send message"; +"Sendet die aktuell im Eingabefeld getippte Nachricht ab." = "Sends the message currently typed in the input field."; +"Wirkt nur, solange Claude Desktop das Chat-Eingabefeld fokussiert hat." = "Only works while Claude Desktop has the chat input field focused."; +"Diff-Ansicht ein/aus" = "Toggle diff view"; +"Blendet die Diff-Ansicht ein oder aus." = "Shows or hides the diff view."; +"Diff und Review" = "Diff and review"; +"Preview ein/aus" = "Toggle preview"; +"Blendet die Vorschau ein oder aus." = "Shows or hides the preview."; +"Element in Preview auswählen" = "Select element in preview"; +"Wählt ein Element in der Vorschau aus." = "Selects an element in the preview."; +"Terminal ein/aus" = "Toggle terminal"; +"Terminal und Ansicht" = "Terminal and view"; +"Fokussiertes Panel schließen" = "Close focused panel"; +"Schließt das aktuell fokussierte Panel." = "Closes the currently focused panel."; +"Side Chat öffnen" = "Open side chat"; +"Öffnet einen Side Chat." = "Opens a side chat."; +"Ansichtsmodus wechseln" = "Cycle view mode"; +"Wechselt zwischen den verfügbaren Ansichtsmodi." = "Cycles through the available view modes."; +"Permission Mode öffnen" = "Open permission mode"; +"Öffnet die Auswahl des Permission Mode." = "Opens the permission mode picker."; +"Modellmenü öffnen" = "Open model menu"; +"Öffnet die Modellauswahl." = "Opens the model picker."; +"Menüeintrag auswählen: Pfeiltasten oder Zifferntasten 1–9." = "Select a menu entry: arrow keys or number keys 1–9."; +"Effort/Denklevel öffnen" = "Open effort / thinking level"; +"Öffnet die Denkaufwand-Auswahl." = "Opens the thinking effort picker."; +"Menüeintrag auswählen: Pfeiltasten oder Zifferntasten 1–9. Muss zuerst geöffnet werden, bevor Pfeiltasten den Denkaufwand ändern." = "Select a menu entry: arrow keys or number keys 1–9. It has to be opened first before arrow keys change the thinking effort."; +"Menüeintrag auswählen" = "Select menu item"; +"Wählt einen Eintrag im geöffneten Menü (Modell oder Effort) direkt per Ziffer." = "Selects an entry in the open menu (model or effort) directly by number."; +"Nur wirksam, während ein Menü (Modell/Effort) bereits offen ist." = "Only effective while a menu (model/effort) is already open."; +"F15 (in Codex zuweisen)" = "F15 (assign in Codex)"; +"Leertaste halten" = "Hold Space"; +"⌘F17 halten" = "Hold ⌘F17"; +"⌘⇧P oder ⌘K" = "⌘⇧P or ⌘K"; diff --git a/Services/CH57xConfigurationEncoder.swift b/Services/CH57xConfigurationEncoder.swift index 248ee38..4d5800e 100644 --- a/Services/CH57xConfigurationEncoder.swift +++ b/Services/CH57xConfigurationEncoder.swift @@ -8,11 +8,20 @@ enum CH57xConfigurationError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .unsupportedDeferredAction(let control): - "\(control.title) nutzt eine lokale/deeplink-basierte Aktion. Sie benötigt einen späteren F13–F21-Listener und kann nicht direkt auf das Gerät geladen werden." + AppLanguage.text( + "\(control.title) nutzt eine lokale/deeplink-basierte Aktion. Sie benötigt einen späteren F13–F21-Listener und kann nicht direkt auf das Gerät geladen werden.", + "\(control.title) uses a local or deep-link action. It requires a later F13–F21 listener and cannot be transferred to the device directly." + ) case .invalidExpression(let control, let expression): - "Ungültiger Ausdruck für \(control.title): \(expression)" + AppLanguage.text( + "Ungültiger Ausdruck für \(control.title): \(expression)", + "Invalid expression for \(control.title): \(expression)" + ) case .sequenceTooLong(let control): - "\(control.title) enthält mehr als fünf Tastenkombinationen; 0x8890 unterstützt höchstens fünf." + AppLanguage.text( + "\(control.title) enthält mehr als fünf Tastenkombinationen; 0x8890 unterstützt höchstens fünf.", + "\(control.title) contains more than five key combinations; the 0x8890 supports at most five." + ) } } } diff --git a/Services/CH57xProcessClient.swift b/Services/CH57xProcessClient.swift index 849eb03..a86f298 100644 --- a/Services/CH57xProcessClient.swift +++ b/Services/CH57xProcessClient.swift @@ -43,7 +43,10 @@ struct CH57xProcessClient { stdout: "", stderr: "", timedOut: false, - launchError: "CH57x-Helper nicht gefunden: \(helperURL.path). Führe script/build_and_run.sh aus." + launchError: AppLanguage.text( + "CH57x-Helper nicht gefunden: \(helperURL.path). Führe script/build_and_run.sh aus.", + "CH57x helper not found: \(helperURL.path). Run script/build_and_run.sh." + ) ) } let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("codexpad-\(UUID().uuidString).yaml") diff --git a/Services/ClaudeAccessibilityInspector.swift b/Services/ClaudeAccessibilityInspector.swift index ff4ddea..1e1edb6 100644 --- a/Services/ClaudeAccessibilityInspector.swift +++ b/Services/ClaudeAccessibilityInspector.swift @@ -111,14 +111,17 @@ final class ClaudeAccessibilityInspector { let pid = claude.processIdentifier guard let popUp = ClaudeAccessibilityControls.effortPopUp(in: application) else { - summary = ["Aufwand-Auswahl nicht gefunden"] + summary = [AppLanguage.text("Aufwand-Auswahl nicht gefunden", "Effort control not found")] status = AppLanguage.text( "Fehlgeschlagen: Claudes Aufwand-Auswahl wurde nicht gefunden.", "Failed: Claude's effort control was not found." ) return } - steps.append("Steuerelement: \(ClaudeAccessibilityControls.title(of: popUp))") + steps.append(AppLanguage.text( + "Steuerelement: \(ClaudeAccessibilityControls.title(of: popUp))", + "Control: \(ClaudeAccessibilityControls.title(of: popUp))" + )) claude.activate(options: [.activateAllWindows]) try? await Task.sleep(nanoseconds: 300_000_000) @@ -127,9 +130,12 @@ final class ClaudeAccessibilityInspector { ClaudeAccessibilityControls.postKey(kVK_Space, to: pid) } let opened = await waitUntil(timeout: 1.0) { ClaudeAccessibilityControls.isExpanded(popUp) } - steps.append("Öffnen: \(opened ? "AXExpanded=1" : "FEHLGESCHLAGEN")") + steps.append(AppLanguage.text( + "Öffnen: \(opened ? "AXExpanded=1" : "FEHLGESCHLAGEN")", + "Open: \(opened ? "AXExpanded=1" : "FAILED")" + )) guard opened, let slider = ClaudeAccessibilityControls.effortSlider(in: application) else { - summary = steps + ["Regler nicht erreichbar"] + summary = steps + [AppLanguage.text("Regler nicht erreichbar", "Slider not reachable")] status = AppLanguage.text( "Fehlgeschlagen: Aufwandmenü ließ sich nicht öffnen.", "Failed: the effort menu did not open." @@ -139,21 +145,27 @@ final class ClaudeAccessibilityInspector { } let original = ClaudeAccessibilityControls.stringValue(of: slider) - steps.append("Startwert: \(original)") + steps.append(AppLanguage.text("Startwert: \(original)", "Initial value: \(original)")) let up = await step(slider, increment: true, from: original, pid: pid) - steps.append("Schritt hoch: \(up.description)") + steps.append(AppLanguage.text("Schritt hoch: \(up.description)", "Step up: \(up.description)")) let afterUp = ClaudeAccessibilityControls.stringValue(of: slider) let down = await step(slider, increment: false, from: afterUp, pid: pid) - steps.append("Schritt zurück: \(down.description)") + steps.append(AppLanguage.text("Schritt zurück: \(down.description)", "Step back: \(down.description)")) let restored = ClaudeAccessibilityControls.stringValue(of: slider) - steps.append("Endwert: \(restored)\(restored == original ? " (wiederhergestellt)" : " (ABWEICHEND)")") + steps.append(AppLanguage.text( + "Endwert: \(restored)\(restored == original ? " (wiederhergestellt)" : " (ABWEICHEND)")", + "Final value: \(restored)\(restored == original ? " (restored)" : " (DIVERGED)")" + )) await closeEffortPopUp(popUp, pid: pid) let collapsed = !ClaudeAccessibilityControls.isExpanded(popUp) - steps.append("Schließen: \(collapsed ? "AXExpanded=0" : "FEHLGESCHLAGEN")") + steps.append(AppLanguage.text( + "Schließen: \(collapsed ? "AXExpanded=0" : "FEHLGESCHLAGEN")", + "Close: \(collapsed ? "AXExpanded=0" : "FAILED")" + )) summary = steps let changed = up.changed || down.changed @@ -191,10 +203,16 @@ final class ClaudeAccessibilityInspector { if let value = await waitForValue(of: slider, changedFrom: previous) { return StepResult( changed: true, - description: "\(previous) → \(value) via Pfeiltaste (\(action) akzeptiert=\(accepted), aber wirkungslos)" + description: AppLanguage.text( + "\(previous) → \(value) via Pfeiltaste (\(action) akzeptiert=\(accepted), aber wirkungslos)", + "\(previous) → \(value) via arrow key (\(action) accepted=\(accepted), but had no effect)" + ) ) } - return StepResult(changed: false, description: "\(previous) unverändert (Ende der Skala oder blockiert)") + return StepResult(changed: false, description: AppLanguage.text( + "\(previous) unverändert (Ende der Skala oder blockiert)", + "\(previous) unchanged (end of the scale or blocked)" + )) } private func waitForValue(of slider: AXUIElement, changedFrom previous: String) async -> String? { @@ -264,7 +282,7 @@ final class ClaudeAccessibilityInspector { rowMatches: rowMatches, report: &report ) - probed.append("\(label): \(opened ?? "kein Weg gefunden")") + probed.append("\(label): \(opened ?? AppLanguage.text("kein Weg gefunden", "no way found"))") } summary = ["Claude \(Self.claudeVersion(for: claude) ?? "?")"] + probed @@ -470,7 +488,10 @@ final class ClaudeAccessibilityInspector { self.status = "\(status) → \(url.path)" logger.info("Claude AX dump written to \(url.path, privacy: .public)") } catch { - self.status = "\(status) (Datei konnte nicht geschrieben werden: \(error.localizedDescription))" + self.status = AppLanguage.text( + "\(status) (Datei konnte nicht geschrieben werden: \(error.localizedDescription))", + "\(status) (the file could not be written: \(error.localizedDescription))" + ) logger.error("Claude AX dump could not be written: \(error.localizedDescription, privacy: .public)") } } diff --git a/Services/ClaudeAgentBridge.swift b/Services/ClaudeAgentBridge.swift index a47c2a2..d58e32a 100644 --- a/Services/ClaudeAgentBridge.swift +++ b/Services/ClaudeAgentBridge.swift @@ -89,7 +89,9 @@ struct ClaudeDesktopSession: Decodable, Sendable { // Claude Desktop keeps the session even before it has generated a // sidebar title. Make that explicit instead of exposing a UUID as // if it were the title; it is unrelated to whether a PR merged. - title: cleanTitle.isEmpty ? "Claude-Sitzung ohne Titel · \(shortID)" : cleanTitle, + title: cleanTitle.isEmpty + ? AppLanguage.text("Claude-Sitzung ohne Titel · \(shortID)", "Untitled Claude session · \(shortID)") + : cleanTitle, preview: [project, shortID].compactMap { $0 }.joined(separator: " · "), cwd: projectPath, parentThreadID: nil, @@ -267,7 +269,10 @@ final class ClaudeAgentBridge: AgentBridge { private func handlePollResult(_ result: Result) { switch result { case .success(let snapshot): - connectionState = .connected(serverVersion: "Claude Desktop · \(snapshot.threads.count) Sitzungen") + connectionState = .connected(serverVersion: AppLanguage.text( + "Claude Desktop · \(snapshot.threads.count) Sitzungen", + "Claude Desktop · \(snapshot.threads.count) sessions" + )) lastError = nil cliToDesktopID = snapshot.cliToDesktopID onThreads?(snapshot.threads) @@ -346,7 +351,7 @@ final class ClaudeAgentBridge: AgentBridge { private nonisolated static func runAgentsJSON() async -> Result<[ClaudeAgentSession], ClaudeBridgeError> { guard let executable = claudeExecutable() else { - return .failure(ClaudeBridgeError(message: "Claude CLI wurde nicht gefunden. Installiere oder starte die Claude-App.")) + return .failure(ClaudeBridgeError(message: AppLanguage.text("Claude CLI wurde nicht gefunden. Installiere oder starte die Claude-App.", "Claude CLI not found. Install or launch the Claude app."))) } let process = Process() process.executableURL = executable @@ -357,15 +362,15 @@ final class ClaudeAgentBridge: AgentBridge { do { try process.run() } catch { - return .failure(ClaudeBridgeError(message: "Claude CLI konnte nicht gestartet werden: \(error.localizedDescription)")) + return .failure(ClaudeBridgeError(message: AppLanguage.text("Claude CLI konnte nicht gestartet werden: \(error.localizedDescription)", "Could not start the Claude CLI: \(error.localizedDescription)"))) } let data = outputPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0 else { - return .failure(ClaudeBridgeError(message: "`claude agents --json` endete mit Code \(process.terminationStatus).")) + return .failure(ClaudeBridgeError(message: AppLanguage.text("`claude agents --json` endete mit Code \(process.terminationStatus).", "`claude agents --json` exited with code \(process.terminationStatus)."))) } guard let sessions = try? JSONDecoder().decode([ClaudeAgentSession].self, from: data) else { - return .failure(ClaudeBridgeError(message: "`claude agents --json` lieferte keine gültige Antwort.")) + return .failure(ClaudeBridgeError(message: AppLanguage.text("`claude agents --json` lieferte keine gültige Antwort.", "`claude agents --json` did not return a valid response."))) } return .success(sessions) } @@ -490,7 +495,7 @@ final class ClaudeAgentBridge: AgentBridge { let data = try Data(contentsOf: url) guard !data.isEmpty else { return [:] } guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw ClaudeBridgeError(message: "~/.claude/settings.json enthält kein gültiges JSON-Objekt.") + throw ClaudeBridgeError(message: AppLanguage.text("~/.claude/settings.json enthält kein gültiges JSON-Objekt.", "~/.claude/settings.json does not contain a valid JSON object.")) } return object } diff --git a/Services/ClaudeReasoningAutomationService.swift b/Services/ClaudeReasoningAutomationService.swift index 14b6ff7..a7a0eb9 100644 --- a/Services/ClaudeReasoningAutomationService.swift +++ b/Services/ClaudeReasoningAutomationService.swift @@ -66,8 +66,8 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { private var isModelMenuOpen = false private let permissionMonitor: PermissionMonitor - private(set) var status = "Deaktiviert" - private(set) var lastInput = "Noch kein Drehrad-Signal empfangen" + private(set) var status = AppLanguage.text("Deaktiviert", "Disabled") + private(set) var lastInput = AppLanguage.text("Noch kein Drehrad-Signal empfangen", "No dial signal received yet") var hasAccessibilityPermission: Bool { permissionMonitor.hasAccessibilityPermission } var hasInputMonitoringPermission: Bool { permissionMonitor.hasInputMonitoringPermission } var isEnabled: Bool { @@ -92,7 +92,10 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { } updateMonitoring() if !hasAccessibilityPermission || (!usesPhysicalEncoderEvents && !hasInputMonitoringPermission) { - status = "Berechtigungen fehlen noch. In den Systemeinstellungen Agent Micro aktivieren und danach zur App zurückkehren." + status = AppLanguage.text( + "Berechtigungen fehlen noch. In den Systemeinstellungen Agent Micro aktivieren und danach zur App zurückkehren.", + "Permissions are still missing. Enable Agent Micro in System Settings, then come back to the app." + ) } } @@ -110,18 +113,24 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { func handlePhysicalEvent(_ event: CodexPadPhysicalEvent) { guard let control = HardwareControl(reportedControlIndex: event.control), HardwareControl.encoderActions.contains(control) else { return } - let source = event.origin == .hardware ? "Hardware" : "Diagnose" + let source = event.origin == .hardware ? "Hardware" : AppLanguage.text("Diagnose", "Diagnostics") lastInput = "\(source): \(control.title) · \(String(describing: event.phase)) · #\(event.sequence)" logger.info( "Physical encoder received origin=\(event.origin.rawValue, privacy: .public) control=\(control.rawValue, privacy: .public) phase=\(event.phase.rawValue, privacy: .public)" ) guard isActiveProfile() else { - status = "Drehrad ignoriert: Claude-Profil ist nicht aktiv." + status = AppLanguage.text( + "Drehrad ignoriert: Claude-Profil ist nicht aktiv.", + "Dial ignored: the Claude profile is not active." + ) logger.error("Physical encoder rejected: inactive Claude profile") return } guard !isExternallySuspended() else { - status = "Drehrad wartet: Thread-Auswahl ist noch aktiv." + status = AppLanguage.text( + "Drehrad wartet: Thread-Auswahl ist noch aktiv.", + "Dial waiting: thread selection is still active." + ) logger.error("Physical encoder rejected: thread picker is active") return } @@ -151,7 +160,10 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { if encoderHoldFired { driveModelMenuHighlight(step) } else if isEncoderPressed { - status = "Drehrad wird gehalten – nach \(Self.modelListHoldThresholdMilliseconds) ms Modelle auswählen." + status = AppLanguage.text( + "Drehrad wird gehalten – nach \(Self.modelListHoldThresholdMilliseconds) ms Modelle auswählen.", + "Dial held — choose models after \(Self.modelListHoldThresholdMilliseconds) ms." + ) } else { stepEffort(step) } @@ -164,7 +176,10 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { encoderHoldFired = false guard !isSequenceInFlight else { suppressCurrentEncoderPress = true - status = "Drehrad-Druck wartet: eine Aufwandänderung läuft noch." + status = AppLanguage.text( + "Drehrad-Druck wartet: eine Aufwandänderung läuft noch.", + "Dial press waiting: an effort change is still running." + ) return } encoderHoldTimer = Timer.scheduledTimer(withTimeInterval: Self.modelListHoldThresholdSeconds, repeats: false) { [weak self] _ in @@ -176,7 +191,10 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { private func fireEncoderHold() { guard !suppressCurrentEncoderPress, isEncoderPressed else { return } guard isActiveProfile(), !isExternallySuspended() else { - status = "Modellwahl abgebrochen: Profil oder Thread-Auswahl hat sich geändert." + status = AppLanguage.text( + "Modellwahl abgebrochen: Profil oder Thread-Auswahl hat sich geändert.", + "Model selection cancelled: the profile or thread selection changed." + ) return } guard !isSequenceInFlight else { return } @@ -210,11 +228,17 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { /// re-reading the slider's own value afterwards. private func stepEffort(_ direction: CodexModelListStep) { guard !isModelMenuOpen else { - status = "Drehschritt ignoriert: das Modellmenü ist noch offen." + status = AppLanguage.text( + "Drehschritt ignoriert: das Modellmenü ist noch offen.", + "Dial step ignored: the model menu is still open." + ) return } guard !isSequenceInFlight else { - status = "Drehschritt ignoriert: Claude verarbeitet noch die vorherige Geste." + status = AppLanguage.text( + "Drehschritt ignoriert: Claude verarbeitet noch die vorherige Geste.", + "Dial step ignored: Claude is still processing the previous gesture." + ) return } run { await self.performEffortStep(direction) } @@ -223,7 +247,10 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { private func performEffortStep(_ direction: CodexModelListStep) async { guard let context = await openEffortPopover() else { return } guard let slider = ClaudeAccessibilityControls.effortSlider(in: context.application) else { - status = "Claudes Aufwand-Regler wurde im geöffneten Menü nicht gefunden." + status = AppLanguage.text( + "Claudes Aufwand-Regler wurde im geöffneten Menü nicht gefunden.", + "Claude's effort slider was not found in the open menu." + ) await closeEffortPopover(context) return } @@ -236,10 +263,19 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { let current = ClaudeAccessibilityControls.stringValue(of: slider) if current == previous, !current.isEmpty { status = direction == .next - ? "Claude-Aufwand ist bereits maximal (\(current))." - : "Claude-Aufwand ist bereits minimal (\(current))." + ? AppLanguage.text( + "Claude-Aufwand ist bereits maximal (\(current)).", + "Claude's effort is already at maximum (\(current))." + ) + : AppLanguage.text( + "Claude-Aufwand ist bereits minimal (\(current)).", + "Claude's effort is already at minimum (\(current))." + ) } else { - status = "Claude-Aufwand ließ sich nicht ändern (\(previous.isEmpty ? "kein Wert lesbar" : previous))." + status = AppLanguage.text( + "Claude-Aufwand ließ sich nicht ändern (\(previous.isEmpty ? "kein Wert lesbar" : previous)).", + "Claude's effort could not be changed (\(previous.isEmpty ? "no readable value" : previous))." + ) await closeEffortPopover(context) return } @@ -247,7 +283,7 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { return } - status = "Claude-Aufwand: \(updated)" + status = AppLanguage.text("Claude-Aufwand: \(updated)", "Claude effort: \(updated)") logger.info("Claude effort changed \(previous, privacy: .public) → \(updated, privacy: .public)") scheduleIdleClose(context) } @@ -281,11 +317,17 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { func toggleEffortMenu() { guard !isModelMenuOpen else { - status = "Menü wartet: das Modellmenü ist noch offen." + status = AppLanguage.text( + "Menü wartet: das Modellmenü ist noch offen.", + "Menu waiting: the model menu is still open." + ) return } guard !isSequenceInFlight else { - status = "Menü wartet: Claude verarbeitet noch die vorherige Geste." + status = AppLanguage.text( + "Menü wartet: Claude verarbeitet noch die vorherige Geste.", + "Menu waiting: Claude is still processing the previous gesture." + ) return } run { @@ -296,7 +338,9 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { guard let context = await self.openEffortPopover() else { return } let value = ClaudeAccessibilityControls.effortSlider(in: context.application) .map(ClaudeAccessibilityControls.stringValue(of:)) ?? "" - self.status = value.isEmpty ? "Aufwandmenü geöffnet." : "Aufwandmenü offen · \(value)" + self.status = value.isEmpty + ? AppLanguage.text("Aufwandmenü geöffnet.", "Effort menu opened.") + : AppLanguage.text("Aufwandmenü offen · \(value)", "Effort menu open · \(value)") self.scheduleIdleClose(context) } } @@ -306,20 +350,29 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { private func openModelMenu() async { guard let context = currentContext() else { return } guard let popUp = resolveModelPopUp(in: context.application) else { - status = "Claudes Modellauswahl wurde nicht gefunden." + status = AppLanguage.text( + "Claudes Modellauswahl wurde nicht gefunden.", + "Claude's model selector was not found." + ) return } await cancelIdleClose() if isEffortPopoverOpen { await closeEffortPopover(context) } guard await open(popUp, context: context) else { - status = "Claudes Modellmenü ließ sich nicht öffnen." + status = AppLanguage.text( + "Claudes Modellmenü ließ sich nicht öffnen.", + "Claude's model menu did not open." + ) isModelMenuOpen = false return } isModelMenuOpen = true let current = ClaudeAccessibilityControls.title(of: popUp) - status = "Modellmenü offen (\(current)): drehen wählt, loslassen übernimmt." + status = AppLanguage.text( + "Modellmenü offen (\(current)): drehen wählt, loslassen übernimmt.", + "Model menu open (\(current)): turn to choose, release to confirm." + ) } /// Only acts while the model menu is verified open. Arrow keys go to @@ -329,11 +382,16 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { guard isModelMenuOpen, let context = currentContext() else { return } guard let popUp = cachedModelPopUp, ClaudeAccessibilityControls.isExpanded(popUp) else { isModelMenuOpen = false - status = "Modellmenü ist nicht mehr offen – Auswahl abgebrochen." + status = AppLanguage.text( + "Modellmenü ist nicht mehr offen – Auswahl abgebrochen.", + "The model menu is no longer open — selection cancelled." + ) return } ClaudeAccessibilityControls.postKey(step == .next ? kVK_DownArrow : kVK_UpArrow, to: context.pid) - status = step == .next ? "Nächstes Modell" : "Vorheriges Modell" + status = step == .next + ? AppLanguage.text("Nächstes Modell", "Next model") + : AppLanguage.text("Vorheriges Modell", "Previous model") } private func confirmModelSelection() async { @@ -350,11 +408,16 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { isModelMenuOpen = false guard collapsed else { await forceClose(context) - status = "Modellauswahl ließ sich nicht bestätigen – Menü wurde geschlossen." + status = AppLanguage.text( + "Modellauswahl ließ sich nicht bestätigen – Menü wurde geschlossen.", + "The model selection could not be confirmed — the menu was closed." + ) return } let current = ClaudeAccessibilityControls.title(of: popUp) - status = current == previous ? "Modell unverändert: \(current)" : "Modell: \(current)" + status = current == previous + ? AppLanguage.text("Modell unverändert: \(current)", "Model unchanged: \(current)") + : AppLanguage.text("Modell: \(current)", "Model: \(current)") logger.info("Claude model \(previous, privacy: .public) → \(current, privacy: .public)") } @@ -378,13 +441,19 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { private func openEffortPopover() async -> Context? { guard let context = currentContext() else { return nil } guard let popUp = resolveEffortPopUp(in: context.application) else { - status = "Claudes Aufwand-Auswahl wurde nicht gefunden." + status = AppLanguage.text( + "Claudes Aufwand-Auswahl wurde nicht gefunden.", + "Claude's effort control was not found." + ) isEffortPopoverOpen = false return nil } await cancelIdleClose() guard await open(popUp, context: context) else { - status = "Claudes Aufwandmenü ließ sich nicht öffnen." + status = AppLanguage.text( + "Claudes Aufwandmenü ließ sich nicht öffnen.", + "Claude's effort menu did not open." + ) isEffortPopoverOpen = false return nil } @@ -527,15 +596,21 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { private func readyClaudeApplication() -> NSRunningApplication? { guard isEnabled else { - status = "Encoder-Steuerung ist deaktiviert." + status = AppLanguage.text("Encoder-Steuerung ist deaktiviert.", "Dial control is disabled.") return nil } guard hasAccessibilityPermission else { - status = "Bedienungshilfen fehlen. Bitte unten Berechtigungen anfordern." + status = AppLanguage.text( + "Bedienungshilfen fehlen. Bitte unten Berechtigungen anfordern.", + "Accessibility permission is missing. Request permissions below." + ) return nil } guard isActiveProfile(), !isExternallySuspended() else { - status = "Claude-Automation pausiert: Profil oder Thread-Auswahl hat sich geändert." + status = AppLanguage.text( + "Claude-Automation pausiert: Profil oder Thread-Auswahl hat sich geändert.", + "Claude automation paused: the profile or thread selection changed." + ) return nil } guard let claude = claudeApplication else { @@ -544,7 +619,7 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { // longer holds, so drop it rather than risk sending keys into a // menu that isn't there. resetState() - status = "Claude läuft nicht." + status = AppLanguage.text("Claude läuft nicht.", "Claude is not running.") return nil } return claude @@ -569,26 +644,38 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { stopMonitoring() resetState() guard isEnabled else { - status = "Deaktiviert" + status = AppLanguage.text("Deaktiviert", "Disabled") return } guard hasAccessibilityPermission else { - status = "Bedienungshilfen fehlen – Agent Micro darf Claude nicht steuern." + status = AppLanguage.text( + "Bedienungshilfen fehlen – Agent Micro darf Claude nicht steuern.", + "Accessibility permission is missing — Agent Micro may not control Claude." + ) return } guard hasInputMonitoringPermission || usesPhysicalEncoderEvents else { - status = "Input Monitoring fehlt – macOS blockiert das Drehrad. Bitte Agent Micro unten freigeben." + status = AppLanguage.text( + "Input Monitoring fehlt – macOS blockiert das Drehrad. Bitte Agent Micro unten freigeben.", + "Input Monitoring is missing — macOS is blocking the dial. Allow Agent Micro below." + ) return } guard !usesPhysicalEncoderEvents else { - status = "Bereit: Drehrad läuft über das direkte Pad-Protokoll. Nur Bedienungshilfen werden benötigt." + status = AppLanguage.text( + "Bereit: Drehrad läuft über das direkte Pad-Protokoll. Nur Bedienungshilfen werden benötigt.", + "Ready: the dial runs over the direct pad protocol. Only Accessibility is required." + ) return } - status = "Bereit: Drehen = Reasoning-Aufwand · Halten (>\(Int(Self.modelListHoldThresholdSeconds * 1000)) ms) + drehen = Modell wechseln." + status = AppLanguage.text( + "Bereit: Drehen = Reasoning-Aufwand · Halten (>\(Int(Self.modelListHoldThresholdSeconds * 1000)) ms) + drehen = Modell wechseln.", + "Ready: turn = reasoning effort · hold (>\(Int(Self.modelListHoldThresholdSeconds * 1000)) ms) + turn = switch model." + ) let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) let keyboardIdentity: (Int, Int) -> [String: Any] = { vendorID, productID in @@ -605,7 +692,10 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) guard result == kIOReturnSuccess else { - status = "Drehrad konnte nicht geöffnet werden (\(result))." + status = AppLanguage.text( + "Drehrad konnte nicht geöffnet werden (\(result)).", + "Could not open the dial (\(result))." + ) IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) return } @@ -640,7 +730,9 @@ final class ClaudeReasoningAutomationService: EncoderAutomationService { } private func recordInput(usage: Int, value: Int) { - lastInput = HIDInputEvent.functionKeyName(usage).map { "Empfangen: \($0)" } ?? String(format: "Empfangen: 0x%02X", usage) + lastInput = HIDInputEvent.functionKeyName(usage) + .map { AppLanguage.text("Empfangen: \($0)", "Received: \($0)") } + ?? String(format: AppLanguage.text("Empfangen: 0x%02X", "Received: 0x%02X"), usage) logger.info("HID input usage=\(usage, privacy: .public) value=\(value, privacy: .public)") } diff --git a/Services/CodexEventBridge.swift b/Services/CodexEventBridge.swift index b7d9a52..66605dc 100644 --- a/Services/CodexEventBridge.swift +++ b/Services/CodexEventBridge.swift @@ -167,7 +167,7 @@ final class CodexEventBridge: @unchecked Sendable, AgentBridge { lastError = nil guard let executable = Self.codexExecutable() else { - fail("Codex CLI wurde nicht gefunden. Installiere oder starte die Codex-App.") + fail(AppLanguage.text("Codex CLI wurde nicht gefunden. Installiere oder starte die Codex-App.", "Codex CLI not found. Install or launch the Codex app.")) return } @@ -215,7 +215,7 @@ final class CodexEventBridge: @unchecked Sendable, AgentBridge { statusReadsInFlight.removeAll() sendInitialize() } catch { - fail("Codex App Server konnte nicht gestartet werden: \(error.localizedDescription)") + fail(AppLanguage.text("Codex App Server konnte nicht gestartet werden: \(error.localizedDescription)", "Could not start the Codex app server: \(error.localizedDescription)")) } } @@ -316,7 +316,7 @@ final class CodexEventBridge: @unchecked Sendable, AgentBridge { do { try input?.write(contentsOf: data) } catch { - fail("Schreiben zum Codex App Server fehlgeschlagen: \(error.localizedDescription)") + fail(AppLanguage.text("Schreiben zum Codex App Server fehlgeschlagen: \(error.localizedDescription)", "Writing to the Codex app server failed: \(error.localizedDescription)")) } } @@ -341,7 +341,7 @@ final class CodexEventBridge: @unchecked Sendable, AgentBridge { case .recent: recentThreadListInFlight = false } } - let detail = error["message"] as? String ?? "Unbekannter App-Server-Fehler" + let detail = error["message"] as? String ?? AppLanguage.text("Unbekannter App-Server-Fehler", "Unknown app server error") lastError = detail logger.error("App-server request failed: \(detail, privacy: .public)") return @@ -443,7 +443,7 @@ final class CodexEventBridge: @unchecked Sendable, AgentBridge { } if pendingElicitations[threadID]?.isEmpty != false { onStatus?(threadID, .running, .event) } case "error": - lastError = params["message"] as? String ?? "Codex App Server meldet einen Fehler." + lastError = params["message"] as? String ?? AppLanguage.text("Codex App Server meldet einen Fehler.", "The Codex app server reported an error.") default: break } @@ -545,7 +545,7 @@ final class CodexEventBridge: @unchecked Sendable, AgentBridge { process = nil input = nil guard !isStopping else { return } - scheduleReconnect(reason: "Codex App Server wurde beendet (Code \(code)).") + scheduleReconnect(reason: AppLanguage.text("Codex App Server wurde beendet (Code \(code)).", "The Codex app server exited (code \(code)).")) } private func fail(_ message: String) { diff --git a/Services/CodexPadEventService.swift b/Services/CodexPadEventService.swift index 5d902cf..0247e44 100644 --- a/Services/CodexPadEventService.swift +++ b/Services/CodexPadEventService.swift @@ -45,7 +45,7 @@ final class CodexPadEventService: @unchecked Sendable { private var diagnosticSequence: UInt8 = 0 private(set) var firmwareStatus: CodexPadFirmwareStatus? private(set) var events: [CodexPadPhysicalEvent] = [] - private(set) var status = "Noch nicht verbunden" + private(set) var status = AppLanguage.text("Noch nicht verbunden", "Not connected yet") var onPhysicalEvent: ((CodexPadPhysicalEvent) -> Void)? var onFirmwareStatus: ((CodexPadFirmwareStatus) -> Void)? /// Rate-limits `reconnectDevice()`: a genuinely absent device (mid USB @@ -73,7 +73,7 @@ final class CodexPadEventService: @unchecked Sendable { let devices = IOHIDManagerCopyDevices(manager) as? Set, let device = devices.first, IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else { - status = "Raw HID konnte nicht geöffnet werden" + status = AppLanguage.text("Raw HID konnte nicht geöffnet werden", "Could not open raw HID") return } let buffer = UnsafeMutablePointer.allocate(capacity: CodexPadPacketEncoder.packetSize) @@ -82,7 +82,7 @@ final class CodexPadEventService: @unchecked Sendable { self.manager = manager self.device = device self.reportBuffer = buffer - status = "Protokoll v2 verbunden" + status = AppLanguage.text("Protokoll v2 verbunden", "Protocol v2 connected") requestStatus() // Physical key edges arrive as their own unsolicited reports, so this // poll only refreshes the firmware/pressed-mask fallback. 150 ms keeps @@ -104,7 +104,7 @@ final class CodexPadEventService: @unchecked Sendable { reportBuffer = nil; device = nil; manager = nil firmwareStatus = nil lastLoggedPressedMask = nil - status = "Noch nicht verbunden" + status = AppLanguage.text("Noch nicht verbunden", "Not connected yet") } func requestStatus() { @@ -209,7 +209,10 @@ final class CodexPadEventService: @unchecked Sendable { // CPU. Only a real change is propagated. guard newStatus != firmwareStatus else { return } firmwareStatus = newStatus - status = "Firmware \(newStatus.version) · Protokoll v\(bytes[2])" + status = AppLanguage.text( + "Firmware \(newStatus.version) · Protokoll v\(bytes[2])", + "Firmware \(newStatus.version) · protocol v\(bytes[2])" + ) if lastLoggedPressedMask != newStatus.pressedMask { lastLoggedPressedMask = newStatus.pressedMask logger.info("Pressed mask changed: 0x\(String(newStatus.pressedMask, radix: 16), privacy: .public)") diff --git a/Services/CodexPadHIDClient.swift b/Services/CodexPadHIDClient.swift index e3d37f8..94d91a0 100644 --- a/Services/CodexPadHIDClient.swift +++ b/Services/CodexPadHIDClient.swift @@ -7,7 +7,10 @@ struct CodexPadHIDClient { func send(_ packets: [[UInt8]]) -> ProcessResult { guard packets.allSatisfy({ $0.count == CodexPadPacketEncoder.packetSize }) else { - return failure("Interner Fehler: HID-Paket ist nicht 32 Byte lang.") + return failure(AppLanguage.text( + "Interner Fehler: HID-Paket ist nicht 32 Byte lang.", + "Internal error: the HID packet is not 32 bytes long." + )) } let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) @@ -20,16 +23,31 @@ struct CodexPadHIDClient { IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary) let managerResult = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) guard managerResult == kIOReturnSuccess else { - return failure(String(format: "Raw-HID-Manager konnte nicht geöffnet werden: 0x%08X", managerResult)) + return failure(String( + format: AppLanguage.text( + "Raw-HID-Manager konnte nicht geöffnet werden: 0x%08X", + "Could not open the raw HID manager: 0x%08X" + ), + managerResult + )) } defer { IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) } guard let devices = IOHIDManagerCopyDevices(manager) as? Set, let device = devices.first else { - return failure("Agent Micro Raw-HID-Interface FF60:0061 wurde nicht gefunden.") + return failure(AppLanguage.text( + "Agent Micro Raw-HID-Interface FF60:0061 wurde nicht gefunden.", + "The Agent Micro raw HID interface FF60:0061 was not found." + )) } let openResult = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) guard openResult == kIOReturnSuccess else { - return failure(String(format: "Agent Micro Raw-HID konnte nicht geöffnet werden: 0x%08X", openResult)) + return failure(String( + format: AppLanguage.text( + "Agent Micro Raw-HID konnte nicht geöffnet werden: 0x%08X", + "Could not open Agent Micro raw HID: 0x%08X" + ), + openResult + )) } defer { IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone)) } @@ -39,12 +57,21 @@ struct CodexPadHIDClient { IOHIDDeviceSetReport(device, kIOHIDReportTypeOutput, 0, buffer.baseAddress!, buffer.count) } guard result == kIOReturnSuccess else { - return failure(String(format: "HID-Ausgabereport fehlgeschlagen: 0x%08X", result)) + return failure(String( + format: AppLanguage.text( + "HID-Ausgabereport fehlgeschlagen: 0x%08X", + "HID output report failed: 0x%08X" + ), + result + )) } } return ProcessResult( exitCode: 0, - stdout: "\(packets.count) Agent-Micro-HID-Paket(e) erfolgreich übertragen.", + stdout: AppLanguage.text( + "\(packets.count) Agent-Micro-HID-Paket(e) erfolgreich übertragen.", + "\(packets.count) Agent Micro HID packet(s) transferred successfully." + ), stderr: "", timedOut: false, launchError: nil diff --git a/Services/CodexPadPacketEncoder.swift b/Services/CodexPadPacketEncoder.swift index 3fa39a2..df91caa 100644 --- a/Services/CodexPadPacketEncoder.swift +++ b/Services/CodexPadPacketEncoder.swift @@ -8,11 +8,20 @@ enum CodexPadPacketError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .unsupportedAction(let control, let value): - "\(control.title) wird von der CH552-Firmware noch nicht direkt unterstützt: \(value)" + AppLanguage.text( + "\(control.title) wird von der CH552-Firmware noch nicht direkt unterstützt: \(value)", + "\(control.title) is not yet supported directly by the CH552 firmware: \(value)" + ) case .invalidMacro(let control, let value): - "Ungültiger Geräteausdruck für \(control.title): \(value)" + AppLanguage.text( + "Ungültiger Geräteausdruck für \(control.title): \(value)", + "Invalid device expression for \(control.title): \(value)" + ) case .tooManySteps(let control): - "\(control.title) enthält mehr als fünf Tastenschritte." + AppLanguage.text( + "\(control.title) enthält mehr als fünf Tastenschritte.", + "\(control.title) contains more than five key steps." + ) } } } diff --git a/Services/CodexReasoningAutomationService.swift b/Services/CodexReasoningAutomationService.swift index 14c5009..21a4f68 100644 --- a/Services/CodexReasoningAutomationService.swift +++ b/Services/CodexReasoningAutomationService.swift @@ -10,19 +10,19 @@ enum CodexEncoderCommand { var title: String { switch self { - case .decreaseEffort: "Aufwand verringern" - case .openPicker: "Model Picker öffnen" - case .closePicker: "Model Picker schließen" - case .increaseEffort: "Aufwand erhöhen" + case .decreaseEffort: AppLanguage.text("Aufwand verringern", "Decrease effort") + case .openPicker: AppLanguage.text("Model Picker öffnen", "Open Model Picker") + case .closePicker: AppLanguage.text("Model Picker schließen", "Close Model Picker") + case .increaseEffort: AppLanguage.text("Aufwand erhöhen", "Increase effort") } } var detail: String { switch self { - case .decreaseEffort: "Direkter Codex-Shortcut F18" - case .openPicker: "Picker öffnen" - case .closePicker: "Picker schließen (Esc)" - case .increaseEffort: "Direkter Codex-Shortcut F19" + case .decreaseEffort: AppLanguage.text("Direkter Codex-Shortcut F18", "Direct Codex shortcut F18") + case .openPicker: AppLanguage.text("Picker öffnen", "Open picker") + case .closePicker: AppLanguage.text("Picker schließen (Esc)", "Close picker (Esc)") + case .increaseEffort: AppLanguage.text("Direkter Codex-Shortcut F19", "Direct Codex shortcut F19") } } @@ -101,8 +101,8 @@ final class CodexReasoningAutomationService: EncoderAutomationService { } private let permissionMonitor: PermissionMonitor - private(set) var status = "Deaktiviert" - private(set) var lastInput = "Noch kein Drehrad-Signal empfangen" + private(set) var status = AppLanguage.text("Deaktiviert", "Disabled") + private(set) var lastInput = AppLanguage.text("Noch kein Drehrad-Signal empfangen", "No dial signal received yet") var hasAccessibilityPermission: Bool { permissionMonitor.hasAccessibilityPermission } var hasInputMonitoringPermission: Bool { permissionMonitor.hasInputMonitoringPermission } var isEnabled: Bool { @@ -131,7 +131,10 @@ final class CodexReasoningAutomationService: EncoderAutomationService { } updateMonitoring() if !hasAccessibilityPermission || (!usesPhysicalEncoderEvents && !hasInputMonitoringPermission) { - status = "Berechtigungen fehlen noch. In den Systemeinstellungen Agent Micro aktivieren und danach zur App zurückkehren." + status = AppLanguage.text( + "Berechtigungen fehlen noch. In den Systemeinstellungen Agent Micro aktivieren und danach zur App zurückkehren.", + "Permissions are still missing. Enable Agent Micro in System Settings, then come back to the app." + ) } } @@ -147,7 +150,7 @@ final class CodexReasoningAutomationService: EncoderAutomationService { func perform(_ command: CodexEncoderCommand) -> Bool { guard let codex = readyCodexApplication() else { return false } codex.activate(options: [.activateAllWindows]) - status = "Ausführen: \(command.title)" + status = AppLanguage.text("Ausführen: \(command.title)", "Running: \(command.title)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in if let keyCode = command.directShortcutKeyCode { @@ -157,11 +160,11 @@ final class CodexReasoningAutomationService: EncoderAutomationService { } switch command { case .openPicker: - self?.status = "Picker offen: erneut drücken zum Schließen." + self?.status = AppLanguage.text("Picker offen: erneut drücken zum Schließen.", "Picker open: press again to close.") case .closePicker: - self?.status = "Picker geschlossen." + self?.status = AppLanguage.text("Picker geschlossen.", "Picker closed.") default: - self?.status = "Fertig: \(command.title)" + self?.status = AppLanguage.text("Fertig: \(command.title)", "Done: \(command.title)") } } return true @@ -249,7 +252,10 @@ final class CodexReasoningAutomationService: EncoderAutomationService { guard let codex = readyCodexApplication() else { return } codex.activate(options: [.activateAllWindows]) isModelListOpen = true - status = "Modellliste offen: drehen wählt, loslassen übernimmt." + status = AppLanguage.text( + "Modellliste offen: drehen wählt, loslassen übernimmt.", + "Model list open: turn to choose, release to confirm." + ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { Self.openModelPickerShortcut() let upArrow = UInt16(kVK_UpArrow) @@ -275,11 +281,11 @@ final class CodexReasoningAutomationService: EncoderAutomationService { isModelListOpen = false guard let codex = readyCodexApplication() else { return } codex.activate(options: [.activateAllWindows]) - status = "Modell übernehmen …" + status = AppLanguage.text("Modell übernehmen …", "Applying model…") Self.postKey(UInt16(kVK_Return)) DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in Self.postKey(UInt16(kVK_Escape)) - self?.status = "Übernommen." + self?.status = AppLanguage.text("Übernommen.", "Applied.") } } @@ -292,7 +298,9 @@ final class CodexReasoningAutomationService: EncoderAutomationService { guard encoderHoldFired, isModelListOpen else { return } guard readyCodexApplication() != nil else { return } let keyCode = step == .next ? UInt16(kVK_DownArrow) : UInt16(kVK_UpArrow) - status = step == .next ? "Nächstes Modell" : "Vorheriges Modell" + status = step == .next + ? AppLanguage.text("Nächstes Modell", "Next model") + : AppLanguage.text("Vorheriges Modell", "Previous model") Self.postKey(keyCode) } @@ -325,16 +333,16 @@ final class CodexReasoningAutomationService: EncoderAutomationService { if openDismissibleArea == area { openDismissibleArea = nil - status = "Schließen: \(area.title)" + status = AppLanguage.text("Schließen: \(area.title)", "Closing: \(area.title)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in Self.postKey(UInt16(kVK_Escape)) - self?.status = "\(area.title) geschlossen." + self?.status = AppLanguage.text("\(area.title) geschlossen.", "\(area.title) closed.") } return } openDismissibleArea = area - status = "Öffnen: \(area.title)" + status = AppLanguage.text("Öffnen: \(area.title)", "Opening: \(area.title)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in switch area { case .modelPicker: @@ -344,7 +352,10 @@ final class CodexReasoningAutomationService: EncoderAutomationService { case .sideChat: break } - self?.status = "\(area.title) offen: erneut drücken zum Schließen." + self?.status = AppLanguage.text( + "\(area.title) offen: erneut drücken zum Schließen.", + "\(area.title) open: press again to close." + ) } } @@ -355,15 +366,18 @@ final class CodexReasoningAutomationService: EncoderAutomationService { private func readyCodexApplication() -> NSRunningApplication? { guard isEnabled else { - status = "Encoder-Steuerung ist deaktiviert." + status = AppLanguage.text("Encoder-Steuerung ist deaktiviert.", "Dial control is disabled.") return nil } guard hasAccessibilityPermission else { - status = "Bedienungshilfen fehlen. Bitte unten Berechtigungen anfordern." + status = AppLanguage.text( + "Bedienungshilfen fehlen. Bitte unten Berechtigungen anfordern.", + "Accessibility permission is missing. Request permissions below." + ) return nil } guard let codex = codexApplication else { - status = "Codex läuft nicht." + status = AppLanguage.text("Codex läuft nicht.", "Codex is not running.") return nil } return codex @@ -374,12 +388,15 @@ final class CodexReasoningAutomationService: EncoderAutomationService { openDismissibleArea = nil resetModelPickerNavigation() guard isEnabled else { - status = "Deaktiviert" + status = AppLanguage.text("Deaktiviert", "Disabled") return } guard hasInputMonitoringPermission || usesPhysicalEncoderEvents else { - status = "Input Monitoring fehlt – macOS blockiert das Drehrad. Bitte Agent Micro unten freigeben." + status = AppLanguage.text( + "Input Monitoring fehlt – macOS blockiert das Drehrad. Bitte Agent Micro unten freigeben.", + "Input Monitoring is missing — macOS is blocking the dial. Allow Agent Micro below." + ) return } @@ -388,11 +405,17 @@ final class CodexReasoningAutomationService: EncoderAutomationService { // interface: macOS can fail to register it in Input Monitoring after // a reconnect, even though the vendor channel remains healthy. guard !usesPhysicalEncoderEvents else { - status = "Bereit: Drehrad läuft über das direkte Pad-Protokoll. Nur Bedienungshilfen werden benötigt." + status = AppLanguage.text( + "Bereit: Drehrad läuft über das direkte Pad-Protokoll. Nur Bedienungshilfen werden benötigt.", + "Ready: the dial runs over the direct pad protocol. Only Accessibility is required." + ) return } - status = "Bereit: Drehen/Drücken = Aufwand & Modellwahl · Halten (>\(Int(Self.modelListHoldThresholdSeconds * 1000)) ms) + drehen = Modell wechseln." + status = AppLanguage.text( + "Bereit: Drehen/Drücken = Aufwand & Modellwahl · Halten (>\(Int(Self.modelListHoldThresholdSeconds * 1000)) ms) + drehen = Modell wechseln.", + "Ready: turn/press = effort & model selection · hold (>\(Int(Self.modelListHoldThresholdSeconds * 1000)) ms) + turn = switch model." + ) let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) let keyboardIdentity: (Int, Int) -> [String: Any] = { vendorID, productID in @@ -409,7 +432,10 @@ final class CodexReasoningAutomationService: EncoderAutomationService { IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) guard result == kIOReturnSuccess else { - status = "Drehrad konnte nicht geöffnet werden (\(result))." + status = AppLanguage.text( + "Drehrad konnte nicht geöffnet werden (\(result)).", + "Could not open the dial (\(result))." + ) IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) return } @@ -458,7 +484,9 @@ final class CodexReasoningAutomationService: EncoderAutomationService { } private func recordInput(usage: Int, value: Int) { - lastInput = HIDInputEvent.functionKeyName(usage).map { "Empfangen: \($0)" } ?? String(format: "Empfangen: 0x%02X", usage) + lastInput = HIDInputEvent.functionKeyName(usage) + .map { AppLanguage.text("Empfangen: \($0)", "Received: \($0)") } + ?? String(format: AppLanguage.text("Empfangen: 0x%02X", "Received: 0x%02X"), usage) logger.info("HID input usage=\(usage, privacy: .public) value=\(value, privacy: .public)") } diff --git a/Services/DeviceDetector.swift b/Services/DeviceDetector.swift index d8bb3cf..3483eb5 100644 --- a/Services/DeviceDetector.swift +++ b/Services/DeviceDetector.swift @@ -41,7 +41,10 @@ struct DeviceDetector { device: nil, candidates: candidates, rawIORegistry: result.stdout, - error: "Kein unterstütztes Agent-Micro-Gerät gefunden. Sichtbare USB-Kennungen: \(summary.isEmpty ? "keine" : summary)." + error: AppLanguage.text( + "Kein unterstütztes Agent-Micro-Gerät gefunden. Sichtbare USB-Kennungen: \(summary.isEmpty ? "keine" : summary).", + "No supported Agent Micro device found. Visible USB identifiers: \(summary.isEmpty ? "none" : summary)." + ) ) } @@ -50,7 +53,7 @@ struct DeviceDetector { var seen = Set() return sections.compactMap { section in guard let vendorID = integer(named: "idVendor", in: section), let productID = integer(named: "idProduct", in: section) else { return nil } - let locationID = string(named: "locationID", in: section) ?? "unbekannt" + let locationID = string(named: "locationID", in: section) ?? AppLanguage.text("unbekannt", "unknown") let identity = "\(vendorID)-\(productID)-\(locationID)" guard seen.insert(identity).inserted else { return nil } return makeDevice(vendorID: vendorID, productID: productID, locationID: locationID, section: section) @@ -81,27 +84,39 @@ struct DeviceDetector { if vendorID == 0x4249 && productID == 0x4287 { support = .supported capabilities = .codexPadCH552 - summary = "Eigene CH552-Agent-Micro-Firmware erkannt. Sechs Tasten, Encoder und sechs einzeln steuerbare RGB-LEDs sind firmwarebestätigt." + summary = AppLanguage.text( + "Eigene CH552-Agent-Micro-Firmware erkannt. Sechs Tasten, Encoder und sechs einzeln steuerbare RGB-LEDs sind firmwarebestätigt.", + "Custom CH552 Agent Micro firmware detected. Six keys, the dial, and six individually controllable RGB LEDs are firmware-confirmed." + ) } else if vendorID == 0x1189 && productID == 0x8890 { support = .supported capabilities = .ch57x8890 - summary = "CH57x-2 erkannt. Das Boot-HID-Keyboard und das separate Konfigurationsinterface werden vom MIT-Helper für 0x1189:0x8890 unterstützt." + summary = AppLanguage.text( + "CH57x-2 erkannt. Das Boot-HID-Keyboard und das separate Konfigurationsinterface werden vom MIT-Helper für 0x1189:0x8890 unterstützt.", + "CH57x-2 detected. The boot HID keyboard and the separate configuration interface are supported by the MIT helper for 0x1189:0x8890." + ) } else if vendorID == 0x1189 && [0x8840, 0x8842].contains(productID) { support = .related capabilities = .unsupported - summary = "CH57x-Variante erkannt. Der gebündelte Helper kennt diese Kennung, Agent Micro verifiziert Uploads jedoch derzeit nur für das getestete 3×2-Modell 0x8890." + summary = AppLanguage.text( + "CH57x-Variante erkannt. Der gebündelte Helper kennt diese Kennung, Agent Micro verifiziert Uploads jedoch derzeit nur für das getestete 3×2-Modell 0x8890.", + "CH57x variant detected. The bundled helper knows this identifier, but Agent Micro currently verifies transfers only for the tested 3×2 model 0x8890." + ) } else if vendorID == 0x1189 { support = .related capabilities = .unsupported - summary = "CH57x-verwandtes USB-Gerät erkannt, aber seine Protokollvariante ist nicht verifiziert. Diagnose kann die Kennung auswählen; Upload bleibt gesperrt." + summary = AppLanguage.text( + "CH57x-verwandtes USB-Gerät erkannt, aber seine Protokollvariante ist nicht verifiziert. Diagnose kann die Kennung auswählen; Upload bleibt gesperrt.", + "A CH57x-related USB device was detected, but its protocol variant is unverified. Diagnostics can select the identifier; transfer stays blocked." + ) } else { support = .unsupported capabilities = .unsupported - summary = "Nicht als CH57x erkannt." + summary = AppLanguage.text("Nicht als CH57x erkannt.", "Not recognized as a CH57x.") } return ConnectedDevice( - name: string(named: "USB Product Name", in: section) ?? string(named: "kUSBProductString", in: section) ?? ([0x1189, 0x4249].contains(vendorID) ? "Agent Micro" : "USB-Gerät"), + name: string(named: "USB Product Name", in: section) ?? string(named: "kUSBProductString", in: section) ?? ([0x1189, 0x4249].contains(vendorID) ? "Agent Micro" : AppLanguage.text("USB-Gerät", "USB device")), vendorID: vendorID, productID: productID, locationID: locationID, diff --git a/Services/DeviceService.swift b/Services/DeviceService.swift index 6703cdb..eacb761 100644 --- a/Services/DeviceService.swift +++ b/Services/DeviceService.swift @@ -52,18 +52,28 @@ final class DeviceService { if device.support == .supported { state = .connected(device) if reportDiagnostics { - diagnostics.append(.success, "Unterstütztes Gerät erkannt", detail: "\(device.name) · \(device.vendorIDHex):\(device.productIDHex)") + diagnostics.append(.success, AppLanguage.text("Unterstütztes Gerät erkannt", "Supported device detected"), detail: "\(device.name) · \(device.vendorIDHex):\(device.productIDHex)") } } else { state = .unsupported(device) if reportDiagnostics { - diagnostics.append(.warning, device.support == .related ? "CH57x-HID erkannt, aber nicht konfigurierbar" : "Nicht unterstütztes USB-Gerät", detail: device.diagnosticSummary) + diagnostics.append( + .warning, + device.support == .related + ? AppLanguage.text("CH57x-HID erkannt, aber nicht konfigurierbar", "CH57x HID detected, but not configurable") + : AppLanguage.text("Nicht unterstütztes USB-Gerät", "Unsupported USB device"), + detail: device.diagnosticSummary + ) } } } else { state = .disconnected if reportDiagnostics { - diagnostics.append(.warning, "Kein unterstütztes Gerät", detail: report.error ?? "Unbekannter Erkennungsfehler") + diagnostics.append( + .warning, + AppLanguage.text("Kein unterstütztes Gerät", "No supported device"), + detail: report.error ?? AppLanguage.text("Unbekannter Erkennungsfehler", "Unknown detection error") + ) } } } @@ -78,11 +88,20 @@ final class DeviceService { if currentDevice?.isCodexPadFirmware == true { do { let packets = try codexPadEncoder.uploadPackets(profile: profile, layout: keyboardLayout) - let result = ProcessResult(exitCode: 0, stdout: "\(packets.count) lokale HID-Pakete sind gültig.", stderr: "", timedOut: false, launchError: nil) - diagnostics.record(result, title: "CH552-Profil validieren") + let result = ProcessResult( + exitCode: 0, + stdout: AppLanguage.text( + "\(packets.count) lokale HID-Pakete sind gültig.", + "\(packets.count) local HID packets are valid." + ), + stderr: "", + timedOut: false, + launchError: nil + ) + diagnostics.record(result, title: AppLanguage.text("CH552-Profil validieren", "Validate CH552 profile")) return result } catch { - diagnostics.append(.error, "Lokale CH552-Validierung fehlgeschlagen", detail: error.localizedDescription) + diagnostics.append(.error, AppLanguage.text("Lokale CH552-Validierung fehlgeschlagen", "Local CH552 validation failed"), detail: error.localizedDescription) return nil } } @@ -90,11 +109,11 @@ final class DeviceService { let configuration = try encoder.encode(profile: profile) lastGeneratedConfiguration = configuration let result = processClient.validate(configuration: configuration) - diagnostics.record(result, title: "Konfiguration validieren") + diagnostics.record(result, title: AppLanguage.text("Konfiguration validieren", "Validate configuration")) logger.info("Validated profile \(profile.name, privacy: .public): \(result.succeeded, privacy: .public)") return result } catch { - diagnostics.append(.error, "Lokale Validierung fehlgeschlagen", detail: error.localizedDescription) + diagnostics.append(.error, AppLanguage.text("Lokale Validierung fehlgeschlagen", "Local validation failed"), detail: error.localizedDescription) return nil } } @@ -102,11 +121,15 @@ final class DeviceService { @discardableResult func upload(profile: MacropadProfile, keyboardLayout: KeyboardLayout = .usANSI) -> ProcessResult? { guard state.isSupportedConnection else { - diagnostics.append(.error, "Upload blockiert", detail: "Kein unterstütztes CH57x-Gerät verbunden.") + diagnostics.append( + .error, + AppLanguage.text("Upload blockiert", "Transfer blocked"), + detail: AppLanguage.text("Kein unterstütztes CH57x-Gerät verbunden.", "No supported CH57x device connected.") + ) return nil } guard !isBusy else { - diagnostics.append(.warning, "Upload läuft bereits") + diagnostics.append(.warning, AppLanguage.text("Upload läuft bereits", "A transfer is already running")) return nil } isBusy = true @@ -114,7 +137,11 @@ final class DeviceService { let validation = validate(profile: profile, keyboardLayout: keyboardLayout) guard validation?.succeeded == true else { - diagnostics.append(.error, "Upload nicht ausgeführt", detail: "Die Konfiguration muss zuerst erfolgreich validieren.") + diagnostics.append( + .error, + AppLanguage.text("Upload nicht ausgeführt", "Transfer not performed"), + detail: AppLanguage.text("Die Konfiguration muss zuerst erfolgreich validieren.", "The configuration has to validate successfully first.") + ) return validation } @@ -122,31 +149,52 @@ final class DeviceService { do { let packets = try codexPadEncoder.uploadPackets(profile: profile, layout: keyboardLayout) let result = codexPadClient.send(packets) - diagnostics.record(result, title: "Profil und RGB live an CH552 übertragen") + diagnostics.record(result, title: AppLanguage.text("Profil und RGB live an CH552 übertragen", "Transfer profile and RGB live to the CH552")) if result.succeeded { lastSuccessfulUpload = .now - diagnostics.append(.success, "CH552-Profil aktiv", detail: "Neun Eingabebelegungen und die konfigurierte Idle-Beleuchtung wurden übertragen.") + diagnostics.append( + .success, + AppLanguage.text("CH552-Profil aktiv", "CH552 profile active"), + detail: AppLanguage.text( + "Neun Eingabebelegungen und die konfigurierte Idle-Beleuchtung wurden übertragen.", + "Nine input mappings and the configured idle lighting were transferred." + ) + ) } return result } catch { - diagnostics.append(.error, "CH552-Upload fehlgeschlagen", detail: error.localizedDescription) + diagnostics.append(.error, AppLanguage.text("CH552-Upload fehlgeschlagen", "CH552 transfer failed"), detail: error.localizedDescription) return nil } } let target = CH57xDeviceTarget.ch57x8890 guard target.matches(currentDevice) else { - diagnostics.append(.error, "Upload blockiert", detail: "Das erkannte Gerät stimmt nicht mit dem sicheren Ziel \(String(format: "%04X:%04X", target.vendorID, target.productID)) überein.") + diagnostics.append( + .error, + AppLanguage.text("Upload blockiert", "Transfer blocked"), + detail: AppLanguage.text( + "Das erkannte Gerät stimmt nicht mit dem sicheren Ziel \(String(format: "%04X:%04X", target.vendorID, target.productID)) überein.", + "The detected device does not match the safe target \(String(format: "%04X:%04X", target.vendorID, target.productID))." + ) + ) return nil } let result = processClient.upload(configuration: lastGeneratedConfiguration, target: target) - diagnostics.record(result, title: "Konfiguration auf Gerät übertragen") + diagnostics.record(result, title: AppLanguage.text("Konfiguration auf Gerät übertragen", "Transfer configuration to the device")) if result.succeeded { lastSuccessfulUpload = .now - diagnostics.append(.success, "Transport erfolgreich", detail: "Helper: Exit 0, stderr leer, Ziel \(String(format: "%04X:%04X", target.vendorID, target.productID)), vorherige Validierung erfolgreich. Die physische Eingabe muss im Eingabe-Test bestätigt werden.") + diagnostics.append( + .success, + AppLanguage.text("Transport erfolgreich", "Transport succeeded"), + detail: AppLanguage.text( + "Helper: Exit 0, stderr leer, Ziel \(String(format: "%04X:%04X", target.vendorID, target.productID)), vorherige Validierung erfolgreich. Die physische Eingabe muss im Eingabe-Test bestätigt werden.", + "Helper: exit 0, stderr empty, target \(String(format: "%04X:%04X", target.vendorID, target.productID)), prior validation successful. Physical input still has to be confirmed in the input test." + ) + ) } else { - diagnostics.append(.error, "Upload nicht bestätigt", detail: result.failureDescription) + diagnostics.append(.error, AppLanguage.text("Upload nicht bestätigt", "Transfer not confirmed"), detail: result.failureDescription) } return result } @@ -155,11 +203,18 @@ final class DeviceService { func setLEDMode(_ mode: Int) -> ProcessResult? { guard state.isSupportedConnection, currentDevice?.capabilities.supportedLEDModes.contains(mode) == true else { - diagnostics.append(.error, "LED-Modus nicht gesetzt", detail: "Kein unterstütztes Gerät verbunden oder LED-Modus \(mode) nicht verfügbar.") + diagnostics.append( + .error, + AppLanguage.text("LED-Modus nicht gesetzt", "LED mode not set"), + detail: AppLanguage.text( + "Kein unterstütztes Gerät verbunden oder LED-Modus \(mode) nicht verfügbar.", + "No supported device connected, or LED mode \(mode) is unavailable." + ) + ) return nil } let result = processClient.setConfirmedLEDMode(mode, target: .ch57x8890) - diagnostics.record(result, title: "LED-Modus \(mode) setzen") + diagnostics.record(result, title: AppLanguage.text("LED-Modus \(mode) setzen", "Set LED mode \(mode)")) return result } @@ -172,11 +227,17 @@ final class DeviceService { func applyLEDs(_ settings: [KeyLEDConfiguration]) -> ProcessResult? { guard !settings.isEmpty else { return nil } guard currentDevice?.isCodexPadFirmware == true else { - diagnostics.append(.error, "RGB nicht übertragen", detail: "Keine eigene CH552-Agent-Micro-Firmware verbunden.") + diagnostics.append( + .error, + AppLanguage.text("RGB nicht übertragen", "RGB not transferred"), + detail: AppLanguage.text("Keine eigene CH552-Agent-Micro-Firmware verbunden.", "No custom CH552 Agent Micro firmware connected.") + ) return nil } let result = codexPadClient.send(settings.map(codexPadEncoder.ledPacket)) - let title = settings.count == 1 ? "\(settings[0].control.title) RGB anwenden" : "Alle RGB-Einstellungen anwenden" + let title = settings.count == 1 + ? AppLanguage.text("\(settings[0].control.title) RGB anwenden", "Apply RGB for \(settings[0].control.title)") + : AppLanguage.text("Alle RGB-Einstellungen anwenden", "Apply all RGB settings") diagnostics.record(result, title: title) return result } @@ -185,7 +246,7 @@ final class DeviceService { func turnOffCustomLEDs() -> ProcessResult? { guard currentDevice?.isCodexPadFirmware == true else { return nil } let result = codexPadClient.send([codexPadEncoder.allOffPacket()]) - diagnostics.record(result, title: "Alle CH552-LEDs ausschalten") + diagnostics.record(result, title: AppLanguage.text("Alle CH552-LEDs ausschalten", "Turn off all CH552 LEDs")) return result } } diff --git a/Services/HIDInputMonitor.swift b/Services/HIDInputMonitor.swift index 89db924..5a9dca4 100644 --- a/Services/HIDInputMonitor.swift +++ b/Services/HIDInputMonitor.swift @@ -55,7 +55,7 @@ final class HIDInputMonitor { private var manager: IOHIDManager? private let permissionMonitor: PermissionMonitor private(set) var isMonitoring = false - private(set) var status = "Nicht gestartet" + private(set) var status = AppLanguage.text("Nicht gestartet", "Not started") private(set) var events: [HIDInputEvent] = [] var hasInputMonitoringPermission: Bool { permissionMonitor.hasInputMonitoringPermission } @@ -81,7 +81,10 @@ final class HIDInputMonitor { let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) guard result == kIOReturnSuccess else { - status = "HID-Monitor konnte nicht geöffnet werden (IOReturn \(result)). Aktiviere bei Bedarf Input Monitoring für Agent Micro." + status = AppLanguage.text( + "HID-Monitor konnte nicht geöffnet werden (IOReturn \(result)). Aktiviere bei Bedarf Input Monitoring für Agent Micro.", + "Could not open the HID monitor (IOReturn \(result)). Enable Input Monitoring for Agent Micro if needed." + ) IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) return } @@ -89,8 +92,14 @@ final class HIDInputMonitor { self.manager = manager isMonitoring = true status = hasInputMonitoringPermission - ? "Lauscht auf physische Tastendrücke des CH57x-Keyboards." - : "Lauscht auf physische Tastendrücke. Falls keine Ereignisse eintreffen, Input Monitoring für Agent Micro erlauben." + ? AppLanguage.text( + "Lauscht auf physische Tastendrücke des CH57x-Keyboards.", + "Listening for physical key presses from the CH57x keyboard." + ) + : AppLanguage.text( + "Lauscht auf physische Tastendrücke. Falls keine Ereignisse eintreffen, Input Monitoring für Agent Micro erlauben.", + "Listening for physical key presses. If no events arrive, allow Input Monitoring for Agent Micro." + ) } func stop() { @@ -99,7 +108,7 @@ final class HIDInputMonitor { IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) self.manager = nil isMonitoring = false - status = "Gestoppt" + status = AppLanguage.text("Gestoppt", "Stopped") } func clear() { events.removeAll() } diff --git a/Services/PermissionMonitor.swift b/Services/PermissionMonitor.swift index eff51f8..5ec6dd0 100644 --- a/Services/PermissionMonitor.swift +++ b/Services/PermissionMonitor.swift @@ -107,14 +107,20 @@ final class PermissionMonitor { let inputMonitoring = CGPreflightListenEventAccess() if hasAccessibilityPermission, !accessibility { notifyRevoked( - title: "Bedienungshilfen entzogen", - body: "Agent Micro kann keine Tastenkombinationen mehr senden. Bitte in Systemeinstellungen → Datenschutz & Sicherheit → Bedienungshilfen wieder erlauben." + title: AppLanguage.text("Bedienungshilfen entzogen", "Accessibility permission revoked"), + body: AppLanguage.text( + "Agent Micro kann keine Tastenkombinationen mehr senden. Bitte in Systemeinstellungen → Datenschutz & Sicherheit → Bedienungshilfen wieder erlauben.", + "Agent Micro can no longer send keyboard shortcuts. Re-enable it in System Settings → Privacy & Security → Accessibility." + ) ) } if hasInputMonitoringPermission, !inputMonitoring { notifyRevoked( - title: "Input Monitoring entzogen", - body: "Legacy-Keyboard-HID und der passive Diagnosemonitor können keine Eingaben mehr sehen. Das direkte Agent-Micro-Pad-Protokoll bleibt davon unberührt." + title: AppLanguage.text("Input Monitoring entzogen", "Input Monitoring revoked"), + body: AppLanguage.text( + "Legacy-Keyboard-HID und der passive Diagnosemonitor können keine Eingaben mehr sehen. Das direkte Agent-Micro-Pad-Protokoll bleibt davon unberührt.", + "Legacy keyboard HID and the passive diagnostics monitor can no longer see input. The direct Agent Micro pad protocol is unaffected." + ) ) } hasAccessibilityPermission = accessibility diff --git a/Services/ProcessRunner.swift b/Services/ProcessRunner.swift index 89a10ee..14bc82d 100644 --- a/Services/ProcessRunner.swift +++ b/Services/ProcessRunner.swift @@ -30,10 +30,10 @@ struct ProcessResult: Sendable, Hashable { var failureDescription: String { if let launchError { return launchError } - if timedOut { return "Zeitüberschreitung" } - if exitCode != 0 { return "Exit-Code \(exitCode)" } + if timedOut { return AppLanguage.text("Zeitüberschreitung", "Timed out") } + if exitCode != 0 { return AppLanguage.text("Exit-Code \(exitCode)", "Exit code \(exitCode)") } if !stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return stderr } - return "Unbekannter Prozessfehler" + return AppLanguage.text("Unbekannter Prozessfehler", "Unknown process error") } } diff --git a/Stores/AppState.swift b/Stores/AppState.swift index c0c4cca..e86144c 100644 --- a/Stores/AppState.swift +++ b/Stores/AppState.swift @@ -448,7 +448,14 @@ final class AppState { if previousID != nil { padEvents.refresh(enabled: false) keyboardState.refresh(enabled: false) - diagnostics.append(.warning, "Agent Micro getrennt", detail: "Eingabe- und LED-Dienste wurden angehalten.") + diagnostics.append( + .warning, + AppLanguage.text("Agent Micro getrennt", "Agent Micro disconnected"), + detail: AppLanguage.text( + "Eingabe- und LED-Dienste wurden angehalten.", + "Input and LED services have been stopped." + ) + ) } observedDeviceID = nil return @@ -476,16 +483,26 @@ final class AppState { keyboardState.refresh(enabled: customFirmware) let transfer = transferCurrentConfiguration() if transfer?.succeeded == true { + let profileName = transfer?.profileName ?? AppLanguage.text("Profil", "Profile") + let layerName = transfer?.layerName ?? "Layer" diagnostics.append( .success, - previousID == nil ? "Agent Micro initialisiert" : "Agent Micro neu initialisiert", - detail: "\(transfer?.profileName ?? "Profil") · \(transfer?.layerName ?? "Layer") · Eingaben und Live-LEDs aktiv" + previousID == nil + ? AppLanguage.text("Agent Micro initialisiert", "Agent Micro initialized") + : AppLanguage.text("Agent Micro neu initialisiert", "Agent Micro reinitialized"), + detail: AppLanguage.text( + "\(profileName) · \(layerName) · Eingaben und Live-LEDs aktiv", + "\(profileName) · \(layerName) · input and live LEDs active" + ) ) } else if reportDiagnostics { diagnostics.append( .error, - "Agent Micro Initialisierung unvollständig", - detail: "Das Gerät wurde erkannt, aber das aktive Profil konnte nicht bestätigt werden. Mit „Gerät erneut suchen“ oder „Übertragen“ kann der Versuch bewusst wiederholt werden." + AppLanguage.text("Agent Micro Initialisierung unvollständig", "Agent Micro setup incomplete"), + detail: AppLanguage.text( + "Das Gerät wurde erkannt, aber das aktive Profil konnte nicht bestätigt werden. Mit „Gerät erneut suchen“ oder „Übertragen“ kann der Versuch bewusst wiederholt werden.", + "The device was detected, but the active profile could not be confirmed. Use “Scan for device again” or “Transfer” to retry deliberately." + ) ) } } diff --git a/Stores/CodexThreadStore.swift b/Stores/CodexThreadStore.swift index bc39f1b..8efa69f 100644 --- a/Stores/CodexThreadStore.swift +++ b/Stores/CodexThreadStore.swift @@ -49,8 +49,14 @@ final class CodexThreadStore { var liveStatusAvailability: AgentLiveStatusAvailability { bridge.liveStatusAvailability } var sessionNavigationSummary: String { automationApp == .claude - ? "Claude-Sitzungen werden über ihre Bridge-Sitzungs-ID (`session_…`) in der bestehenden Desktop-Unterhaltung geöffnet. Ältere Sitzungen ohne diese ID lassen sich nicht direkt anspringen; ein Tastendruck holt dann nur Claude nach vorn." - : "Codex öffnet den zugewiesenen Thread per Deep Link." + ? AppLanguage.text( + "Claude-Sitzungen werden über ihre Bridge-Sitzungs-ID (`session_…`) in der bestehenden Desktop-Unterhaltung geöffnet. Ältere Sitzungen ohne diese ID lassen sich nicht direkt anspringen; ein Tastendruck holt dann nur Claude nach vorn.", + "Claude sessions open in the existing desktop conversation via their bridge session ID (`session_…`). Older sessions without that ID cannot be jumped to directly; a key press then only brings Claude to the front." + ) + : AppLanguage.text( + "Codex öffnet den zugewiesenen Thread per Deep Link.", + "Codex opens the assigned thread via deep link." + ) } func start() { diff --git a/Stores/DiagnosticsStore.swift b/Stores/DiagnosticsStore.swift index dc15c67..3ca5a30 100644 --- a/Stores/DiagnosticsStore.swift +++ b/Stores/DiagnosticsStore.swift @@ -27,7 +27,7 @@ struct AgentStatusTrace: Identifiable, Hashable { final class DiagnosticsStore { private(set) var entries: [DiagnosticEntry] = [] private(set) var statusTraces: [AgentStatusTrace] = [] - private(set) var rawIORegistry: String = "Noch nicht erfasst." + private(set) var rawIORegistry: String = AppLanguage.text("Noch nicht erfasst.", "Not captured yet.") func append(_ level: DiagnosticEntry.Level, _ title: String, detail: String = "") { entries.insert(DiagnosticEntry(level: level, title: title, detail: detail), at: 0) diff --git a/Stores/ProfileStore.swift b/Stores/ProfileStore.swift index 5f5f0d7..256ffcb 100644 --- a/Stores/ProfileStore.swift +++ b/Stores/ProfileStore.swift @@ -298,7 +298,7 @@ final class ProfileStore { guard let source = profile.layers.first(where: { $0.id == layerID }) else { return } var copy = source copy.id = UUID() - copy.name = uniqueLayerName("\(source.name) Kopie", in: profile) + copy.name = uniqueLayerName(AppLanguage.text("\(source.name) Kopie", "\(source.name) copy"), in: profile) profile.layers.append(copy) profile.activeLayerID = copy.id profile.updatedAt = .now @@ -402,7 +402,7 @@ final class ProfileStore { func newProfile() { let profile = ProfileFactory.safe() var renamed = profile - renamed.name = uniqueName("Neues Profil") + renamed.name = uniqueName(AppLanguage.text("Neues Profil", "New profile")) profiles.append(renamed) selectedProfileID = renamed.id commitChange() @@ -411,7 +411,7 @@ final class ProfileStore { func duplicateSelected() { var duplicate = selectedProfile duplicate.id = UUID() - duplicate.name = uniqueName("\(selectedProfile.name) Kopie") + duplicate.name = uniqueName(AppLanguage.text("\(selectedProfile.name) Kopie", "\(selectedProfile.name) copy")) duplicate.createdAt = .now duplicate.updatedAt = .now duplicate.isBuiltIn = false @@ -422,7 +422,9 @@ final class ProfileStore { func renameSelected(to name: String) { var profile = selectedProfile - profile.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Unbenanntes Profil" : name + profile.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? AppLanguage.text("Unbenanntes Profil", "Untitled profile") + : name replace(profile) } @@ -526,12 +528,18 @@ final class ProfileStore { backupRawFile(data, persistenceURL: url, reason: "corrupt") guard let recovered = ProfileFileCodec.decodeLeniently(data) else { - warning = "Profiles.json war beschädigt und konnte nicht gelesen werden. Eine Sicherung liegt neben der Datei, die Belegung wurde auf Werkseinstellungen zurückgesetzt." + warning = AppLanguage.text( + "Profiles.json war beschädigt und konnte nicht gelesen werden. Eine Sicherung liegt neben der Datei, die Belegung wurde auf Werkseinstellungen zurückgesetzt.", + "Profiles.json was corrupt and could not be read. A backup was saved next to the file and the mapping has been reset to factory defaults." + ) logger.error("Profiles.json fully unreadable, resetting to defaults") return nil } if recovered.droppedCount > 0 { - warning = "\(recovered.droppedCount) Profil(e) in Profiles.json waren beschädigt und wurden übersprungen. Die übrigen \(recovered.profiles.count) wurden wiederhergestellt; eine Sicherung liegt neben der Datei." + warning = AppLanguage.text( + "\(recovered.droppedCount) Profil(e) in Profiles.json waren beschädigt und wurden übersprungen. Die übrigen \(recovered.profiles.count) wurden wiederhergestellt; eine Sicherung liegt neben der Datei.", + "\(recovered.droppedCount) profile(s) in Profiles.json were corrupt and have been skipped. The remaining \(recovered.profiles.count) were recovered; a backup was saved next to the file." + ) logger.error("Recovered \(recovered.profiles.count) profile(s), dropped \(recovered.droppedCount)") } return recovered.profiles diff --git a/Tests/AgentMicroTests.swift b/Tests/AgentMicroTests.swift index 96e2e56..49b024d 100644 --- a/Tests/AgentMicroTests.swift +++ b/Tests/AgentMicroTests.swift @@ -4,6 +4,42 @@ import XCTest @testable import AgentMicro final class AgentMicroTests: XCTestCase { + /// Guards the English localization end to end: the strings table has to be + /// reachable from the resource bundle, and the action catalogue — whose + /// JSON stays German on disk — has to be translated on read. Both have + /// silently regressed before, leaving German text in the English UI. + func testEnglishLocalizationResolvesForTableAndActionCatalog() { + let previous = UserDefaults.standard.string(forKey: AppLanguage.defaultsKey) + defer { UserDefaults.standard.set(previous, forKey: AppLanguage.defaultsKey) } + AppLanguage.current = .english + + XCTAssertEqual(AppLanguage.localized("Abbrechen"), "Cancel") + XCTAssertEqual(AppLanguage.localized("Gerät"), "Device") + // Unknown keys must fall back to the German source, never to an empty + // string, so an untranslated label still renders. + XCTAssertEqual(AppLanguage.localized("Kein Eintrag in der Tabelle"), "Kein Eintrag in der Tabelle") + + for (catalog, name) in [ + (CodexActionCatalog(), "CodexActions"), + (CodexActionCatalog(resourceName: "ClaudeActions", app: .claude), "ClaudeActions") + ] { + XCTAssertFalse(catalog.actions.isEmpty, "\(name) failed to load") + for action in catalog.actions { + for (field, value) in [ + ("title", action.title), + ("description", action.description), + ("category", action.category), + ("availabilityNote", action.availabilityNote ?? "") + ] { + XCTAssertFalse( + value.contains(where: { "äöüÄÖÜß".contains($0) }), + "\(name).\(action.id).\(field) is still German in English: \(value)" + ) + } + } + } + } + func testPrivacySettingsDeepLinksTargetTheExpectedTCCPanes() { XCTAssertEqual( SystemPrivacySettingsPane.accessibility.url.absoluteString, @@ -514,7 +550,9 @@ final class AgentMicroTests: XCTestCase { XCTAssertEqual(catalog.keyboardAction(id: "open-side-chat")?.deviceMacro, "f15") XCTAssertEqual(catalog.action(id: "open-skills")?.execution, .deepLink) XCTAssertEqual(catalog.action(id: "reasoning-decrease")?.execution, .configurableShortcut) - XCTAssertTrue(catalog.categories.contains("Terminal und Panels")) + // `categories` is translated on read, so compare against the same + // lookup rather than the German source — CI runs in English. + XCTAssertTrue(catalog.categories.contains(AppLanguage.localized("Terminal und Panels"))) } func testCH57xConfigurationGeneration() throws { diff --git a/Views/ActionSelectionSheet.swift b/Views/ActionSelectionSheet.swift index 66bde76..266ca01 100644 --- a/Views/ActionSelectionSheet.swift +++ b/Views/ActionSelectionSheet.swift @@ -46,8 +46,14 @@ struct ActionSelectionSheet: View { Button("Abbrechen") { dismiss() } } Text(context == .tap - ? "Der Katalog gilt für \(appName). Fehlende App-Aktionen kannst du direkt in der jeweiligen Zeile einrichten." - : "Der Katalog gilt für \(appName). Halten verwendet einen eigenen, lokal ausgeführten Slot.") + ? AppLanguage.text( + "Der Katalog gilt für \(appName). Fehlende App-Aktionen kannst du direkt in der jeweiligen Zeile einrichten.", + "The catalog applies to \(appName). You can set up missing app actions directly in their row." + ) + : AppLanguage.text( + "Der Katalog gilt für \(appName). Halten verwendet einen eigenen, lokal ausgeführten Slot.", + "The catalog applies to \(appName). Hold uses its own slot, executed locally." + )) .font(.caption).foregroundStyle(.secondary) specialActions @@ -117,7 +123,10 @@ struct ActionSelectionSheet: View { .foregroundStyle(.secondary) } Spacer(minLength: 8) - ContextInfoButton(title: action.title, message: action.description) + ContextInfoButton( + title: action.title, + message: action.description + ) if action.execution == .configurableShortcut { if let configured = configuredAction(for: action) { diff --git a/Views/CodexAgentAssignmentView.swift b/Views/CodexAgentAssignmentView.swift index 27035c9..6c362db 100644 --- a/Views/CodexAgentAssignmentView.swift +++ b/Views/CodexAgentAssignmentView.swift @@ -48,7 +48,7 @@ struct CodexAgentAssignmentView: View { .padding(.top, 4) VStack(alignment: .leading, spacing: 2) { - Text(assignment?.threadTitle ?? "Noch kein Chat zugeordnet") + Text(assignment?.threadTitle ?? AppLanguage.text("Noch kein Chat zugeordnet", "No chat assigned yet")) .font(.subheadline.weight(.semibold)) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) @@ -97,8 +97,11 @@ struct CodexAgentAssignmentView: View { .help("Taste halten, mit dem Drehrad einen Chat wählen und zum Zuweisen loslassen.") Spacer(minLength: 4) ContextInfoButton( - title: "Chat direkt am Pad zuweisen", - message: "Halte die Agent-Taste etwa \(CodexQuickAssignService.holdThresholdMilliseconds) ms gedrückt. Drehe weiter gedrückt am Drehrad durch die letzten Chats und lasse die Taste beim gewünschten Chat los. Kurzes Tippen öffnet den bereits zugewiesenen Chat." + title: AppLanguage.text("Chat direkt am Pad zuweisen", "Assign a chat directly on the pad"), + message: AppLanguage.text( + "Halte die Agent-Taste etwa \(CodexQuickAssignService.holdThresholdMilliseconds) ms gedrückt. Drehe weiter gedrückt am Drehrad durch die letzten Chats und lasse die Taste beim gewünschten Chat los. Kurzes Tippen öffnet den bereits zugewiesenen Chat.", + "Hold the agent key for about \(CodexQuickAssignService.holdThresholdMilliseconds) ms. Keep holding it while turning the dial through the most recent chats, then release on the chat you want. A short tap opens the chat that is already assigned." + ) ) } .font(.caption.weight(.medium)) diff --git a/Views/CodexAssignmentWizardView.swift b/Views/CodexAssignmentWizardView.swift index e458db0..861892c 100644 --- a/Views/CodexAssignmentWizardView.swift +++ b/Views/CodexAssignmentWizardView.swift @@ -66,14 +66,20 @@ struct CodexAssignmentWizardView: View { HStack(spacing: 8) { Image(systemName: "wand.and.stars") .foregroundStyle(.tint) - Text("\(appName)-Aktion einrichten") + Text(AppLanguage.text("\(appName)-Aktion einrichten", "Set up \(appName) action")) .font(.title3.weight(.semibold)) Spacer() - Label("\(control.shortTitle)\(slot == .hold ? " · Halten" : "")", systemImage: control.icon) + Label( + "\(control.shortTitle)\(slot == .hold ? AppLanguage.text(" · Halten", " · Hold") : "")", + systemImage: control.icon + ) .font(.caption) .foregroundStyle(.secondary) } - Text("Diese Aktionen haben in \(appName) keine Standardtaste. Der Assistent vergibt einen eindeutigen Trigger, den das Pad direkt sendet – du weist ihn danach einmalig in \(appName) zu.") + Text(AppLanguage.text( + "Diese Aktionen haben in \(appName) keine Standardtaste. Der Assistent vergibt einen eindeutigen Trigger, den das Pad direkt sendet – du weist ihn danach einmalig in \(appName) zu.", + "These actions have no default shortcut in \(appName). The assistant assigns a unique trigger that the pad sends directly – you then bind it once in \(appName)." + )) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -204,8 +210,14 @@ struct CodexAssignmentWizardView: View { } else { let detail = result?.detail.trimmingCharacters(in: .whitespacesAndNewlines) setupError = detail?.isEmpty == false - ? "Die Taste konnte nicht übertragen werden: \(detail!)" - : "Die Taste konnte nicht auf das Pad übertragen werden. Prüfe die Verbindung und versuche es erneut." + ? AppLanguage.text( + "Die Taste konnte nicht übertragen werden: \(detail!)", + "The key could not be transferred: \(detail!)" + ) + : AppLanguage.text( + "Die Taste konnte nicht auf das Pad übertragen werden. Prüfe die Verbindung und versuche es erneut.", + "The key could not be transferred to the pad. Check the connection and try again." + ) } } .keyboardShortcut(.defaultAction) @@ -244,7 +256,10 @@ struct CodexAssignmentWizardView: View { } VStack(alignment: .leading, spacing: 2) { - Text("\(CodexTriggerPool.candidates.count) Kandidaten: A–Z, 0–9 und F1–F12.") + Text(AppLanguage.text( + "\(CodexTriggerPool.candidates.count) Kandidaten: A–Z, 0–9 und F1–F12.", + "\(CodexTriggerPool.candidates.count) candidates: A–Z, 0–9 and F1–F12." + )) Text("Codex veröffentlicht seine belegten Shortcuts nicht. Ist einer dort schon belegt, markiere ihn hier; Agent Micro schlägt ihn danach nie wieder vor.") if !knownTriggers.isEmpty { Text("Für frühere Einrichtungen: Auswählen › Bereits eingerichtet und den bereits in Codex verwendeten Trigger einmal übernehmen.") @@ -261,9 +276,9 @@ struct CodexAssignmentWizardView: View { private var triggerPicker: some View { Menu { - triggerMenu("Buchstaben", candidates: CodexTriggerPool.letterCandidates) - triggerMenu("Zahlen", candidates: CodexTriggerPool.numberCandidates) - triggerMenu("F-Tasten", candidates: CodexTriggerPool.functionKeyCandidates) + triggerMenu(AppLanguage.text("Buchstaben", "Letters"), candidates: CodexTriggerPool.letterCandidates) + triggerMenu(AppLanguage.text("Zahlen", "Numbers"), candidates: CodexTriggerPool.numberCandidates) + triggerMenu(AppLanguage.text("F-Tasten", "Function keys"), candidates: CodexTriggerPool.functionKeyCandidates) if !knownTriggers.isEmpty { Divider() Menu("Bereits eingerichtet") { @@ -324,20 +339,68 @@ struct CodexAssignmentWizardView: View { private func instructions(for action: CodexActionDefinition) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(appName == "Codex" ? "Schnelleinrichtung" : "So in \(appName) zuweisen") + Text(appName == "Codex" + ? AppLanguage.text("Schnelleinrichtung", "Quick setup") + : AppLanguage.text("So in \(appName) zuweisen", "How to bind it in \(appName)")) .font(.caption.weight(.semibold)) if appName == "Codex" { - step(1, "Klicke unten auf ", trailing: "„Übertragen“, damit \(control.shortTitle) den Trigger sendet.") - step(2, "Öffne in Codex ", trailing: "Settings › Keyboard Shortcuts und suche „\(action.title)“.") - step(3, "Klicke beim Treffer auf ", trailing: "„+“ und drücke einmal die physische \(control.shortTitle).") - Text("Verwende dabei \(CodexTriggerPool.displayLabel(for: trigger)). Agent Micro öffnet keine Codex-Ansicht mehr automatisch.") + step( + 1, + AppLanguage.text("Klicke unten auf ", "Click "), + trailing: AppLanguage.text( + "„Übertragen“, damit \(control.shortTitle) den Trigger sendet.", + "“Transfer” below so \(control.shortTitle) sends the trigger." + ) + ) + step( + 2, + AppLanguage.text("Öffne in Codex ", "In Codex, open "), + trailing: AppLanguage.text( + "Settings › Keyboard Shortcuts und suche „\(action.title)“.", + "Settings › Keyboard Shortcuts and search for “\(action.title)”." + ) + ) + step( + 3, + AppLanguage.text("Klicke beim Treffer auf ", "On the matching row, click "), + trailing: AppLanguage.text( + "„+“ und drücke einmal die physische \(control.shortTitle).", + "“+” and press the physical \(control.shortTitle) once." + ) + ) + Text(AppLanguage.text( + "Verwende dabei \(CodexTriggerPool.displayLabel(for: trigger)). Agent Micro öffnet keine Codex-Ansicht mehr automatisch.", + "Use \(CodexTriggerPool.displayLabel(for: trigger)) for this. Agent Micro no longer opens any Codex view automatically." + )) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) } else { - step(1, "Öffne in Claude ", trailing: "die Datei ~/.claude/keybindings.json.") - step(2, "Trage die Aktions-ID ein: ", trailing: "„\(action.codexCommandID ?? action.id)“.") - step(3, "Binde sie an ", trailing: CodexTriggerPool.displayLabel(for: trigger) + ".") - step(4, "Danach in Agent Micro ", trailing: "Übertragen klicken.") + step( + 1, + AppLanguage.text("Öffne in Claude ", "In Claude, open "), + trailing: AppLanguage.text( + "die Datei ~/.claude/keybindings.json.", + "the file ~/.claude/keybindings.json." + ) + ) + step( + 2, + AppLanguage.text("Trage die Aktions-ID ein: ", "Enter the action ID: "), + trailing: AppLanguage.text( + "„\(action.codexCommandID ?? action.id)“.", + "“\(action.codexCommandID ?? action.id)”." + ) + ) + step( + 3, + AppLanguage.text("Binde sie an ", "Bind it to "), + trailing: CodexTriggerPool.displayLabel(for: trigger) + "." + ) + step( + 4, + AppLanguage.text("Danach in Agent Micro ", "Then, in Agent Micro, "), + trailing: AppLanguage.text("Übertragen klicken.", "click Transfer.") + ) } } .padding(12) diff --git a/Views/CodexCatalogView.swift b/Views/CodexCatalogView.swift index 4020ca9..2b1d45e 100644 --- a/Views/CodexCatalogView.swift +++ b/Views/CodexCatalogView.swift @@ -20,7 +20,10 @@ struct CodexCatalogView: View { VStack(alignment: .leading, spacing: 18) { Text("Actions") .font(.largeTitle.weight(.semibold)) - Text("Versionierbarer Katalog: \(appState.activeCatalog.document.verifiedAgainst). Direkte Shortcuts werden auf das aktuell gewählte Pad-Control geschrieben; Deep Links bleiben transparent als lokale Listener-Aktionen gekennzeichnet.") + Text(AppLanguage.text( + "Versionierbarer Katalog: \(appState.activeCatalog.document.verifiedAgainst). Direkte Shortcuts werden auf das aktuell gewählte Pad-Control geschrieben; Deep Links bleiben transparent als lokale Listener-Aktionen gekennzeichnet.", + "Versioned catalog: \(appState.activeCatalog.document.verifiedAgainst). Direct shortcuts are written to the currently selected pad control; deep links remain transparently marked as local listener actions." + )) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -39,7 +42,10 @@ struct CodexCatalogView: View { Button(control.title) { selectedControl = control } } } label: { - Label("Ziel: \(selectedControl.title)", systemImage: selectedControl.icon) + Label( + AppLanguage.text("Ziel: \(selectedControl.title)", "Target: \(selectedControl.title)"), + systemImage: selectedControl.icon + ) } } .padding(12) @@ -123,19 +129,31 @@ private struct ActionCatalogCard: View { private var buttonTitle: String { switch action.execution { - case .deepLink: "Zuweisen*" - case .configurableShortcut: action.isDirectlyAssignable ? "Zuweisen*" : "In Codex belegen" - default: "Zuweisen" + case .deepLink: AppLanguage.text("Zuweisen*", "Assign*") + case .configurableShortcut: + action.isDirectlyAssignable + ? AppLanguage.text("Zuweisen*", "Assign*") + : AppLanguage.text("In Codex belegen", "Bind in Codex") + default: AppLanguage.text("Zuweisen", "Assign") } } private var helpText: String { switch action.execution { - case .deepLink: "*Deep Links benötigen einen lokalen Listener und sind nicht direkt uploadbar." + case .deepLink: AppLanguage.text( + "*Deep Links benötigen einen lokalen Listener und sind nicht direkt uploadbar.", + "*Deep links require a local listener and cannot be transferred to the device directly." + ) case .configurableShortcut: action.isDirectlyAssignable - ? "*Der Pad-Trigger muss in Codex unter Settings > Keyboard Shortcuts einmalig derselben Aktion zugewiesen werden. Der zweite Tastendruck schließt den geöffneten Bereich." - : "Für diese reale Codex-Aktion ist in 26.715.21425 keine Standardtaste eingebunden. Lege sie zuerst unter Settings > Keyboard Shortcuts fest und verwende anschließend denselben freien Shortcut im Pad." + ? AppLanguage.text( + "*Der Pad-Trigger muss in Codex unter Settings > Keyboard Shortcuts einmalig derselben Aktion zugewiesen werden. Der zweite Tastendruck schließt den geöffneten Bereich.", + "*The pad trigger has to be bound to the same action once in Codex under Settings > Keyboard Shortcuts. The second key press closes the opened area." + ) + : AppLanguage.text( + "Für diese reale Codex-Aktion ist in 26.715.21425 keine Standardtaste eingebunden. Lege sie zuerst unter Settings > Keyboard Shortcuts fest und verwende anschließend denselben freien Shortcut im Pad.", + "This real Codex action has no default shortcut in 26.715.21425. Set one under Settings > Keyboard Shortcuts first, then use the same free shortcut on the pad." + ) default: "" } } diff --git a/Views/ControlAssignmentPanel.swift b/Views/ControlAssignmentPanel.swift index 8a9eec2..5f7cdc9 100644 --- a/Views/ControlAssignmentPanel.swift +++ b/Views/ControlAssignmentPanel.swift @@ -40,7 +40,10 @@ struct ControlAssignmentPanel: View { .foregroundStyle(.tint) Text(control.shortTitle) .font(.headline) - ContextInfoButton(title: "Bedienelement belegen", message: infoMessage) + ContextInfoButton( + title: AppLanguage.text("Bedienelement belegen", "Assign control"), + message: infoMessage + ) Spacer() Menu { ForEach(HardwareControl.allCases) { item in @@ -135,9 +138,15 @@ struct ControlAssignmentPanel: View { Image(systemName: "wand.and.stars") .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 3) { - Text("In Codex zuweisen: \(CodexTriggerPool.displayLabel(for: macro))") + Text(AppLanguage.text( + "In Codex zuweisen: \(CodexTriggerPool.displayLabel(for: macro))", + "Bind in Codex: \(CodexTriggerPool.displayLabel(for: macro))" + )) .font(.caption.weight(.semibold)) - Text("Codex › Settings › Keyboard Shortcuts › „\(definition.title)“. Danach übertragen.") + Text(AppLanguage.text( + "Codex › Settings › Keyboard Shortcuts › „\(definition.title)“. Danach übertragen.", + "Codex › Settings › Keyboard Shortcuts › “\(definition.title)”. Then transfer." + )) .font(.caption2) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -152,7 +161,10 @@ struct ControlAssignmentPanel: View { } private var infoMessage: String { - "Wähle oben eine Taste und danach eine Aktion. Agent-Tasten werden direkt am Pad zugewiesen: Taste halten, mit dem Drehrad einen Chat wählen und loslassen. Der Live-Status steuert die LED. Änderungen müssen einmal auf das Pad übertragen werden." + AppLanguage.text( + "Wähle oben eine Taste und danach eine Aktion. Agent-Tasten werden direkt am Pad zugewiesen: Taste halten, mit dem Drehrad einen Chat wählen und loslassen. Der Live-Status steuert die LED. Änderungen müssen einmal auf das Pad übertragen werden.", + "Pick a key above, then an action. Agent keys are assigned directly on the pad: hold the key, choose a chat with the dial, and release. The live status drives the LED. Changes have to be transferred to the pad once." + ) } } diff --git a/Views/ControlInspectorView.swift b/Views/ControlInspectorView.swift index beb9296..6e1bb84 100644 --- a/Views/ControlInspectorView.swift +++ b/Views/ControlInspectorView.swift @@ -120,6 +120,11 @@ struct ControlInspectorView: View { profile: appState.profiles.selectedProfile, keyboardLayout: appState.profiles.keyboardLayout ) - testMessage = result?.succeeded == true ? "Geräteausdruck ist valide." : "Validierung fehlgeschlagen – Details in Diagnose." + testMessage = result?.succeeded == true + ? AppLanguage.text("Geräteausdruck ist valide.", "Device expression is valid.") + : AppLanguage.text( + "Validierung fehlgeschlagen – Details in Diagnose.", + "Validation failed – see Diagnostics for details." + ) } } diff --git a/Views/DeviceCanvasView.swift b/Views/DeviceCanvasView.swift index a85c3e9..b86e4ed 100644 --- a/Views/DeviceCanvasView.swift +++ b/Views/DeviceCanvasView.swift @@ -159,7 +159,7 @@ struct KeyControlView: View { Button(action: select) { VStack(alignment: .leading, spacing: compact ? 2 : 3) { HStack(spacing: 3) { - Text(control.title.replacingOccurrences(of: "Taste ", with: "")) + Text(control.title.replacingOccurrences(of: AppLanguage.text("Taste ", "Key "), with: "")) .font(.system(size: compact ? 8 : 9, weight: .bold, design: .monospaced)) .foregroundStyle(.white.opacity(0.5)) Spacer(minLength: 0) @@ -221,7 +221,7 @@ struct KeyControlView: View { private var resolvedLabel: String { guard action.kind.isAgent else { return action.displayLabel } - return displayLabel ?? "Kein Chat zugeordnet" + return displayLabel ?? AppLanguage.text("Kein Chat zugeordnet", "No chat assigned") } private var keyBackground: LinearGradient { diff --git a/Views/DeviceEditorView.swift b/Views/DeviceEditorView.swift index 59768e4..6f60608 100644 --- a/Views/DeviceEditorView.swift +++ b/Views/DeviceEditorView.swift @@ -29,8 +29,11 @@ struct DeviceEditorView: View { Text("Bedienelement am Pad auswählen") Spacer(minLength: 4) ContextInfoButton( - title: "Direkt am Pad bearbeiten", - message: "Klicke eine Taste oder einen Bereich des Drehrads an. Die passende Belegung oder Lichteinstellung erscheint darunter." + title: AppLanguage.text("Direkt am Pad bearbeiten", "Edit directly on the pad"), + message: AppLanguage.text( + "Klicke eine Taste oder einen Bereich des Drehrads an. Die passende Belegung oder Lichteinstellung erscheint darunter.", + "Click a key or a zone of the dial. Its assignment or lighting settings appear below." + ) ) } .font(.caption) diff --git a/Views/DiagnosticsView.swift b/Views/DiagnosticsView.swift index 82dddab..805e183 100644 --- a/Views/DiagnosticsView.swift +++ b/Views/DiagnosticsView.swift @@ -156,7 +156,7 @@ private struct CodexPadProtocolCard: View { LabeledContent("Gedrückte Controls", value: String(format: "0x%03X", firmware.pressedMask)) } if let event = appState.padEvents.events.first { - let source = event.origin == .hardware ? "Hardware" : "Diagnose" + let source = event.origin == .hardware ? "Hardware" : AppLanguage.text("Diagnose", "Diagnostics") LabeledContent("Letztes Ereignis", value: "\(source) · Control \(event.control + 1) · \(String(describing: event.phase)) · #\(event.sequence)") } } @@ -239,10 +239,10 @@ private struct DeviceDiagnosticsCard: View { LabeledContent("Name", value: device.name) LabeledContent("Vendor / Product", value: "\(device.vendorIDHex) / \(device.productIDHex)") LabeledContent("Location ID", value: device.locationID) - LabeledContent("Hersteller", value: device.manufacturer ?? "nicht bereitgestellt") - LabeledContent("Produktstring", value: device.productName ?? "nicht bereitgestellt") - LabeledContent("Seriennummer", value: device.serialNumber ?? "nicht bereitgestellt") - LabeledContent("Status", value: device.support == .supported ? "Upload verifiziert" : device.support == .related ? "CH57x erkannt, Upload gesperrt" : "Nicht CH57x") + LabeledContent("Hersteller", value: device.manufacturer ?? AppLanguage.text("nicht bereitgestellt", "not provided")) + LabeledContent("Produktstring", value: device.productName ?? AppLanguage.text("nicht bereitgestellt", "not provided")) + LabeledContent("Seriennummer", value: device.serialNumber ?? AppLanguage.text("nicht bereitgestellt", "not provided")) + LabeledContent("Status", value: device.support == .supported ? AppLanguage.text("Upload verifiziert", "Transfer verified") : device.support == .related ? AppLanguage.text("CH57x erkannt, Upload gesperrt", "CH57x detected, transfer blocked") : AppLanguage.text("Nicht CH57x", "Not CH57x")) if !device.interfaces.isEmpty { Divider() Text("USB-Interfaces").font(.subheadline.weight(.medium)) @@ -277,7 +277,7 @@ private struct InputMonitorCard: View { Label("Eingabe-Testmonitor", systemImage: "waveform.path.ecg") .font(.headline) Spacer() - Button(monitor.isMonitoring ? "Stoppen" : "Starten") { + Button(monitor.isMonitoring ? AppLanguage.text("Stoppen", "Stop") : AppLanguage.text("Starten", "Start")) { monitor.isMonitoring ? monitor.stop() : monitor.start() } .disabled(!appState.device.state.isSupportedConnection && !monitor.isMonitoring) diff --git a/Views/EditorModeSwitch.swift b/Views/EditorModeSwitch.swift index f96fd0c..b75a249 100644 --- a/Views/EditorModeSwitch.swift +++ b/Views/EditorModeSwitch.swift @@ -117,7 +117,9 @@ struct EditorModeSwitch: View { } .buttonStyle(.plain) .accessibilityLabel(option.title) - .accessibilityValue(isSelected ? "Ausgewählt" : "Nicht ausgewählt") + .accessibilityValue(isSelected + ? AppLanguage.text("Ausgewählt", "Selected") + : AppLanguage.text("Nicht ausgewählt", "Not selected")) .accessibilityHint("Wechselt zum ausgewählten Bereich") .accessibilityAddTraits(isSelected ? .isSelected : []) } @@ -166,7 +168,9 @@ struct EditorToggleRail: View { } .buttonStyle(.plain) .accessibilityLabel(title) - .accessibilityValue(isOn ? "Aktiviert" : "Deaktiviert") + .accessibilityValue(isOn + ? AppLanguage.text("Aktiviert", "On") + : AppLanguage.text("Deaktiviert", "Off")) .accessibilityHint("Schaltet Grundlicht ein oder aus") } diff --git a/Views/EncoderAssignmentSection.swift b/Views/EncoderAssignmentSection.swift index 70ec540..0f708fe 100644 --- a/Views/EncoderAssignmentSection.swift +++ b/Views/EncoderAssignmentSection.swift @@ -22,7 +22,10 @@ struct EncoderAssignmentSection: View { .foregroundStyle(.tint) Text("Drehrad") .font(.headline) - ContextInfoButton(title: "Drehrad-Gesten", message: infoMessage) + ContextInfoButton( + title: AppLanguage.text("Drehrad-Gesten", "Dial gestures"), + message: infoMessage + ) Spacer() Toggle("", isOn: isEnabled) .labelsHidden() @@ -32,20 +35,31 @@ struct EncoderAssignmentSection: View { row( icon: "slider.horizontal.3", - gesture: "Normal drücken / drehen", - value: "Aufwand ± · Modellwahl öffnen/schließen" + gesture: AppLanguage.text("Normal drücken / drehen", "Normal press / turn"), + value: AppLanguage.text( + "Aufwand ± · Modellwahl öffnen/schließen", + "Effort ± · open/close model selection" + ) ) row( icon: "cube", - gesture: "Halten (>\(ClaudeReasoningAutomationService.modelListHoldThresholdMilliseconds) ms) + drehen", - value: "Modell wechseln, Loslassen übernimmt" + gesture: AppLanguage.text( + "Halten (>\(ClaudeReasoningAutomationService.modelListHoldThresholdMilliseconds) ms) + drehen", + "Hold (>\(ClaudeReasoningAutomationService.modelListHoldThresholdMilliseconds) ms) + turn" + ), + value: AppLanguage.text( + "Modell wechseln, Loslassen übernimmt", + "Switch model, release applies it" + ) ) HStack(spacing: 8) { PermissionStatus( title: "Input Monitoring", isGranted: automation.usesPhysicalEncoderEvents || automation.hasInputMonitoringPermission, - grantedTitle: automation.usesPhysicalEncoderEvents ? "Nicht nötig" : "Erteilt" + grantedTitle: automation.usesPhysicalEncoderEvents + ? AppLanguage.text("Nicht nötig", "Not needed") + : AppLanguage.text("Erteilt", "Granted") ) PermissionStatus(title: "Accessibility", isGranted: automation.hasAccessibilityPermission) } @@ -131,14 +145,14 @@ struct EncoderAssignmentSection: View { struct PermissionStatus: View { let title: String let isGranted: Bool - var grantedTitle = "Erteilt" + var grantedTitle = AppLanguage.text("Erteilt", "Granted") var body: some View { HStack(spacing: 6) { Text(title) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) - Text(isGranted ? grantedTitle : "Fehlt") + Text(isGranted ? grantedTitle : AppLanguage.text("Fehlt", "Missing")) .foregroundStyle(isGranted ? Color.green : Color.orange) } .font(.caption) diff --git a/Views/GeneralView.swift b/Views/GeneralView.swift index c2a7799..6c47ffe 100644 --- a/Views/GeneralView.swift +++ b/Views/GeneralView.swift @@ -35,14 +35,14 @@ struct GeneralView: View { Section("Import & Export") { HStack { Button("Aktuelles Profil exportieren") { - do { resultText = "Exportiert: \(try appState.profiles.exportSelectedProfile().path)" } + do { resultText = AppLanguage.text("Exportiert", "Exported") + ": \(try appState.profiles.exportSelectedProfile().path)" } catch is CancellationError { } - catch { resultText = "Export fehlgeschlagen: \(error.localizedDescription)" } + catch { resultText = AppLanguage.text("Export fehlgeschlagen", "Export failed") + ": \(error.localizedDescription)" } } Button("Profil importieren") { - do { try appState.profiles.importProfile(); resultText = "Profil importiert." } + do { try appState.profiles.importProfile(); resultText = AppLanguage.text("Profil importiert.", "Profile imported.") } catch is CancellationError { } - catch { resultText = "Import fehlgeschlagen: \(error.localizedDescription)" } + catch { resultText = AppLanguage.text("Import fehlgeschlagen", "Import failed") + ": \(error.localizedDescription)" } } } if let resultText { Text(resultText).font(.caption).foregroundStyle(.secondary) } @@ -89,7 +89,13 @@ struct GeneralView: View { private func setLEDMode(_ mode: Int) { let result = appState.device.setLEDMode(mode) ledTestMessage = result?.succeeded == true - ? "LED-Pattern \(mode) wurde auf dem Pad gesetzt." - : "LED-Pattern \(mode) konnte nicht gesetzt werden – Details stehen in Diagnose." + ? AppLanguage.text( + "LED-Pattern \(mode) wurde auf dem Pad gesetzt.", + "LED pattern \(mode) was set on the pad." + ) + : AppLanguage.text( + "LED-Pattern \(mode) konnte nicht gesetzt werden – Details stehen in Diagnose.", + "LED pattern \(mode) could not be set — see Diagnostics for details." + ) } } diff --git a/Views/LEDControlPanel.swift b/Views/LEDControlPanel.swift index fda3333..0bd1b8b 100644 --- a/Views/LEDControlPanel.swift +++ b/Views/LEDControlPanel.swift @@ -15,14 +15,17 @@ private enum LEDScope: Hashable { var title: String { switch self { - case .all: "Alle" - case .key(let control): control.shortTitle.replacingOccurrences(of: "Taste ", with: "") + case .all: AppLanguage.text("Alle", "All") + case .key(let control): control.shortTitle.replacingOccurrences( + of: AppLanguage.text("Taste ", "Key "), + with: "" + ) } } var detailTitle: String { switch self { - case .all: "Alle Tasten" + case .all: AppLanguage.text("Alle Tasten", "All keys") case .key(let control): control.shortTitle } } @@ -57,8 +60,11 @@ struct LEDControlPanel: View { Text("Licht") .font(.headline) ContextInfoButton( - title: "So funktioniert das Licht", - message: "Zwei Ebenen: Das Grundlicht zeigt, wie deine Tasten im Ruhezustand aussehen. Reaktionen legen sich bei Ereignissen (Diktat, Senden, Agent-Status) automatisch darüber und kehren danach zum Grundlicht zurück." + title: AppLanguage.text("So funktioniert das Licht", "How lighting works"), + message: AppLanguage.text( + "Zwei Ebenen: Das Grundlicht zeigt, wie deine Tasten im Ruhezustand aussehen. Reaktionen legen sich bei Ereignissen (Diktat, Senden, Agent-Status) automatisch darüber und kehren danach zum Grundlicht zurück.", + "Two layers: base lighting shows how your keys look while idle. Reactions automatically override it on events (dictation, submit, agent status) and return to the base lighting afterwards." + ) ) Spacer() } @@ -253,7 +259,7 @@ struct LEDControlPanel: View { } .buttonStyle(.plain) .help(preset.name) - .accessibilityLabel("Idle-Farbe \(preset.name)") + .accessibilityLabel(AppLanguage.text("Idle-Farbe \(preset.name)", "Idle color \(preset.name)")) } } } @@ -285,7 +291,10 @@ struct LEDControlPanel: View { .frame(width: 38, alignment: .trailing) } } - Text("Pulsiert zwischen \(minPercent) % und \(maxPercent) %. Bei 0 % übernimmt die Firmware den Puls.") + Text(AppLanguage.text( + "Pulsiert zwischen \(minPercent) % und \(maxPercent) %. Bei 0 % übernimmt die Firmware den Puls.", + "Pulses between \(minPercent) % and \(maxPercent) %. At 0 % the firmware takes over the pulse." + )) .font(.caption2) .foregroundStyle(.secondary) } @@ -392,12 +401,18 @@ struct LEDControlPanel: View { appState.refreshAgentLEDs() } - private static let presets: [(name: String, color: Color, rgb: (UInt8, UInt8, UInt8))] = [ - ("Rot", .red, (255, 0, 0)), ("Orange", .orange, (255, 96, 0)), - ("Gelb", .yellow, (255, 255, 0)), ("Grün", .green, (0, 255, 0)), - ("Cyan", .cyan, (0, 255, 255)), ("Blau", .blue, (0, 0, 255)), - ("Pink", .pink, (255, 0, 255)), ("Weiß", .white, (255, 255, 255)) - ] + private static var presets: [(name: String, color: Color, rgb: (UInt8, UInt8, UInt8))] { + [ + (AppLanguage.text("Rot", "Red"), .red, (255, 0, 0)), + (AppLanguage.text("Orange", "Orange"), .orange, (255, 96, 0)), + (AppLanguage.text("Gelb", "Yellow"), .yellow, (255, 255, 0)), + (AppLanguage.text("Grün", "Green"), .green, (0, 255, 0)), + (AppLanguage.text("Cyan", "Cyan"), .cyan, (0, 255, 255)), + (AppLanguage.text("Blau", "Blue"), .blue, (0, 0, 255)), + (AppLanguage.text("Pink", "Pink"), .pink, (255, 0, 255)), + (AppLanguage.text("Weiß", "White"), .white, (255, 255, 255)) + ] + } } private struct LEDReactionEditor: View { @@ -508,7 +523,10 @@ private struct LEDReactionEditor: View { } if layer.confirmationEffect != .steady && layer.confirmationEffect != .off { Stepper( - "Wiederholungen: \(layer.confirmationRepeatCount)", + AppLanguage.text( + "Wiederholungen: \(layer.confirmationRepeatCount)", + "Repeats: \(layer.confirmationRepeatCount)" + ), value: layerRepeatBinding(layer.id), in: 1...8 ) @@ -611,7 +629,7 @@ private struct LEDReactionEditor: View { } .buttonStyle(.borderless) .help("Reaktion testen") - .accessibilityLabel("\(event.title) testen") + .accessibilityLabel(AppLanguage.text("\(event.title) testen", "Test \(event.title)")) ColorPicker("Farbe", selection: reactionColorBinding(event), supportsOpacity: false) .labelsHidden() Picker("Effekt", selection: reactionEffectBinding(event)) { @@ -634,7 +652,7 @@ private struct LEDReactionEditor: View { } .buttonStyle(.borderless) .help(isExpanded ? "Optionen schließen" : "Helligkeit und Dauer einstellen") - .accessibilityLabel("Optionen für \(event.title)") + .accessibilityLabel(AppLanguage.text("Optionen für \(event.title)", "Options for \(event.title)")) } if isExpanded { @@ -667,7 +685,10 @@ private struct LEDReactionEditor: View { .frame(width: 38, alignment: .trailing) } } - Text("Pulsiert zwischen \(Int(reaction.minBrightness) * 100 / 255) % und \(Int(reaction.brightness) * 100 / 255) %. Bei 0 % übernimmt die Firmware den Puls.") + Text(AppLanguage.text( + "Pulsiert zwischen \(Int(reaction.minBrightness) * 100 / 255) % und \(Int(reaction.brightness) * 100 / 255) %. Bei 0 % übernimmt die Firmware den Puls.", + "Pulses between \(Int(reaction.minBrightness) * 100 / 255) % and \(Int(reaction.brightness) * 100 / 255) %. At 0 % the firmware takes over the pulse." + )) .font(.caption2) .foregroundStyle(.secondary) } diff --git a/Views/MainWindowView.swift b/Views/MainWindowView.swift index 60ecbd8..aefc31e 100644 --- a/Views/MainWindowView.swift +++ b/Views/MainWindowView.swift @@ -178,7 +178,9 @@ struct MainWindowView: View { keyboardLayout: appState.profiles.keyboardLayout ) showFeedback( - message: result?.succeeded == true ? "Konfiguration gültig" : "Validierung fehlgeschlagen", + message: result?.succeeded == true + ? AppLanguage.text("Konfiguration gültig", "Configuration valid") + : AppLanguage.text("Validierung fehlgeschlagen", "Validation failed"), detail: detail(for: result), succeeded: result?.succeeded == true ) @@ -187,19 +189,27 @@ struct MainWindowView: View { private func upload() { guard appState.profiles.hasUnsyncedChanges else { showFeedback( - message: "Aktuelles Setup bereits übertragen", - detail: "Auf dem Pad ist bereits die ausgewählte Profil- und Layer-Konfiguration aktiv.", + message: AppLanguage.text("Aktuelles Setup bereits übertragen", "Current setup already transferred"), + detail: AppLanguage.text( + "Auf dem Pad ist bereits die ausgewählte Profil- und Layer-Konfiguration aktiv.", + "The selected profile and layer configuration is already active on the pad." + ), succeeded: true ) return } let transfer = appState.transferCurrentConfiguration() + let profileName = transfer?.profileName ?? AppLanguage.text("Profil", "Profile") + let layerName = transfer?.layerName ?? AppLanguage.text("Layer", "Layer") showFeedback( message: transfer?.succeeded == true - ? "\(transfer?.profileName ?? "Profil") · \(transfer?.layerName ?? "Layer") übertragen" - : "Upload fehlgeschlagen", - detail: transfer?.detail ?? "Die Übertragung konnte nicht gestartet werden. Prüfe die Pad-Verbindung und versuche es erneut.", + ? AppLanguage.text("\(profileName) · \(layerName) übertragen", "\(profileName) · \(layerName) transferred") + : AppLanguage.text("Upload fehlgeschlagen", "Upload failed"), + detail: transfer?.detail ?? AppLanguage.text( + "Die Übertragung konnte nicht gestartet werden. Prüfe die Pad-Verbindung und versuche es erneut.", + "The transfer could not be started. Check the pad connection and try again." + ), succeeded: transfer?.succeeded == true ) } @@ -220,9 +230,16 @@ struct MainWindowView: View { } private func detail(for result: ProcessResult?) -> String { - guard let result else { return "Die lokale Vorprüfung hat einen Konfigurationsfehler gemeldet. Details stehen in Diagnose." } + guard let result else { + return AppLanguage.text( + "Die lokale Vorprüfung hat einen Konfigurationsfehler gemeldet. Details stehen in Diagnose.", + "The local pre-check reported a configuration error. Details are in Diagnostics." + ) + } let parts = [result.launchError, result.stdout.nilIfEmpty, result.stderr.nilIfEmpty].compactMap { $0 } - return parts.isEmpty ? "Befehl ohne zusätzliche Ausgabe beendet." : parts.joined(separator: "\n") + return parts.isEmpty + ? AppLanguage.text("Befehl ohne zusätzliche Ausgabe beendet.", "Command finished without additional output.") + : parts.joined(separator: "\n") } } @@ -244,6 +261,8 @@ struct ConnectionStatus: View { .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) } - .help(help ?? (connected ? "Verbunden" : "Nicht verbunden")) + .help(help ?? (connected + ? AppLanguage.text("Verbunden", "Connected") + : AppLanguage.text("Nicht verbunden", "Not connected"))) } } diff --git a/Views/MenuBarPopoverView.swift b/Views/MenuBarPopoverView.swift index 9448154..9f148ce 100644 --- a/Views/MenuBarPopoverView.swift +++ b/Views/MenuBarPopoverView.swift @@ -132,12 +132,20 @@ struct MenuBarPopoverView: View { private func upload() { guard appState.profiles.hasUnsyncedChanges else { - showFeedback(message: "Aktuelles Setup bereits übertragen", succeeded: true) + showFeedback( + message: AppLanguage.text("Aktuelles Setup bereits übertragen", "Current setup already transferred"), + succeeded: true + ) return } let result = appState.transferCurrentConfiguration() - showFeedback(message: result?.succeeded == true ? "Übertragen" : "Fehlgeschlagen", succeeded: result?.succeeded == true) + showFeedback( + message: result?.succeeded == true + ? AppLanguage.text("Übertragen", "Transferred") + : AppLanguage.text("Fehlgeschlagen", "Failed"), + succeeded: result?.succeeded == true + ) } private func showFeedback(message: String, succeeded: Bool) { @@ -231,10 +239,13 @@ private struct MenuBarSelectedControlPanel: View { .fill(statusColor) .frame(width: 8, height: 8) } - Text(action.kind.isAgent ? (assignment?.threadTitle ?? "Kein Agent zugeordnet") : action.displayLabel) + let keyLabel = action.kind.isAgent + ? (assignment?.threadTitle ?? AppLanguage.text("Kein Agent zugeordnet", "No agent assigned")) + : action.displayLabel + Text(keyLabel) .font(.body.weight(.semibold)) .lineLimit(1) - .help(action.kind.isAgent ? (assignment?.threadTitle ?? "Kein Agent zugeordnet") : action.displayLabel) + .help(keyLabel) if action.kind.isAgent, assignment != nil, appState.activeAgentThreads.liveStatusAvailability == .sessionListOnly { Text(appState.activeAgentThreads.liveStatusAvailability.title) @@ -260,10 +271,11 @@ private struct MenuBarSelectedControlPanel: View { Image(systemName: binding.holdAction?.icon ?? "hand.raised") .foregroundStyle(.tint) .frame(width: 20) - Text("Halten: \(binding.holdAction?.displayLabel ?? "–")") + let holdLabel = binding.holdAction?.displayLabel ?? "–" + Text(AppLanguage.text("Halten: \(holdLabel)", "Hold: \(holdLabel)")) .font(.callout) .lineLimit(1) - .help("Halten: \(binding.holdAction?.displayLabel ?? "–")") + .help(AppLanguage.text("Halten: \(holdLabel)", "Hold: \(holdLabel)")) Spacer(minLength: 4) Button("Belegen") { isPresentingHoldActionSheet = true } .controlSize(.small) @@ -400,10 +412,13 @@ private struct MenuBarSelectedControlPanel: View { Image(systemName: "dial.medium") .foregroundStyle(.tint) .frame(width: 20) - Text(automation.isEnabled ? "Drehrad · Reasoning-Gesten aktiv" : "Drehrad · Deaktiviert") + let encoderLabel = automation.isEnabled + ? AppLanguage.text("Drehrad · Reasoning-Gesten aktiv", "Dial · Reasoning gestures active") + : AppLanguage.text("Drehrad · Deaktiviert", "Dial · Disabled") + Text(encoderLabel) .font(.body.weight(.semibold)) .lineLimit(1) - .help(automation.isEnabled ? "Drehrad · Reasoning-Gesten aktiv" : "Drehrad · Deaktiviert") + .help(encoderLabel) Spacer(minLength: 4) Toggle("", isOn: isEnabled) .labelsHidden() diff --git a/Views/OnboardingAssignmentDemo.swift b/Views/OnboardingAssignmentDemo.swift index 9ee553a..c64a0c8 100644 --- a/Views/OnboardingAssignmentDemo.swift +++ b/Views/OnboardingAssignmentDemo.swift @@ -65,7 +65,11 @@ struct OnboardingAssignmentDemo: View { let color: Color } - private static let sampleThreads: [SampleThread] = [ + /// Computed rather than a `static let`: a stored static is initialised + /// lazily exactly once, which would freeze these titles at whatever + /// language was active on first access and leave them behind after a + /// language switch. + private static var sampleThreads: [SampleThread] { [ SampleThread( title: AppLanguage.text("Login-Flow refactoren", "Refactor login flow"), detail: AppLanguage.text("Web App · Läuft", "Web App · Running"), @@ -86,7 +90,7 @@ struct OnboardingAssignmentDemo: View { detail: AppLanguage.text("Website · Erfolgreich", "Website · Completed"), color: Color(red: 48 / 255, green: 209 / 255, blue: 88 / 255) ) - ] + ] } /// The default `threadAssigned` reaction colour, so the demo flash matches /// what the hardware actually does on a confirmed assignment. diff --git a/Views/SidebarView.swift b/Views/SidebarView.swift index 0cf4de9..fdff22c 100644 --- a/Views/SidebarView.swift +++ b/Views/SidebarView.swift @@ -6,8 +6,8 @@ enum SidebarSection: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .pad: "Codex-Drehrad" - case .diagnostics: "Diagnose" + case .pad: AppLanguage.text("Codex-Drehrad", "Codex dial") + case .diagnostics: AppLanguage.text("Diagnose", "Diagnostics") } } var icon: String { diff --git a/Views/TapHoldSection.swift b/Views/TapHoldSection.swift index 1f75e6b..33f3144 100644 --- a/Views/TapHoldSection.swift +++ b/Views/TapHoldSection.swift @@ -52,7 +52,10 @@ struct TapHoldSection: View { .foregroundStyle(.tint) Text("Halten") .font(.subheadline.weight(.semibold)) - ContextInfoButton(title: "Aktion beim Halten", message: infoMessage) + ContextInfoButton( + title: AppLanguage.text("Aktion beim Halten", "Action on hold"), + message: infoMessage + ) Spacer() Toggle("", isOn: tapHoldEnabled) .labelsHidden() @@ -91,14 +94,14 @@ struct TapHoldSection: View { row( icon: "arrow.up.forward.app", - title: "Tippen", - value: "Zugewiesenen Chat öffnen", + title: AppLanguage.text("Tippen", "Tap"), + value: AppLanguage.text("Zugewiesenen Chat öffnen", "Open the assigned chat"), macro: "" ) row( icon: "arrow.triangle.2.circlepath", - title: "Halten", - value: "Chat neu zuweisen", + title: AppLanguage.text("Halten", "Hold"), + value: AppLanguage.text("Chat neu zuweisen", "Reassign the chat"), macro: "\(CodexQuickAssignService.holdThresholdMilliseconds) ms" ) @@ -121,7 +124,7 @@ struct TapHoldSection: View { .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 1) { Text("Halten").font(.caption2).foregroundStyle(.secondary) - Text(binding.holdAction?.displayLabel ?? "Aktion wählen") + Text(binding.holdAction?.displayLabel ?? AppLanguage.text("Aktion wählen", "Choose action")) .font(.body.weight(.medium)) .fixedSize(horizontal: false, vertical: true) } @@ -237,6 +240,9 @@ struct TapHoldSection: View { } private var infoMessage: String { - "Halten löst zusätzlich zur normalen Tastenaktion eine eigene Aktion aus. Agent Micro misst die Druckdauer und sendet die passende Tastenkombination selbst. Das benötigt die eigene CH552-Firmware (sie meldet die Druckflanken) und die Bedienungshilfen-Berechtigung. Nach dem Ändern einmal „Übertragen“ klicken." + AppLanguage.text( + "Halten löst zusätzlich zur normalen Tastenaktion eine eigene Aktion aus. Agent Micro misst die Druckdauer und sendet die passende Tastenkombination selbst. Das benötigt die eigene CH552-Firmware (sie meldet die Druckflanken) und die Bedienungshilfen-Berechtigung. Nach dem Ändern einmal „Übertragen“ klicken.", + "Holding triggers its own action in addition to the regular key action. Agent Micro measures the press duration and sends the matching shortcut itself. This requires the custom CH552 firmware (it reports the press edges) and Accessibility permission. After a change, click Transfer once." + ) } } diff --git a/script/build_and_run.sh b/script/build_and_run.sh index c244cae..4a2a340 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -92,9 +92,14 @@ cp "$BUILD_BINARY" "$APP_BINARY" cp "$APP_ICON" "$APP_RESOURCES/AgentMicroIcon.icns" cp "$HELPER_DESTINATION" "$APP_RESOURCES/ch57x-keyboard-tool" cp -R "$RESOURCE_BUNDLE" "$APP_RESOURCES/" -if [[ -d "$RESOURCE_BUNDLE/en.lproj" ]]; then - cp -R "$RESOURCE_BUNDLE/en.lproj" "$APP_RESOURCES/" +# SwiftUI resolves Text("…") against the main bundle, so the English strings +# table has to sit next to the binary and not only inside the SPM resource +# bundle. Fail loudly: silently skipping this ships a German-only app. +if [[ ! -d "$RESOURCE_BUNDLE/en.lproj" ]]; then + echo "Englische Lokalisierung fehlt: $RESOURCE_BUNDLE/en.lproj" >&2 + exit 1 fi +cp -R "$RESOURCE_BUNDLE/en.lproj" "$APP_RESOURCES/" chmod +x "$APP_BINARY" "$APP_RESOURCES/ch57x-keyboard-tool" cat >"$INFO_PLIST" <