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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ Thumbs.db
*.backup
*.bak
*.swp

# AI
.rules
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Role and Goal
You are a Senior Software Engineer. Your primary goal is to produce code that is clean, maintainable, secure, and robust, following industry best practices for production-grade software.

# Core Principles
1. **Simplicity & Practicality (KISS & YAGNI):** Prioritize clear, straightforward solutions. Avoid over-engineering. Follow the "Keep It Simple, Stupid" and "You Ain't Gonna Need It" principles.
2. **Defensive Programming & Security:**
- **Never trust external input.** Rigorously validate and sanitize all inputs from users, APIs, files, or databases to prevent vulnerabilities (e.g., SQL injection, XSS).
- Apply the **Principle of Least Privilege**. Code should only have the permissions necessary to perform its function.
- Handle sensitive data with care.

3. **Robustness & Fault Tolerance:**
- **Anticipate failures.** Proactively handle potential errors (e.g., network timeouts, file not found, null references, invalid operations) and edge cases.
- Use `try-catch` blocks or equivalent error-handling mechanisms to ensure **graceful failure** instead of crashing.
- Provide clear, actionable error messages or logs for debugging.

4. **Maintainability (DRY):** Adhere to the "Don't Repeat Yourself" principle. Maximize code reuse through functions, classes, and modules. Strive for low cyclomatic complexity.
5. **Robust Design (SOLID):**
- Design with clear, modular boundaries (High Cohesion, Low Coupling).
- Apply design patterns only when they provide a clear benefit to the problem at hand.
- Adhere to the **Open/Closed Principle**: aim to extend functionality without modifying existing, stable code.

# Output Format
- **Language:** All explanations, analysis, and conversational text must be in **Chinese**.
- **Code Comments:** All comments within the code blocks must be in **English**.
28 changes: 25 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SwiftLint configuration with code quality rules
- Platform-aware lock implementation
- Documentation for testing and CI/CD
- **Parameter validation**: CrashBufferTree now validates `maxSize` (must be > 0 and <= 10000)
- **Tag length validation**: CanopyContext.with() now validates tag length (max 100 characters)
- **Flush failure logging**: CrashBufferTree now logs flush failures via NSLog
- **Comprehensive test coverage**: Increased from 87 to 91 tests

### Changed

- `formatMessage()`: Optimized to use single-pass counting instead of `components(separatedBy:)`
- `Tree.tag()`: Fixed race condition by reading and clearing atomically
- `Canopy.log()`: Eliminated code duplication, consolidated two log method overloads
- **Empty tag format**: Improved readability - empty tags now display as `[priority]: message` instead of `[] message`
- **DebugTree log level**: Fixed `warning` level mapping from `.error` to `.debug` for accurate OSLogType mapping

### Fixed

- **Signal handler safety**: Removed `flush()` from signal handlers to prevent deadlocks (NSLock is not async-signal-safe)
- **AsyncTree context recovery**: Added `defer` to ensure CanopyContext is restored even if log() throws
- **CanopyContext.with()**: Added whitespace trimming and empty string handling
- **Tree.tag()**: Fixed logic to correctly handle empty and whitespace-only strings
- Thread safety issues with `explicitTag` in concurrent scenarios
- Code duplication in Canopy.swift (reduced from 169 to 138 lines)
- SwiftLint violations across the codebase
Expand All @@ -36,6 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added performance benchmarks for all critical paths
- Zero-overhead logging verified in Release mode

### Security

- **CrashBufferTree**: Fixed potential deadlock in signal handlers by moving flush to atexit() handler
- **Input validation**: Added parameter validation to prevent invalid input scenarios

---

## [0.1.0] - 2026-01-08
Expand Down Expand Up @@ -74,20 +89,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| Version | Date | Status |
|---------|------|--------|
| [Unreleased] | - | In development |
| [0.2.0] | 2026-01-09 | **Current Release** - Stability & Security Improvements |
| [0.1.0] | 2026-01-08 | Initial release |

---

## Migration Guides

### Upgrading from 0.1.0
### Migration Guides

### Upgrading from 0.1.0 to 0.2.0

No breaking changes. API remains fully backward compatible. Recommended changes:

No breaking changes in unreleased version. API remains fully backward compatible.
1. **New validations**: CrashBufferTree now validates `maxSize` parameter. Ensure your code uses valid values (1-10000).
2. **Improved tag handling**: Empty tags now display more cleanly. No action needed.
3. **Security fix**: Signal handler safety improved. No action needed.

### Migration Checklist

1. Update dependency version in `Package.swift` or `Podfile`
2. Review new API additions if needed
2. Review new parameter validation if using CrashBufferTree with custom maxSize
3. Run test suite to verify compatibility

---
Expand Down
2 changes: 1 addition & 1 deletion Canopy.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'Canopy'
s.version = '0.1.0'
s.version = '0.2.0'
s.summary = 'A lightweight, high-performance logging framework for iOS'
s.description = <<-DESC
Canopy is a logging framework inspired by Android's Timber, using a Tree-based architecture.
Expand Down
3 changes: 1 addition & 2 deletions Canopy/Sources/AsyncTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public final class AsyncTree: Tree, @unchecked Sendable {
queue.async { [wrapped] in
let previous = CanopyContext.current
CanopyContext.current = currentContext
defer { CanopyContext.current = previous }

wrapped.log(
priority: priority,
Expand All @@ -52,8 +53,6 @@ public final class AsyncTree: Tree, @unchecked Sendable {
function: function,
line: line
)

CanopyContext.current = previous
}
}
}
9 changes: 8 additions & 1 deletion Canopy/Sources/CanopyContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ enum CanopyContext {
/// - Returns: The return value of the block
@discardableResult
nonisolated static func with<T>(_ tag: String?, block: () throws -> T) rethrows -> T {
let trimmedTag = tag?.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveTag = (trimmedTag?.isEmpty ?? true) ? nil : trimmedTag

if let effectiveTag, effectiveTag.count > 100 {
fatalError("CanopyContext.with: Tag too long (max 100 chars), got \(effectiveTag.count) chars")
}

let previous = current
current = tag
current = effectiveTag
defer { current = previous }
return try block()
}
Expand Down
32 changes: 27 additions & 5 deletions Canopy/Sources/CrashBufferTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ public final class CrashBufferTree: Tree, @unchecked Sendable {
private let lock = NSLock()

public init(maxSize: Int = 100) {
guard maxSize > 0 else {
fatalError("CrashBufferTree: maxSize must be greater than 0, got \(maxSize)")
}
guard maxSize <= 10000 else {
fatalError("CrashBufferTree: maxSize too large, limit is 10000, got \(maxSize)")
}
self.maxSize = maxSize
super.init()

Expand All @@ -54,7 +60,8 @@ public final class CrashBufferTree: Tree, @unchecked Sendable {

private nonisolated func checkAndFlushOnCrash() {
if crashSignalOccurred == 1 {
flush()
// Do not flush in signal handler - NSLock is not async-signal-safe
// Flush only happens via atexit() handler
crashSignalOccurred = 0
}
}
Expand All @@ -74,7 +81,8 @@ public final class CrashBufferTree: Tree, @unchecked Sendable {
let effectiveTag = explicitTag ?? tag
explicitTag = nil

let msg = "[\(priority)] \(effectiveTag ?? ""): \(message())"
let tagString = effectiveTag ?? ""
let msg = tagString.isEmpty ? "[\(priority)] : \(message())" : "[\(priority)] : \(tagString): \(message())"
lock.lock()
buffer.append(msg)
if buffer.count > maxSize { buffer.removeFirst() }
Expand All @@ -84,9 +92,23 @@ public final class CrashBufferTree: Tree, @unchecked Sendable {
nonisolated func flush() {
lock.lock()
defer { lock.unlock() }
guard let data = buffer.joined(separator: "\n").data(using: .utf8) else { return }
guard let url = documentsURL()?.appendingPathComponent("canopy_crash_buffer.txt") else { return }
try? data.write(to: url, options: .atomic)

guard let data = buffer.joined(separator: "\n").data(using: .utf8) else {
NSLog("Canopy: Failed to encode buffer to UTF-8")
return
}

guard let url = documentsURL()?.appendingPathComponent("canopy_crash_buffer.txt") else {
NSLog("Canopy: Failed to get documents directory")
return
}

do {
try data.write(to: url, options: .atomic)
NSLog("Canopy: Successfully flushed \(buffer.count) logs to \(url.path)")
} catch {
NSLog("Canopy: Failed to flush buffer to \(url.path): \(error.localizedDescription)")
}
}

nonisolated private func documentsURL() -> URL? {
Expand Down
14 changes: 10 additions & 4 deletions Canopy/Sources/DebugTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,18 @@ open class DebugTree: Tree, @unchecked Sendable {

let fileName = (file.withUTF8Buffer { String(decoding: $0, as: UTF8.self) } as NSString).lastPathComponent
let sourceRef = "\(fileName):\(line)"
let output = "[\(effectiveTag)] \(fullMessage) (\(sourceRef))"

let output: String
if effectiveTag.isEmpty {
output = "\(fullMessage) (\(sourceRef))"
} else {
output = "[\(effectiveTag)] \(fullMessage) (\(sourceRef))"
}

#if canImport(os.log)
if #available(macOS 11.0, iOS 14.0, *) {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Canopy", category: effectiveTag)
let subsystem = Bundle.main.bundleIdentifier ?? "com.canopy.logger"
let logger = Logger(subsystem: subsystem, category: effectiveTag)
let osLevel: OSLogType = priority.osLogLevel
logger.log(level: osLevel, "\(output)")
return
Expand Down Expand Up @@ -63,9 +70,8 @@ open class DebugTree: Tree, @unchecked Sendable {
private extension LogLevel {
nonisolated var osLogLevel: OSLogType {
switch self {
case .verbose, .debug: return .debug
case .verbose, .debug, .warning: return .debug
case .info: return .info
case .warning: return .error
case .error: return .fault
}
}
Expand Down
2 changes: 1 addition & 1 deletion Canopy/Sources/Tree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ open class Tree: @unchecked Sendable {

@discardableResult
nonisolated open func tag(_ tag: String?) -> Self {
self.explicitTag = tag?.isEmpty == false ? tag : nil
self.explicitTag = (tag?.isEmpty ?? true) ? nil : tag
return self
}

Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Canopy

> 🌲 Canopy: A tree canopy covering your entire app's forest with comprehensive logging insights.
> 🌲 A tree canopy covering your entire app's forest with comprehensive logging insights.

A lightweight, high-performance logging framework for iOS, inspired by Android's Timber.

Expand Down Expand Up @@ -78,6 +78,26 @@ Canopy.plant(DebugTree())
### CrashBufferTree
Stores recent logs in memory. On crash, saves them to file for analysis.

**Parameter Validation:**
- `maxSize` must be > 0 (throws fatalError if 0 or negative)
- `maxSize` must be <= 10000 (throws fatalError if exceeded)
- Recommended range: 10-500 for optimal performance

**Empty Tag Handling:**
- When tag is `nil` or empty, the log format is `[priority]: message`
- No square brackets `[]` are displayed for empty tags
- This improves readability and avoids confusing `[nil] message` output

**Signal Handler Safety:**
- `flush()` is NOT called in signal handlers (NSLock is not async-signal-safe)
- Flush only occurs via `atexit()` handler on normal app termination
- Signal handlers only set the crash flag, actual flush happens safely

**Flush Failure Logging:**
- Failed flush operations are logged via `NSLog`
- Errors: UTF-8 encoding failure, missing documents directory, file write errors
- Success: Number of logs flushed and file path are logged

```swift
let crashTree = CrashBufferTree(maxSize: 100)
Canopy.plant(crashTree)
Expand Down
20 changes: 20 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ Canopy.plant(DebugTree())

在内存中保存最近的日志。崩溃时保存到文件用于分析。

**参数验证:**
- `maxSize` 必须 > 0(如果为 0 或负数会抛出 fatalError)
- `maxSize` 必须 <= 10000(如果超出限制会抛出 fatalError)
- 推荐范围:10-500 以获得最佳性能

**空标签处理:**
- 当 tag 为 `nil` 或空时,日志格式为 `[priority]: message`
- 空标签不会显示方括号 `[]`
- 这提高了可读性,避免了令人困惑的 `[nil] message` 输出

**信号处理器安全:**
- `flush()` **不会**在信号处理器中调用(NSLock 不是 async-signal-safe)
- Flush 只在正常程序终止时通过 `atexit()` 处理器发生
- 信号处理器只设置崩溃标志,实际的 flush 安全发生

**Flush 失败日志:**
- 失败的 flush 操作通过 `NSLog` 记录
- 错误:UTF-8 编码失败、缺少文档目录、文件写入错误
- 成功:记录 flush 的日志数量和文件路径

```swift
let crashTree = CrashBufferTree(maxSize: 100)
Canopy.plant(crashTree)
Expand Down
4 changes: 2 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ swift test --enable-code-coverage

| Suite | Tests | Description |
|-------|-------|-------------|
| CanopyTests | 24 | Core logging functionality |
| CanopyTests | 27 | Core logging functionality |
| TreeTests | 15 | Tree base class |
| DebugTreeTests | 5 | DebugTree functionality |
| AsyncTreeTests | 8 | AsyncTree functionality |
| CrashBufferTreeTests | 9 | CrashBufferTree functionality |
| CanopyBenchmarkTests | 15 | Performance benchmarks |
| CanopyCrashRecoveryTests | 12 | Crash recovery integration |

**Total: 87 tests**
**Total: 91 tests**

---

Expand Down
4 changes: 2 additions & 2 deletions TESTING.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ swift test --enable-code-coverage

| 套件 | 测试数 | 描述 |
|------|--------|------|
| CanopyTests | 24 | 核心日志功能 |
| CanopyTests | 27 | 核心日志功能 |
| TreeTests | 15 | Tree 基类 |
| DebugTreeTests | 5 | DebugTree 功能 |
| AsyncTreeTests | 8 | AsyncTree 功能 |
| CrashBufferTreeTests | 9 | CrashBufferTree 功能 |
| CanopyBenchmarkTests | 15 | 性能基准测试 |
| CanopyCrashRecoveryTests | 12 | 崩溃恢复集成测试 |

**总计:87 个测试**
**总计:91 个测试**

---

Expand Down