Skip to content
Open
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
137 changes: 72 additions & 65 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,76 +4,83 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Build and Development Commands

### Building and Testing
- **Full build and test**: `./buildscripts/build_and_test.sh` - Builds both macOS and iOS targets and runs all tests
- **Quiet build and test**: `./buildscripts/quiet_build_and_test.sh` - Same as above with less verbose output
- **Manual Xcode builds**:
- macOS: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire -destination "platform=macOS,arch=arm64" build`
- iOS: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire-iOS -destination "platform=iOS Simulator,name=iPhone 17" build`

### Testing
- Run all tests: Use the `NetNewsWire.xctestplan` which includes tests from all modules
- Individual test runs follow same xcodebuild pattern with `test` action instead of `build`

### Setup
- First-time setup: Run `./setup.sh` to configure development environment and code signing
- Manual setup: Create `SharedXcodeSettings/DeveloperSettings.xcconfig` in parent directory

## Project Architecture

### High-Level Structure
NetNewsWire is a multi-platform RSS reader with separate targets for macOS and iOS, organized as a modular architecture with shared business logic.

### Key Modules (in /Modules)
- **RSCore**: Core utilities, extensions, and shared infrastructure
- **RSParser**: Feed parsing (RSS, Atom, JSON Feed, RSS-in-JSON)
- **RSWeb**: HTTP networking, downloading, caching, and web services
- **RSDatabase**: SQLite database abstraction layer using FMDB
- **Account**: Account management (Local, Feedbin, Feedly, NewsBlur, Reader API, CloudKit)
- **Articles**: Article and author data models
- **ArticlesDatabase**: Article storage and search functionality
- **SyncDatabase**: Cross-device synchronization state management
- **Secrets**: Secure credential and API key management

### Platform-Specific Code
- **Mac/**: macOS-specific UI (AppKit), preferences, main window management
- **iOS/**: iOS-specific UI (UIKit), settings, navigation
- **Shared/**: Cross-platform business logic, article rendering, smart feeds
- First-time setup: `./setup.sh` (creates `SharedXcodeSettings/DeveloperSettings.xcconfig` in parent directory)
- Requires `xcbeautify`: https://github.com/cpisciotta/xcbeautify

### Key Architectural Patterns
- **Account System**: Pluggable account delegates for different sync services
- **Feed Management**: Hierarchical folder/feed organization with OPML import/export
- **Article Rendering**: Template-based HTML rendering with custom CSS themes
- **Smart Feeds**: Virtual feeds (Today, All Unread, Starred) implemented as PseudoFeed protocol
- **Timeline/Detail**: Classic three-pane interface (sidebar, timeline, detail)
### Building
- **Full build and test**: `./buildscripts/build_and_test.sh`
- **Quiet build and test** (CI-friendly): `./buildscripts/quiet_build_and_test.sh`
- **macOS only**: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire -destination "platform=macOS,arch=arm64" build`
- **iOS only**: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire-iOS -destination "platform=iOS Simulator,name=iPhone 17" build`

### Extension Points
- Share extensions for both platforms
- Safari extension for feed subscription
- Widget support for iOS
- AppleScript support on macOS
- Intent extensions for Siri shortcuts

### Development Notes
- Uses Xcode project with Swift Package Manager for module dependencies
- Requires `xcbeautify` for formatted build output in scripts
- API keys are managed through buildscripts/updateSecrets.sh (runs during builds)
- Some features disabled in development builds due to private API keys
- Code signing configured through SharedXcodeSettings for development
- Documentation and technical notes are located in the `Technotes/` folder

## Code Formatting

Prefer idiomatic modern Swift.

Prefer `if let x` and `guard let x` over `if let x = x` and `guard let x = x`.

Don’t use `...` or `…` in Logger messages.
### Testing
- **All macOS tests**: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire -destination "platform=macOS,arch=arm64" test`
- **Single test class**: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire -destination "platform=macOS,arch=arm64" -only-testing:AccountTests/ArticleFilterTests test`
- **Single test method**: `xcodebuild -project NetNewsWire.xcodeproj -scheme NetNewsWire -destination "platform=macOS,arch=arm64" -only-testing:AccountTests/ArticleFilterTests/testContainsMatchesTitleKeyword test`
- Test plans: `NetNewsWire.xctestplan` (macOS), `NetNewsWire-iOS.xctestplan` (iOS)
- Tests use XCTest framework with `@MainActor` attribute on test classes

Guard statements should always put the return in a separate line.
## Project Architecture

Don’t do force unwrapping of optionals.
### Overview
NetNewsWire is a multi-platform RSS reader (macOS/iOS) with a modular architecture. Shared business logic lives in Swift packages under `Modules/`; platform UI is in `Mac/` (AppKit) and `iOS/` (UIKit).

### Module Dependency Hierarchy (bottom-up)
- **Level 0**: RSCore (base utilities)
- **Level 1**: RSDatabase (SQLite/FMDB), RSParser (feed parsing), RSWeb (networking)
- **Level 2**: Articles (data models), FeedFinder (feed discovery)
- **Level 3**: ArticlesDatabase (article persistence)
- **Level 4**: Secrets, SyncDatabase, ErrorLog
- **Level 5**: Account (orchestrator - depends on 11 modules)

### Key Protocols
- **AccountDelegate** (`Modules/Account/Sources/Account/AccountDelegate.swift`): Defines behavior for account types (Local, Feedly, Feedbin, NewsBlur, CloudKit, etc.)
- **Container** (`Modules/Account/Sources/Account/Container.swift`): Hierarchical feed/folder organization. Adopted by Account and Folder
- **PseudoFeed** (`Shared/SmartFeeds/PseudoFeed.swift`): Virtual feeds (Today, All Unread, Starred)

### Key Patterns
- **Notifications over KVO**: Use `NotificationCenter.default.postOnMainThread()` for state changes. KVO is entirely forbidden
- **Delegates over subclasses**: AccountDelegate pattern for pluggable sync service backends
- **Extensions for conformances**: Protocol implementations go in extensions, private methods in `private extension`

## Coding Guidelines

These come from `Technotes/CodingGuidelines.md` -- read it for full details.

### Priority Values (in order)
1. No data loss
2. No crashes
3. No other bugs
4. Fast performance
5. Developer productivity

### Strict Rules
- **All classes must be `final`** (except required AppKit/UIKit subclasses). Use protocols and delegates instead of inheritance
- **Everything runs on the main thread**. Only exceptions: feed parsing and database fetches run in the background
- **No KVO, no bindings, no NSArrayController**. Use NotificationCenter or `didSet`
- **No Core Data**. Use plain Swift structs/classes with RSDatabase (FMDB/SQLite)
- **No locks** (almost never). Use serial queues for isolation instead
- **No force unwrapping** except as intentional precondition
- **No stack views in table/outline cells** (performance)
- **Tabs for indentation**, not spaces
- **Commit messages start with a present-tense verb**
- **Storyboards preferred** over XIBs (except small UI pieces)

### Code Style
- Prefer `if let x` and `guard let x` over `if let x = x` and `guard let x = x`
- Guard statements: always put `return` on a separate line
- Don't use `...` or `...` in Logger messages
- Prefer immutable structs
- Small objects over large ones
- Use `@MainActor` attribute on classes and protocols that must run on main thread
- Nil-targeted actions and responder chain for UI commands

### Development Build Limitations
Some features are disabled in dev builds due to private API keys (iCloud sync, Feedly, Reader View). API keys managed through `buildscripts/updateSecrets.sh` which runs as a pre-build action.

## Things to Know

Just because unit tests pass doesn’t mean a given bug is fixed. It may not have a test. It may not even be testable — it may require manual testing.
- Just because unit tests pass doesn't mean a bug is fixed. Many things require manual testing
- Don't contribute features without discussing in the [Discourse forum](https://discourse.netnewswire.com/) first (see CONTRIBUTING.md)
- Documentation and technical notes are in `Technotes/`
46 changes: 46 additions & 0 deletions Mac/Inspector/FeedInspectorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import AppKit
import SwiftUI
import UserNotifications
import Synchronization
import Articles
Expand All @@ -19,6 +20,7 @@ final class FeedInspectorViewController: NSViewController, Inspector {
@IBOutlet var urlTextField: NSTextField?
@IBOutlet var newArticleNotificationsEnabledCheckBox: NSButton!
@IBOutlet var readerViewAlwaysEnabledCheckBox: NSButton?
private var filtersButton: NSButton?

private var feed: Feed? {
didSet {
Expand Down Expand Up @@ -48,6 +50,7 @@ final class FeedInspectorViewController: NSViewController, Inspector {
// MARK: NSViewController

override func viewDidLoad() {
addFiltersButton()
updateUI()
NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .imageDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(updateUI), name: .DidUpdateFeedPreferencesFromContextMenu, object: nil)
Expand Down Expand Up @@ -108,6 +111,21 @@ final class FeedInspectorViewController: NSViewController, Inspector {
feed?.readerViewAlwaysEnabled = (readerViewAlwaysEnabledCheckBox?.state ?? .off) == .on ? true : false
}

@objc func filtersButtonClicked(_ sender: Any) {
guard let feed else {
return
}
let viewModel = ArticleFiltersViewModel(feed: feed)
var filtersView = ArticleFiltersView(viewModel: viewModel)
let hostingController = NSHostingController(rootView: filtersView)
filtersView.onDismiss = { [weak self] in
self?.dismiss(hostingController)
self?.updateFiltersButton()
}
hostingController.rootView = filtersView
presentAsSheet(hostingController)
}

// MARK: Notifications

@objc func imageDidBecomeAvailable(_ note: Notification) {
Expand Down Expand Up @@ -139,6 +157,7 @@ private extension FeedInspectorViewController {
updateFeedURL()
updateNewArticleNotificationsEnabled()
updateReaderViewAlwaysEnabled()
updateFiltersButton()
windowTitle = feed?.nameForDisplay ?? NSLocalizedString("Feed Inspector", comment: "Feed Inspector window title")
readerViewAlwaysEnabledCheckBox?.isEnabled = true
view.needsLayout = true
Expand Down Expand Up @@ -179,6 +198,33 @@ private extension FeedInspectorViewController {
readerViewAlwaysEnabledCheckBox?.state = (feed?.readerViewAlwaysEnabled ?? false) ? .on : .off
}

func addFiltersButton() {
guard let readerViewCheckBox = readerViewAlwaysEnabledCheckBox else {
return
}

let button = NSButton(title: "Filters...", target: self, action: #selector(filtersButtonClicked(_:)))
button.bezelStyle = .rounded
button.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(button)

NSLayoutConstraint.activate([
button.topAnchor.constraint(equalTo: readerViewCheckBox.bottomAnchor, constant: 8),
button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20)
])

self.filtersButton = button
}

func updateFiltersButton() {
let count = feed?.articleFilters?.count ?? 0
if count > 0 {
filtersButton?.title = "Filters (\(count))..."
} else {
filtersButton?.title = "Filters..."
}
}

func updateNotificationSettings() {
UNUserNotificationCenter.current().getNotificationSettings { (settings) in
let authorizationStatus = settings.authorizationStatus
Expand Down
27 changes: 14 additions & 13 deletions Mac/MainWindow/AddFeed/AddFeedController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import RSParser

// MARK: - AddFeedWindowControllerDelegate

func addFeedWindowController(_: AddFeedWindowController, userEnteredURL url: URL, userEnteredTitle title: String?, container: Container) {
func addFeedWindowController(_: AddFeedWindowController, userEnteredURL url: URL, userEnteredTitle title: String?, container: Container, articleFilter: ArticleFilter?) {
closeAddFeedSheet(NSApplication.ModalResponse.OK)

guard let accountAndFolderSpecifier = accountAndFolderFromContainer(container) else {
Expand All @@ -65,19 +65,20 @@ import RSParser

DispatchQueue.main.async {
self.endShowingProgress()
}

switch result {
case .success(let feed):
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
case .failure(let error):
switch error {
case AccountError.createErrorAlreadySubscribed:
self.showAlreadySubscribedError(url.absoluteString)
case AccountError.createErrorNotFound:
self.showNoFeedsErrorMessage()
default:
DispatchQueue.main.async {
switch result {
case .success(let feed):
if let articleFilter {
feed.articleFilters = [articleFilter]
}
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
case .failure(let error):
switch error {
case AccountError.createErrorAlreadySubscribed:
self.showAlreadySubscribedError(url.absoluteString)
case AccountError.createErrorNotFound:
self.showNoFeedsErrorMessage()
default:
NSApplication.shared.presentError(error)
}
}
Expand Down
Loading