mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-29 19:54:27 +00:00
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:
parent
53a234afcd
commit
4813da8c7f
6 changed files with 138 additions and 229 deletions
BIN
MacTorn-v1.0.zip
BIN
MacTorn-v1.0.zip
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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("MacTorn")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Enter your Torn API Key")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// API Key input
|
||||
SecureField("API Key", text: $inputKey)
|
||||
// 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)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
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)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Divider()
|
||||
|
||||
// Support section
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "gift.fill")
|
||||
.foregroundColor(.purple)
|
||||
Text("Support the Developer")
|
||||
.font(.caption.bold())
|
||||
}
|
||||
|
||||
Text("Send me some Xanax or cash :)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Notification Settings
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue