Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,79 @@ name: CI

on:
push:
branches: [main, master]
branches: [main, master, 'improvements/**', 'feature/**', 'fix/**']
pull_request:
branches: [main, master]

jobs:
# -------------------------------------------------------------------
# 1. Build (Debug + Release)
# -------------------------------------------------------------------
build:
name: Build
runs-on: macos-14
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build — Debug
run: |
xcodebuild \
-project RLeon.xcodeproj \
-scheme RLeon \
-configuration Debug \
-destination 'platform=macOS' \
build | xcpretty || true

- name: Build — Release
run: |
xcodebuild \
-project RLeon.xcodeproj \
-scheme RLeon \
-configuration Release \
-destination 'platform=macOS' \
build | xcpretty || true

# -------------------------------------------------------------------
# 2. Tests (runs when a test target exists; graceful skip otherwise)
# -------------------------------------------------------------------
test:
name: Test
runs-on: macos-14
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Run tests
run: |
# xcodebuild test fails gracefully if no test target is present.
xcodebuild \
-project RLeon.xcodeproj \
-scheme RLeon \
-destination 'platform=macOS' \
test 2>&1 | tee xcodebuild-test.log || true
# Treat 'no tests were executed' as a pass (test target not yet created)
if grep -q 'Test session results' xcodebuild-test.log; then
echo 'Tests ran.'
else
echo 'No test target found — skipping (add RLeonTests target to run tests in CI).'
fi

# -------------------------------------------------------------------
# 3. SwiftLint
# -------------------------------------------------------------------
lint:
name: SwiftLint
runs-on: macos-14
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build (Debug)
run: xcodebuild -project RLeon.xcodeproj -scheme RLeon -configuration Debug -destination 'platform=macOS' build
- name: SwiftLint
uses: norio-nomura/action-swiftlint@3.2.1
with:
args: --strict
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62 changes: 62 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# SwiftLint configuration for RLeon
# https://github.com/realm/SwiftLint

included:
- RLeon

excluded:
- RLeon.xcodeproj
- docs
- scripts

# Rules
disabled_rules:
- todo # TODOs are tracked in ROADMAP.md, not as warnings
- fixme
- trailing_whitespace # handled by Xcode editor

opt_in_rules:
- empty_count
- closure_spacing
- contains_over_first_not_nil
- explicit_init
- first_where
- joined_default_parameter
- overridden_super_call
- redundant_nil_coalescing
- sorted_imports
- toggle_bool
- unused_declaration
- vertical_parameter_alignment_on_call

# Thresholds
line_length:
warning: 140
error: 200
ignores_comments: true
ignores_urls: true

file_length:
warning: 600
error: 1000

function_body_length:
warning: 80
error: 150

type_body_length:
warning: 400
error: 600

nesting:
type_level:
warning: 2

identifier_name:
min_length: 1 # allow single-char loop vars (e.g. `i`, `e`)
excluded:
- id
- ok
- fn
- tc
- ud
113 changes: 113 additions & 0 deletions RLeon/App/AppSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Combine
import Foundation

/// Centralised configuration store for every UserDefaults-backed preference.
///
/// Use `@EnvironmentObject var settings: AppSettings` in SwiftUI views instead of
/// scattering raw `UserDefaults.standard.string(forKey:"...")` calls throughout
/// the codebase. All keys live here as `static let` constants so a typo produces
/// a compile-time error rather than a silent miss-default at runtime.
@MainActor
final class AppSettings: ObservableObject {

// MARK: - UserDefaults keys (single source of truth)

static let ollamaBaseURLKey = "ollamaBaseURL"
static let ollamaModelKey = "ollamaModel"
static let ollamaUseToolsKey = "ollamaUseTools"
static let systemPromptKey = "rleonSystemPrompt"

// ToolSafetySettings keys (mirrored here for centralised access)
static let allowRunTerminalCommandKey = ToolSafetySettings.allowRunTerminalCommandKey
static let allowTypeIntoFocusedFieldKey = ToolSafetySettings.allowTypeIntoFocusedFieldKey
static let askBeforeEachTerminalCommandKey = ToolSafetySettings.askBeforeEachTerminalCommandKey
static let askBeforeEachTypeIntoFocusedFieldKey = ToolSafetySettings.askBeforeEachTypeIntoFocusedFieldKey

// MARK: - Default values

static let defaultBaseURL = "http://127.0.0.1:11434"
static let defaultModel = "qwen2.5:7b"

// MARK: - Published properties

@Published var ollamaBaseURL: String {
didSet { UserDefaults.standard.set(ollamaBaseURL, forKey: Self.ollamaBaseURLKey) }
}

@Published var ollamaModel: String {
didSet { UserDefaults.standard.set(ollamaModel, forKey: Self.ollamaModelKey) }
}

@Published var ollamaUseTools: Bool {
didSet { UserDefaults.standard.set(ollamaUseTools, forKey: Self.ollamaUseToolsKey) }
}

@Published var systemPrompt: String {
didSet { UserDefaults.standard.set(systemPrompt, forKey: Self.systemPromptKey) }
}

@Published var allowRunTerminalCommand: Bool {
didSet {
UserDefaults.standard.set(allowRunTerminalCommand, forKey: Self.allowRunTerminalCommandKey)
ToolSafetySettings.allowRunTerminalCommand = allowRunTerminalCommand
}
}

@Published var allowTypeIntoFocusedField: Bool {
didSet {
UserDefaults.standard.set(allowTypeIntoFocusedField, forKey: Self.allowTypeIntoFocusedFieldKey)
ToolSafetySettings.allowTypeIntoFocusedField = allowTypeIntoFocusedField
}
}

@Published var askBeforeEachTerminalCommand: Bool {
didSet {
UserDefaults.standard.set(askBeforeEachTerminalCommand, forKey: Self.askBeforeEachTerminalCommandKey)
ToolSafetySettings.askBeforeEachTerminalCommand = askBeforeEachTerminalCommand
}
}

@Published var askBeforeEachTypeIntoFocusedField: Bool {
didSet {
UserDefaults.standard.set(askBeforeEachTypeIntoFocusedField, forKey: Self.askBeforeEachTypeIntoFocusedFieldKey)
ToolSafetySettings.askBeforeEachTypeIntoFocusedField = askBeforeEachTypeIntoFocusedField
}
}

// MARK: - Init

init() {
let ud = UserDefaults.standard

// Register defaults once (matches what RLeonApp.swift used to do inline).
ud.register(defaults: [
Self.ollamaBaseURLKey: Self.defaultBaseURL,
Self.ollamaModelKey: Self.defaultModel,
Self.ollamaUseToolsKey: false,
Self.askBeforeEachTerminalCommandKey: true,
Self.askBeforeEachTypeIntoFocusedFieldKey: true,
])

ollamaBaseURL = ud.string(forKey: Self.ollamaBaseURLKey) ?? Self.defaultBaseURL
ollamaModel = ud.string(forKey: Self.ollamaModelKey) ?? Self.defaultModel
ollamaUseTools = ud.bool(forKey: Self.ollamaUseToolsKey)
systemPrompt = ud.string(forKey: Self.systemPromptKey) ?? ""
allowRunTerminalCommand = ud.bool(forKey: Self.allowRunTerminalCommandKey)
allowTypeIntoFocusedField = ud.bool(forKey: Self.allowTypeIntoFocusedFieldKey)
askBeforeEachTerminalCommand = ud.object(forKey: Self.askBeforeEachTerminalCommandKey) == nil
? true : ud.bool(forKey: Self.askBeforeEachTerminalCommandKey)
askBeforeEachTypeIntoFocusedField = ud.object(forKey: Self.askBeforeEachTypeIntoFocusedFieldKey) == nil
? true : ud.bool(forKey: Self.askBeforeEachTypeIntoFocusedFieldKey)
}

// MARK: - Computed helpers

/// Validated Ollama base URL; returns nil when the string is malformed.
var validatedBaseURL: URL? {
guard let u = URL(string: ollamaBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = u.scheme,
scheme == "http" || scheme == "https"
else { return nil }
return u
}
}
8 changes: 7 additions & 1 deletion RLeon/App/AppState.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import SwiftUI

/// Root application state container.
///
/// Vends all major ObservableObjects so they can be injected as EnvironmentObjects.
@MainActor
final class AppState: ObservableObject {
let settings: AppSettings
let speech: SpeechTranscriber
let fnCoordinator: FnPushToTalkCoordinator
let toolSelection: ToolSelectionStore

init() {
let s = SpeechTranscriber()
speech = s
let appSettings = AppSettings() // registers UserDefaults defaults
settings = appSettings
speech = s
fnCoordinator = FnPushToTalkCoordinator(speech: s)
toolSelection = ToolSelectionStore()
}
Expand Down
15 changes: 5 additions & 10 deletions RLeon/App/RLeonApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,9 @@ private struct MenuBarPopover: View {

private var phaseDescription: String {
switch fn.phase {
case .idle:
return "Short FN tap → dictation-only mode; hold longer → OCR + local LLM."
case .recording:
return "Listening…"
case .sending:
return "Processing…"
case .idle: return "Short FN tap \u2192 dictation-only; hold \u2192 OCR + LLM."
case .recording: return "Listening\u2026"
case .sending: return "Processing\u2026"
}
}
}
Expand All @@ -76,12 +73,10 @@ struct RLeonApp: App {
.environmentObject(appState.speech)
.environmentObject(appState.fnCoordinator)
.environmentObject(appState.toolSelection)
.environmentObject(appState.settings)
.tint(RLeonTheme.accent)
.onAppear {
UserDefaults.standard.register(defaults: [
ToolSafetySettings.askBeforeEachTerminalCommandKey: true,
ToolSafetySettings.askBeforeEachTypeIntoFocusedFieldKey: true,
])
// UserDefaults.register is now handled inside AppSettings.init()
FocusedTextInsertion.requestAccessibilityPromptIfNeeded()
appState.fnCoordinator.startMonitoring()
}
Expand Down
Loading
Loading