Make Torn models more robust to missing API data

Updated TornModels.swift to make many properties optional, improving resilience to missing or incomplete API responses. Adjusted ChainView and StatusView to handle optional values safely. Refactored SettingsView for a cleaner layout and removed notification rules UI. Updated README to reflect notification changes and clarify features.
This commit is contained in:
Paweł Orzech 2026-01-17 19:53:28 +00:00
parent 53a234afcd
commit 4813da8c7f
No known key found for this signature in database
6 changed files with 138 additions and 229 deletions

Binary file not shown.

View file

@ -85,10 +85,10 @@ struct Cooldowns: Codable, Equatable {
// MARK: - Travel
struct Travel: Codable, Equatable {
let destination: String
let timestamp: Int
let departed: Int
let timeLeft: Int
let destination: String?
let timestamp: Int?
let departed: Int?
let timeLeft: Int?
enum CodingKeys: String, CodingKey {
case destination
@ -98,25 +98,27 @@ struct Travel: Codable, Equatable {
}
var isAbroad: Bool {
destination != "Torn" && timeLeft == 0
guard let dest = destination, let time = timeLeft else { return false }
return dest != "Torn" && time == 0
}
var isTraveling: Bool {
timeLeft > 0
guard let time = timeLeft else { return false }
return time > 0
}
var arrivalDate: Date? {
guard isTraveling else { return nil }
return Date(timeIntervalSince1970: TimeInterval(timestamp))
guard isTraveling, let ts = timestamp else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ts))
}
}
// MARK: - Status (Hospital/Jail)
struct Status: Codable, Equatable {
let description: String
let description: String?
let details: String?
let state: String
let until: Int
let state: String?
let until: Int?
var isInHospital: Bool {
state == "Hospital"
@ -127,31 +129,35 @@ struct Status: Codable, Equatable {
}
var isOkay: Bool {
state == "Okay"
state == "Okay" || state == nil
}
var timeRemaining: Int {
max(0, until - Int(Date().timeIntervalSince1970))
guard let until = until else { return 0 }
return max(0, until - Int(Date().timeIntervalSince1970))
}
}
// MARK: - Chain
struct Chain: Codable, Equatable {
let current: Int
let maximum: Int
let timeout: Int
let cooldown: Int
let current: Int?
let maximum: Int?
let timeout: Int?
let cooldown: Int?
var isActive: Bool {
current > 0 && timeout > 0
guard let current = current, let timeout = timeout else { return false }
return current > 0 && timeout > 0
}
var isOnCooldown: Bool {
cooldown > 0
guard let cooldown = cooldown else { return false }
return cooldown > 0
}
var timeoutRemaining: Int {
max(0, timeout - Int(Date().timeIntervalSince1970))
guard let timeout = timeout else { return 0 }
return max(0, timeout - Int(Date().timeIntervalSince1970))
}
}
@ -159,7 +165,7 @@ struct Chain: Codable, Equatable {
struct TornEvent: Codable, Identifiable {
let timestamp: Int
let event: String
let seen: Int
let seen: Int?
var id: Int { timestamp }
@ -175,11 +181,11 @@ struct TornEvent: Codable, Identifiable {
// MARK: - Messages
struct TornMessage: Codable {
let name: String
let type: String
let title: String
let timestamp: Int
let read: Int
let name: String?
let type: String?
let title: String?
let timestamp: Int?
let read: Int?
}
// MARK: - Error

View file

@ -9,7 +9,7 @@ struct ChainView: View {
HStack {
Image(systemName: "link")
.foregroundColor(timeoutColor)
Text("Chain: \(chain.current)/\(chain.maximum)")
Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
.font(.caption.bold())
Spacer()

View file

@ -3,36 +3,30 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@State private var inputKey: String = ""
@State private var showNotificationSettings = false
// Developer ID for tip feature (bombel)
private let developerID = 2362436
var body: some View {
ScrollView {
VStack(spacing: 16) {
// Header
Image(systemName: "bolt.circle.fill")
.font(.system(size: 48))
.foregroundStyle(
LinearGradient(
colors: [.orange, .yellow],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: 20) {
// Header
Image(systemName: "bolt.circle.fill")
.font(.system(size: 48))
.foregroundStyle(
LinearGradient(
colors: [.orange, .yellow],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Text("MacTorn")
.font(.title2.bold())
Text("Enter your Torn API Key")
.font(.caption)
.foregroundColor(.secondary)
// API Key input
SecureField("API Key", text: $inputKey)
)
Text("MacTorn")
.font(.title2.bold())
// API Key section
VStack(spacing: 8) {
SecureField("Torn API Key", text: $inputKey)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button("Save & Connect") {
appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines)
@ -44,147 +38,100 @@ struct SettingsView: View {
Link("Get API Key from Torn",
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
.font(.caption)
Divider()
.padding(.vertical, 8)
}
.padding(.horizontal)
Divider()
// Settings
VStack(spacing: 12) {
// Refresh Interval
refreshIntervalSection
HStack {
Image(systemName: "clock")
.foregroundColor(.secondary)
.frame(width: 20)
Picker("Refresh", selection: Binding(
get: { appState.refreshInterval },
set: { newValue in
appState.refreshInterval = newValue
appState.startPolling()
}
)) {
Text("15s").tag(15)
Text("30s").tag(30)
Text("60s").tag(60)
Text("2m").tag(120)
}
.pickerStyle(.segmented)
}
// Launch at Login
Toggle(isOn: Binding(
get: { appState.launchAtLogin.isEnabled },
set: { _ in appState.launchAtLogin.toggle() }
)) {
Label("Launch at Login", systemImage: "power")
HStack {
Image(systemName: "power")
.foregroundColor(.secondary)
.frame(width: 20)
Toggle("Launch at Login", isOn: Binding(
get: { appState.launchAtLogin.isEnabled },
set: { _ in appState.launchAtLogin.toggle() }
))
.toggleStyle(.switch)
}
}
.padding(.horizontal)
Divider()
// Support section
VStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: "gift.fill")
.foregroundColor(.purple)
Text("Support the Developer")
.font(.caption.bold())
}
.toggleStyle(.switch)
.padding(.horizontal)
// Notification Settings
Text("Send me some Xanax or cash :)")
.font(.caption2)
.foregroundColor(.secondary)
Button {
showNotificationSettings.toggle()
openTornProfile()
} label: {
HStack {
Image(systemName: "bell.badge")
Text("Notification Rules")
Spacer()
Image(systemName: showNotificationSettings ? "chevron.up" : "chevron.down")
HStack(spacing: 4) {
Image(systemName: "paperplane.fill")
Text("Send to bombel")
}
.font(.caption)
.padding(.horizontal)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(Color.purple.opacity(0.15))
.cornerRadius(6)
}
.buttonStyle(.plain)
if showNotificationSettings {
notificationRulesSection
}
Divider()
.padding(.vertical, 4)
// Tip Me section
tipMeSection
// GitHub link
githubSection
}
.padding()
.padding(.vertical, 8)
.padding(.horizontal)
.background(Color.purple.opacity(0.05))
.cornerRadius(8)
// GitHub
HStack(spacing: 4) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundColor(.gray)
Link("View on GitHub",
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
.font(.caption)
}
}
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(width: 320)
.onAppear {
inputKey = appState.apiKey
}
}
// MARK: - Refresh Interval
private var refreshIntervalSection: some View {
HStack {
Label("Refresh Interval", systemImage: "clock")
.font(.caption)
Spacer()
Picker("", selection: Binding(
get: { appState.refreshInterval },
set: { newValue in
appState.refreshInterval = newValue
appState.startPolling()
}
)) {
Text("15s").tag(15)
Text("30s").tag(30)
Text("60s").tag(60)
Text("120s").tag(120)
}
.pickerStyle(.segmented)
.frame(width: 180)
}
.padding(.horizontal)
}
// MARK: - Notification Rules
private var notificationRulesSection: some View {
VStack(spacing: 8) {
ForEach(appState.notificationRules) { rule in
NotificationRuleRow(rule: rule) { updatedRule in
appState.updateRule(updatedRule)
}
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
}
// MARK: - Tip Me Section
private var tipMeSection: some View {
VStack(spacing: 8) {
HStack {
Image(systemName: "gift.fill")
.foregroundColor(.purple)
Text("Support the Developer")
.font(.caption.bold())
}
Text("Send me some Xanax or cash :)")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
openTornProfile()
} label: {
HStack {
Image(systemName: "paperplane.fill")
Text("Send Xanax to bombel")
}
.font(.caption)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(Color.purple.opacity(0.15))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
.padding()
.background(Color.purple.opacity(0.05))
.cornerRadius(8)
}
// MARK: - GitHub Section
private var githubSection: some View {
HStack {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.foregroundColor(.gray)
Link("View Source on GitHub",
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
.font(.caption)
}
}
// MARK: - Helpers
private func openTornProfile() {
let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
if let url = URL(string: url) {
@ -192,46 +139,3 @@ struct SettingsView: View {
}
}
}
// MARK: - Notification Rule Row
struct NotificationRuleRow: View {
let rule: NotificationRule
let onUpdate: (NotificationRule) -> Void
var body: some View {
HStack {
Toggle(isOn: Binding(
get: { rule.enabled },
set: { newValue in
var updated = rule
updated.enabled = newValue
onUpdate(updated)
}
)) {
HStack {
Text(rule.barType.rawValue)
.font(.caption)
Text("@ \(rule.threshold)%")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.toggleStyle(.switch)
.controlSize(.small)
Picker("", selection: Binding(
get: { rule.soundName },
set: { newValue in
var updated = rule
updated.soundName = newValue
onUpdate(updated)
}
)) {
ForEach(NotificationSound.allCases, id: \.rawValue) { sound in
Text(sound.displayName).tag(sound.rawValue)
}
}
.frame(width: 80)
}
}
}

View file

@ -131,7 +131,7 @@ struct StatusView: View {
HStack {
Image(systemName: "airplane")
.foregroundColor(.blue)
Text(travel.isTraveling ? "Traveling to \(travel.destination)" : "In \(travel.destination)")
Text(travel.isTraveling ? "Traveling to \(travel.destination ?? "Unknown")" : "In \(travel.destination ?? "Unknown")")
.font(.caption.bold())
}
@ -140,7 +140,7 @@ struct StatusView: View {
Text("Arriving in:")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatTime(travel.timeLeft))
Text(formatTime(travel.timeLeft ?? 0))
.font(.caption.monospacedDigit())
.foregroundColor(.blue)
}

View file

@ -9,14 +9,15 @@ A native macOS menu bar app for monitoring your **Torn** game status.
## Features
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown
- 🔗 **Chain Timer** - Active chain counter with timeout warning
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns with ready state
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown and abroad state
- 🔗 **Chain Timer** - Active chain counter with timeout warning + cooldown state
- 🏥 **Hospital/Jail Status** - Countdown to release
- 📨 **Unread Messages** - Quick access to inbox
- 📨 **Unread Messages** - Inbox badge with one-click open
- 🔔 **Events Feed** - Recent activity at a glance
- 🔔 **Smart Notifications** - Custom threshold alerts with sound options
- ⚡ **Quick Links** - 8 configurable shortcuts to Torn pages
- 🔔 **Notifications** - Bars thresholds, cooldown ready, landing, chain expiring, and release
- ⚡ **Quick Links** - Grid of customizable Torn shortcuts (8 defaults)
- 🕒 **Refresh Control** - 15s/30s/60s/2m polling + manual refresh + last updated
- 🚀 **Launch at Login** - Start automatically with macOS
## Installation
@ -26,7 +27,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
3. Open MacTorn from Applications
4. Enter your [Torn API Key](https://www.torn.com/preferences.php#tab=api)
> **Note**: On first launch, macOS may show a security warning. Right-click the app and select "Open" to bypass.
> **Note**: If you download an unsigned build, macOS Gatekeeper will block it. Right-click the app and select "Open", or go to System Settings → Privacy & Security → Open Anyway.
## Requirements
@ -38,10 +39,8 @@ A native macOS menu bar app for monitoring your **Torn** game status.
### Refresh Interval
Choose polling frequency: 15s, 30s, 60s, or 120s
### Notification Rules
Customize when to receive alerts:
- Energy/Nerve/Happy/Life at specific thresholds
- Sound selection per notification type
### Notifications
MacTorn sends notifications for bar thresholds, cooldown ready, landing, chain expiring, and release. Notification defaults are stored locally.
### Quick Links
8 preset shortcuts to common Torn pages (fully editable)