Compare commits

..

No commits in common. "main" and "v1.4.4" have entirely different histories.
main ... v1.4.4

31 changed files with 72 additions and 920 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,15 +0,0 @@
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create version for direct distribution. Update changelog, update readme, update version in gradle files, create a git commit and push it to github with release.
---
## Context
- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`
## Your task
Create version for direct distribution. Update changelog, update readme, update version in gradle files, create a git commit and push it to github with release.

View file

@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

View file

@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

3
.gitignore vendored
View file

@ -68,6 +68,3 @@ DerivedData/
# Archive folder (old releases) # Archive folder (old releases)
archive/ archive/
# Claude Code
CLAUDE.md

View file

@ -1,88 +0,0 @@
# Changelog
All notable changes to MacTorn will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.1] - 2026-02-04
### Added
- Expanded browser support in browser picker with additional browser options
## [1.5.0] - 2026-02-04
### Added
- Preferred browser support for opening Torn links (system default browser selection)
- GitHub Actions integration with Claude Code for automated PR assistance and code review
- BrowserManager utility for managing browser preferences across the app
### Changed
- Improved link handling to respect user's default browser choice
## [1.4.7] - 2026-01-27
### Added
- In-app feedback prompt with smart timing (1 hour, 1 week, 1 month thresholds)
- Positive feedback links to Torn forums thread
- Negative feedback opens email for direct developer contact
- 5-minute cooldown between prompt dismissals
- Comprehensive test coverage for feedback logic
## [1.4.6] - 2025-01-25
### Fixed
- Fixed incorrect "Released" notification triggering when landing from travel
- "Released! 🎉 - You are now free" notification now only fires when released from Hospital or Jail, not when arriving from airplane travel
## [1.4.5] - 2025-01-25
### Fixed
- Improved travel timer accuracy by using API timestamp directly instead of calculating from fetch time offset
- Travel countdown now stays synchronized regardless of network delays or fetch timing
### Added
- Comprehensive test coverage for travel timer calculations
## [1.4.4] - Previous Release
### Fixed
- Resolve Swift concurrency errors by extracting MainActor functions
- Fix watchlist item mutation to update via copy
### Added
- Universal Binary support for Intel and Apple Silicon Macs
- Improved accessibility support
- Display cooldown labels as text instead of icons
## [1.4.3] - Earlier Release
### Added
- GitHub wiki documentation
- Migrated wiki to GitHub Wiki feature
## [1.4.2] - Earlier Release
### Changed
- Various bug fixes and improvements
## [1.4.1] - Earlier Release
### Changed
- Various bug fixes and improvements
## [1.4] - Initial Public Release
### Added
- Native macOS menu bar app for Torn game monitoring
- Status tab with live bars, cooldowns, and travel monitoring
- Travel tab with live countdown timer in menu bar
- Money tab with cash, vault, points display
- Attacks tab with battle stats and recent attacks
- Faction tab with chain status
- Watchlist tab for item price tracking
- Smart notifications for various game events
- Configurable refresh intervals
- Launch at login support
- Light and dark mode support
- Accessibility support with Reduce Transparency

Binary file not shown.

BIN
MacTorn-v1.4.4.zip Normal file

Binary file not shown.

BIN
MacTorn/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -31,8 +31,6 @@
AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; }; AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; };
AAA00023 /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10024 /* CreditsView.swift */; }; AAA00023 /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10024 /* CreditsView.swift */; };
AAA00024 /* TransparencyEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10025 /* TransparencyEnvironment.swift */; }; AAA00024 /* TransparencyEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10025 /* TransparencyEnvironment.swift */; };
AAA00025 /* FeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10026 /* FeedbackPromptView.swift */; };
AAA00026 /* BrowserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10027 /* BrowserManager.swift */; };
/* Unit Tests */ /* Unit Tests */
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; }; BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; }; BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
@ -46,7 +44,6 @@
BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; }; BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; };
BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; }; BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; };
BBB00012 /* AppStateWatchlistTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10012 /* AppStateWatchlistTests.swift */; }; BBB00012 /* AppStateWatchlistTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10012 /* AppStateWatchlistTests.swift */; };
BBB00013 /* AppStateFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10013 /* AppStateFeedbackTests.swift */; };
/* UI Tests */ /* UI Tests */
CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; }; CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -94,8 +91,6 @@
AAA10023 /* TravelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelView.swift; sourceTree = "<group>"; }; AAA10023 /* TravelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelView.swift; sourceTree = "<group>"; };
AAA10024 /* CreditsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsView.swift; sourceTree = "<group>"; }; AAA10024 /* CreditsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsView.swift; sourceTree = "<group>"; };
AAA10025 /* TransparencyEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparencyEnvironment.swift; sourceTree = "<group>"; }; AAA10025 /* TransparencyEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparencyEnvironment.swift; sourceTree = "<group>"; };
AAA10026 /* FeedbackPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPromptView.swift; sourceTree = "<group>"; };
AAA10027 /* BrowserManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserManager.swift; sourceTree = "<group>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* Unit Test Files */ /* Unit Test Files */
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; }; BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
@ -110,7 +105,6 @@
BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = "<group>"; }; BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = "<group>"; };
BBB10011 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; }; BBB10011 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
BBB10012 /* AppStateWatchlistTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWatchlistTests.swift; sourceTree = "<group>"; }; BBB10012 /* AppStateWatchlistTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWatchlistTests.swift; sourceTree = "<group>"; };
BBB10013 /* AppStateFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFeedbackTests.swift; sourceTree = "<group>"; };
BBB10000 /* MacTornTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; BBB10000 /* MacTornTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* UI Test Files */ /* UI Test Files */
CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; }; CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; };
@ -219,7 +213,6 @@
AAA10013 /* ChainView.swift */, AAA10013 /* ChainView.swift */,
AAA10014 /* StatusBadgesView.swift */, AAA10014 /* StatusBadgesView.swift */,
AAA10015 /* EventsView.swift */, AAA10015 /* EventsView.swift */,
AAA10026 /* FeedbackPromptView.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@ -228,7 +221,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AAA10009 /* NotificationManager.swift */, AAA10009 /* NotificationManager.swift */,
AAA10027 /* BrowserManager.swift */,
AAA10011 /* LaunchAtLoginManager.swift */, AAA10011 /* LaunchAtLoginManager.swift */,
AAA10012 /* ShortcutsManager.swift */, AAA10012 /* ShortcutsManager.swift */,
AAA10016 /* SoundManager.swift */, AAA10016 /* SoundManager.swift */,
@ -300,7 +292,6 @@
children = ( children = (
BBB10011 /* AppStateTests.swift */, BBB10011 /* AppStateTests.swift */,
BBB10012 /* AppStateWatchlistTests.swift */, BBB10012 /* AppStateWatchlistTests.swift */,
BBB10013 /* AppStateFeedbackTests.swift */,
); );
path = ViewModels; path = ViewModels;
sourceTree = "<group>"; sourceTree = "<group>";
@ -466,8 +457,6 @@
AAA00022 /* TravelView.swift in Sources */, AAA00022 /* TravelView.swift in Sources */,
AAA00023 /* CreditsView.swift in Sources */, AAA00023 /* CreditsView.swift in Sources */,
AAA00024 /* TransparencyEnvironment.swift in Sources */, AAA00024 /* TransparencyEnvironment.swift in Sources */,
AAA00025 /* FeedbackPromptView.swift in Sources */,
AAA00026 /* BrowserManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -487,7 +476,6 @@
BBB00010 /* MoneyDataTests.swift in Sources */, BBB00010 /* MoneyDataTests.swift in Sources */,
BBB00011 /* AppStateTests.swift in Sources */, BBB00011 /* AppStateTests.swift in Sources */,
BBB00012 /* AppStateWatchlistTests.swift in Sources */, BBB00012 /* AppStateWatchlistTests.swift in Sources */,
BBB00013 /* AppStateFeedbackTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -651,7 +639,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -678,7 +666,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -696,7 +684,7 @@
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
@ -714,7 +702,7 @@
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
@ -732,7 +720,7 @@
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
@ -749,7 +737,7 @@
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;

View file

@ -14,7 +14,7 @@ struct MacTornApp: App {
.onAppear { .onAppear {
updateAppearance() updateAppearance()
} }
.onChange(of: appearanceModeRaw) { .onChange(of: appearanceModeRaw) { _ in
updateAppearance() updateAppearance()
} }
} label: { } label: {

View file

@ -1,11 +1,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
// MARK: - Constants
enum TornConstants {
static let developerID = 2362436
}
// MARK: - Root Response // MARK: - Root Response
struct TornResponse: Codable { struct TornResponse: Codable {
let name: String? let name: String?
@ -120,13 +115,6 @@ struct Travel: Codable, Equatable {
/// Calculate remaining seconds based on fetch time (for live countdown) /// Calculate remaining seconds based on fetch time (for live countdown)
func remainingSeconds(from fetchTime: Date) -> Int { func remainingSeconds(from fetchTime: Date) -> Int {
// Primary: Use timestamp directly if available (more accurate)
if let timestamp = timestamp, timestamp > 0 {
let now = Int(Date().timeIntervalSince1970)
return max(0, timestamp - now)
}
// Fallback: Use timeLeft with fetchTime offset (backward compatibility)
guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 } guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 }
let elapsed = Int(Date().timeIntervalSince(fetchTime)) let elapsed = Int(Date().timeIntervalSince(fetchTime))
return max(0, timeLeft - elapsed) return max(0, timeLeft - elapsed)
@ -378,7 +366,7 @@ struct AttackResult: Codable, Identifiable {
let result: String? let result: String?
let respect: Double? let respect: Double?
let id: String var id: String { code ?? UUID().uuidString }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case code case code
@ -391,33 +379,6 @@ struct AttackResult: Codable, Identifiable {
case result, respect case result, respect
} }
init(code: String?, timestampStarted: Int?, timestampEnded: Int?, attackerId: Int?, attackerName: String?, defenderId: Int?, defenderName: String?, result: String?, respect: Double?) {
self.code = code
self.timestampStarted = timestampStarted
self.timestampEnded = timestampEnded
self.attackerId = attackerId
self.attackerName = attackerName
self.defenderId = defenderId
self.defenderName = defenderName
self.result = result
self.respect = respect
self.id = code ?? UUID().uuidString
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code)
timestampStarted = try container.decodeIfPresent(Int.self, forKey: .timestampStarted)
timestampEnded = try container.decodeIfPresent(Int.self, forKey: .timestampEnded)
attackerId = try container.decodeIfPresent(Int.self, forKey: .attackerId)
attackerName = try container.decodeIfPresent(String.self, forKey: .attackerName)
defenderId = try container.decodeIfPresent(Int.self, forKey: .defenderId)
defenderName = try container.decodeIfPresent(String.self, forKey: .defenderName)
result = try container.decodeIfPresent(String.self, forKey: .result)
respect = try container.decodeIfPresent(Double.self, forKey: .respect)
id = code ?? UUID().uuidString
}
func opponentName(forUserId userId: Int) -> String { func opponentName(forUserId userId: Int) -> String {
let name: String? let name: String?
if attackerId == userId { if attackerId == userId {
@ -766,14 +727,6 @@ enum NotificationSound: String, CaseIterable {
} }
} }
// MARK: - App Feedback State
struct AppFeedbackState: Codable {
var firstLaunchDate: Date
var hasResponded: Bool
var dismissCount: Int
var lastDismissedDate: Date?
}
// MARK: - Keyboard Shortcuts // MARK: - Keyboard Shortcuts
struct KeyboardShortcut: Identifiable, Codable, Equatable { struct KeyboardShortcut: Identifiable, Codable, Equatable {
let id: String let id: String

View file

@ -1,112 +0,0 @@
import AppKit
enum PreferredBrowser: String, CaseIterable, Identifiable {
case system = "System Default"
case safari = "Safari"
case chrome = "Google Chrome"
case firefox = "Firefox"
case edge = "Microsoft Edge"
case brave = "Brave"
case arc = "Arc"
case vivaldi = "Vivaldi"
case zen = "Zen"
case opera = "Opera"
case duckduckgo = "DuckDuckGo"
case orion = "Orion"
case tor = "Tor Browser"
case chromium = "Chromium"
case librewolf = "LibreWolf"
case waterfox = "Waterfox"
case atlas = "ChatGPT Atlas"
var id: String { rawValue }
var bundleIdentifiers: [String]? {
switch self {
case .system:
return nil
case .safari:
return ["com.apple.Safari"]
case .chrome:
return ["com.google.Chrome"]
case .firefox:
return ["org.mozilla.firefox"]
case .edge:
return ["com.microsoft.edgemac"]
case .brave:
return ["com.brave.Browser"]
case .arc:
return ["company.thebrowser.Browser"]
case .vivaldi:
return ["com.vivaldi.Vivaldi"]
case .zen:
return ["app.zen-browser.zen"]
case .opera:
return ["com.operasoftware.Opera"]
case .duckduckgo:
return ["com.duckduckgo.macos.browser"]
case .orion:
return ["com.kagi.kagimacOS", "com.kagi.kagimacOS.RC"]
case .tor:
return ["com.torproject.tor"]
case .chromium:
return ["org.chromium.Chromium"]
case .librewolf:
return ["io.gitlab.librewolf-community"]
case .waterfox:
return ["net.waterfox.waterfox"]
case .atlas:
return ["com.openai.atlas"]
}
}
var installedApplicationURL: URL? {
guard let bundleIdentifiers else { return nil }
for bundleIdentifier in bundleIdentifiers {
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
return appURL
}
}
return nil
}
var isInstalled: Bool {
self == .system || installedApplicationURL != nil
}
static func availableBrowsers() -> [PreferredBrowser] {
PreferredBrowser.allCases.filter { $0.isInstalled }
}
init(storedValue: String?) {
guard let storedValue,
let value = PreferredBrowser(rawValue: storedValue) else {
self = .system
return
}
self = value
}
}
final class BrowserManager {
static let shared = BrowserManager()
private init() {}
func open(_ url: URL) {
guard let scheme = url.scheme,
["http", "https"].contains(scheme) else {
NSWorkspace.shared.open(url)
return
}
let preference = PreferredBrowser(storedValue: UserDefaults.standard.string(forKey: "preferredBrowser"))
guard let appURL = preference.installedApplicationURL else {
NSWorkspace.shared.open(url)
return
}
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: configuration, completionHandler: nil)
}
}

View file

@ -103,8 +103,14 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
/// Cancel all travel-related notifications /// Cancel all travel-related notifications
func cancelTravelNotifications() { func cancelTravelNotifications() {
let identifiers = TravelNotificationSetting.defaults.map { "\($0.id)_alert" } let identifiers = [
"travel_2min_alert",
"travel_1min_alert",
"travel_30sec_alert",
"travel_10sec_alert"
]
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
print("Cancelled travel notifications")
} }
/// Cancel a specific notification by identifier /// Cancel a specific notification by identifier
@ -121,7 +127,7 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
) { ) {
let categoryIdentifier = response.notification.request.content.categoryIdentifier let categoryIdentifier = response.notification.request.content.categoryIdentifier
if let type = NotificationType(rawValue: categoryIdentifier) { if let type = NotificationType(rawValue: categoryIdentifier) {
BrowserManager.shared.open(type.url) NSWorkspace.shared.open(type.url)
} }
completionHandler() completionHandler()
} }

View file

@ -41,6 +41,6 @@ class ShortcutsManager: ObservableObject {
func openURL(_ urlString: String) { func openURL(_ urlString: String) {
guard let url = URL(string: urlString) else { return } guard let url = URL(string: urlString) else { return }
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }

View file

@ -46,11 +46,6 @@ class AppState: ObservableObject {
// MARK: - Update State // MARK: - Update State
@Published var updateAvailable: GitHubRelease? @Published var updateAvailable: GitHubRelease?
// MARK: - Feedback State
@Published var feedbackState: AppFeedbackState?
@Published var showFeedbackPrompt: Bool = false
static let feedbackThresholds: [TimeInterval] = [3600, 7 * 86400, 30 * 86400]
// MARK: - Fetch Time (for live countdown calculations) // MARK: - Fetch Time (for live countdown calculations)
@Published var lastFetchTime: Date = Date() @Published var lastFetchTime: Date = Date()
@ -81,7 +76,6 @@ class AppState: ObservableObject {
loadNotificationRules() loadNotificationRules()
loadTravelNotificationSettings() loadTravelNotificationSettings()
loadWatchlist() loadWatchlist()
loadFeedbackState()
// Polling and permissions moved to onAppear in UI // Polling and permissions moved to onAppear in UI
} }
@ -306,15 +300,26 @@ class AppState: ObservableObject {
let sortedListings = allListings.sorted { $0.price < $1.price } let sortedListings = allListings.sorted { $0.price < $1.price }
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)") logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
if let best = sortedListings.first { await MainActor.run {
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0 if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice) if let best = sortedListings.first {
} else { watchlistItems[index].lowestPrice = best.price
await updateItemError(itemId: itemId, error: "No listings") watchlistItems[index].lowestPriceQuantity = best.amount
// Check for next distinct price or just next listing? usually user wants to know diff to next cheapest offer even if it's same price?
// Actually "second lowest price" usually implies the price of the *next available item*.
// But usually users want to know price steps.
// Let's stick to simple logic: price of the 2nd listing in sorted list.
watchlistItems[index].secondLowestPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
watchlistItems[index].lastUpdated = Date()
watchlistItems[index].error = nil
} else {
watchlistItems[index].error = "No listings"
}
saveWatchlist()
}
} }
} else {
logger.error("Item \(itemId): failed to parse JSON response")
await updateItemError(itemId: itemId, error: "Parse Error")
} }
} catch { } catch {
logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)") logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)")
@ -322,26 +327,10 @@ class AppState: ObservableObject {
} }
} }
@MainActor
private func updateItemPrice(itemId: Int, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
var item = watchlistItems[index]
item.lowestPrice = lowestPrice
item.lowestPriceQuantity = lowestPriceQuantity
item.secondLowestPrice = secondLowestPrice
item.lastUpdated = Date()
item.error = nil
watchlistItems[index] = item
saveWatchlist()
}
}
@MainActor @MainActor
private func updateItemError(itemId: Int, error: String) { private func updateItemError(itemId: Int, error: String) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) { if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
var item = watchlistItems[index] watchlistItems[index].error = error
item.error = error
watchlistItems[index] = item
saveWatchlist() saveWatchlist()
} }
} }
@ -581,10 +570,9 @@ class AppState: ObservableObject {
// Manage travel timer after data is set // Manage travel timer after data is set
self.manageTravelTimer() self.manageTravelTimer()
// Check if feedback prompt should be shown // Force UI update by triggering objectWillChange
self.checkFeedbackPrompt() self.objectWillChange.send()
logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
logger.info("Data updated, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
} }
} }
@ -672,7 +660,7 @@ class AppState: ObservableObject {
} }
if let prevStatus = previousStatus, let currentStatus = newData.status { if let prevStatus = previousStatus, let currentStatus = newData.status {
if (prevStatus.isInHospital || prevStatus.isInJail) && currentStatus.isOkay { if !prevStatus.isOkay && currentStatus.isOkay {
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released) NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released)
} }
} }
@ -723,73 +711,6 @@ class AppState: ObservableObject {
} }
} }
} }
// MARK: - Feedback Prompt
func loadFeedbackState() {
if let data = UserDefaults.standard.data(forKey: "appFeedbackState"),
let state = try? JSONDecoder().decode(AppFeedbackState.self, from: data) {
feedbackState = state
} else {
feedbackState = AppFeedbackState(
firstLaunchDate: Date(),
hasResponded: false,
dismissCount: 0,
lastDismissedDate: nil
)
saveFeedbackState()
}
}
func saveFeedbackState() {
guard let state = feedbackState,
let data = try? JSONEncoder().encode(state) else { return }
UserDefaults.standard.set(data, forKey: "appFeedbackState")
}
func checkFeedbackPrompt() {
guard let state = feedbackState else { return }
guard !state.hasResponded else { return }
guard state.dismissCount < Self.feedbackThresholds.count else { return }
// 5-minute cooldown after last dismissal
if let lastDismissed = state.lastDismissedDate,
Date().timeIntervalSince(lastDismissed) < 300 {
return
}
let elapsed = Date().timeIntervalSince(state.firstLaunchDate)
let requiredTime = Self.feedbackThresholds[state.dismissCount]
if elapsed >= requiredTime {
showFeedbackPrompt = true
}
}
func feedbackRespondedPositive() {
feedbackState?.hasResponded = true
showFeedbackPrompt = false
saveFeedbackState()
if let url = URL(string: "https://www.torn.com/forums.php#/p=threads&f=67&t=16532308") {
BrowserManager.shared.open(url)
}
}
func feedbackRespondedNegative() {
feedbackState?.hasResponded = true
showFeedbackPrompt = false
saveFeedbackState()
if let url = URL(string: "mailto:pawel@orzech.lol?subject=MacTorn%20Feedback") {
BrowserManager.shared.open(url)
}
}
func feedbackDismissed() {
feedbackState?.dismissCount += 1
feedbackState?.lastDismissedDate = Date()
showFeedbackPrompt = false
saveFeedbackState()
}
} }
// MARK: - Errors // MARK: - Errors

View file

@ -58,7 +58,7 @@ struct AttacksView: View {
Button { Button {
if let opponentId = attack.opponentId(forUserId: userId), if let opponentId = attack.opponentId(forUserId: userId),
let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") { let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
@ -128,7 +128,7 @@ struct AttacksView: View {
private func openURL(_ urlString: String) { private func openURL(_ urlString: String) {
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -1,74 +0,0 @@
import SwiftUI
struct FeedbackPromptView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
VStack(spacing: 16) {
Image(systemName: "heart.fill")
.font(.system(size: 32))
.foregroundStyle(
LinearGradient(
colors: [.pink, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("Enjoying MacTorn?")
.font(.headline)
Text("Your feedback helps make the app better for everyone.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Button {
appState.feedbackRespondedPositive()
} label: {
HStack(spacing: 6) {
Image(systemName: "hand.thumbsup.fill")
Text("Yes! Leave a review")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.2))
.cornerRadius(8)
}
.buttonStyle(.plain)
Button {
appState.feedbackRespondedNegative()
} label: {
HStack(spacing: 6) {
Image(systemName: "envelope.fill")
Text("Not really — send feedback")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.2))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
Button {
appState.feedbackDismissed()
} label: {
Text("Not now")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(20)
.frame(width: 260)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(reduceTransparency ? Color(.windowBackgroundColor) : Color(.windowBackgroundColor).opacity(0.95))
.shadow(radius: 8)
)
}
}

View file

@ -67,15 +67,6 @@ struct ContentView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
// Feedback Prompt Overlay
if appState.showFeedbackPrompt {
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.3))
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
FeedbackPromptView()
.environmentObject(appState)
}
} }
.frame(width: 320) .frame(width: 320)
.onAppear { .onAppear {
@ -91,7 +82,7 @@ struct ContentView: View {
private var headerView: some View { private var headerView: some View {
HStack { HStack {
if let lastUpdated = appState.lastUpdated { if let lastUpdated = appState.lastUpdated {
Text("Updated: \(lastUpdated, formatter: Self.timeFormatter)") Text("Updated: \(lastUpdated, formatter: timeFormatter)")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -177,9 +168,9 @@ struct ContentView: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
private static let timeFormatter: DateFormatter = { private var timeFormatter: DateFormatter {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.timeStyle = .short formatter.timeStyle = .short
return formatter return formatter
}() }
} }

View file

@ -5,7 +5,7 @@ struct CreditsView: View {
@Binding var showCredits: Bool @Binding var showCredits: Bool
// MARK: - Developer // MARK: - Developer
private let developer = TornContributor(name: "bombel", tornID: TornConstants.developerID) private let developer = TornContributor(name: "bombel", tornID: 2362436)
// MARK: - Special Thanks // MARK: - Special Thanks
private let specialThanks: [TornContributor] = [ private let specialThanks: [TornContributor] = [
@ -92,9 +92,7 @@ struct CreditsView: View {
} }
Button { Button {
if let tornID = developer.tornID { openTornProfile(developer.tornID!)
openTornProfile(tornID)
}
} label: { } label: {
HStack { HStack {
Text(developer.name) Text(developer.name)
@ -240,21 +238,21 @@ struct CreditsView: View {
private func openTornProfile(_ tornID: Int) { private func openTornProfile(_ tornID: Int) {
let urlString = "https://www.torn.com/profiles.php?XID=\(tornID)" let urlString = "https://www.torn.com/profiles.php?XID=\(tornID)"
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
private func openFaction(_ factionID: Int) { private func openFaction(_ factionID: Int) {
let urlString = "https://www.torn.com/factions.php?step=profile&ID=\(factionID)" let urlString = "https://www.torn.com/factions.php?step=profile&ID=\(factionID)"
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
private func openCompany(_ ownerID: Int) { private func openCompany(_ ownerID: Int) {
let urlString = "https://www.torn.com/joblist.php#/p=corpinfo&userID=\(ownerID)" let urlString = "https://www.torn.com/joblist.php#/p=corpinfo&userID=\(ownerID)"
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -132,7 +132,7 @@ struct FactionView: View {
private func openURL(_ urlString: String) { private func openURL(_ urlString: String) {
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -106,7 +106,7 @@ struct MoneyView: View {
private func openURL(_ urlString: String) { private func openURL(_ urlString: String) {
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -52,7 +52,7 @@ struct PropertiesView: View {
private func openURL(_ urlString: String) { private func openURL(_ urlString: String) {
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -4,12 +4,11 @@ struct SettingsView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue @AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false @AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
@AppStorage("preferredBrowser") private var preferredBrowser: String = PreferredBrowser.system.rawValue
@State private var inputKey: String = "" @State private var inputKey: String = ""
@State private var showCredits: Bool = false @State private var showCredits: Bool = false
@State private var availableBrowsers: [PreferredBrowser] = PreferredBrowser.availableBrowsers()
private let developerID = TornConstants.developerID // Developer ID for tip feature (bombel)
private let developerID = 2362436
var body: some View { var body: some View {
if showCredits { if showCredits {
@ -73,7 +72,7 @@ struct SettingsView: View {
Text("2m").tag(120) Text("2m").tag(120)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: appState.refreshInterval) { .onChange(of: appState.refreshInterval) { _ in
Task { @MainActor in Task { @MainActor in
appState.startPolling() appState.startPolling()
} }
@ -107,20 +106,6 @@ struct SettingsView: View {
.labelsHidden() .labelsHidden()
} }
// Preferred Browser
HStack {
Image(systemName: "globe")
.foregroundColor(.secondary)
.frame(width: 20)
Picker("Preferred Browser", selection: $preferredBrowser) {
ForEach(availableBrowsers) { browser in
Text(browser.rawValue).tag(browser.rawValue)
}
}
.pickerStyle(.menu)
}
// Reduce Transparency (Accessibility) // Reduce Transparency (Accessibility)
HStack { HStack {
Image(systemName: "eye") Image(systemName: "eye")
@ -179,7 +164,7 @@ struct SettingsView: View {
Button("Download Update") { Button("Download Update") {
if let url = URL(string: update.htmlUrl) { if let url = URL(string: update.htmlUrl) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@ -230,15 +215,6 @@ struct SettingsView: View {
.frame(width: 320) .frame(width: 320)
.onAppear { .onAppear {
inputKey = appState.apiKey inputKey = appState.apiKey
refreshAvailableBrowsers()
}
}
private func refreshAvailableBrowsers() {
let browsers = PreferredBrowser.availableBrowsers()
availableBrowsers = browsers
if !browsers.contains(where: { $0.rawValue == preferredBrowser }) {
preferredBrowser = PreferredBrowser.system.rawValue
} }
} }
@ -292,7 +268,7 @@ struct SettingsView: View {
private func openTornProfile() { private func openTornProfile() {
let url = "https://www.torn.com/profiles.php?XID=\(developerID)" let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
if let url = URL(string: url) { if let url = URL(string: url) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -99,7 +99,7 @@ struct StatusView: View {
private var messagesBadge: some View { private var messagesBadge: some View {
Button { Button {
if let url = URL(string: "https://www.torn.com/messages.php") { if let url = URL(string: "https://www.torn.com/messages.php") {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
HStack { HStack {

View file

@ -149,7 +149,7 @@ struct TravelView: View {
Button { Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") { if let url = URL(string: "https://www.torn.com/travelagency.php") {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
HStack { HStack {
@ -204,7 +204,7 @@ struct TravelView: View {
// Show only return button when abroad // Show only return button when abroad
Button { Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") { if let url = URL(string: "https://www.torn.com/travelagency.php") {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
HStack { HStack {
@ -234,7 +234,7 @@ struct TravelView: View {
private func destinationButton(_ destination: TornDestination) -> some View { private func destinationButton(_ destination: TornDestination) -> some View {
Button { Button {
BrowserManager.shared.open(destination.travelAgencyURL) NSWorkspace.shared.open(destination.travelAgencyURL)
} label: { } label: {
VStack(spacing: 4) { VStack(spacing: 4) {
HStack(spacing: 4) { HStack(spacing: 4) {
@ -304,7 +304,7 @@ struct TravelView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Button { Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") { if let url = URL(string: "https://www.torn.com/travelagency.php") {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
HStack { HStack {
@ -321,7 +321,7 @@ struct TravelView: View {
Button { Button {
if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") { if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
HStack { HStack {

View file

@ -126,7 +126,7 @@ struct WatchlistView: View {
private func openURL(_ urlString: String) { private func openURL(_ urlString: String) {
if let url = URL(string: urlString) { if let url = URL(string: urlString) {
BrowserManager.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
} }

View file

@ -132,108 +132,4 @@ final class TravelTests: XCTestCase {
let travel = try decode(Travel.self, from: json) let travel = try decode(Travel.self, from: json)
XCTAssertFalse(travel.isTraveling) XCTAssertFalse(travel.isTraveling)
} }
// MARK: - remainingSeconds Tests
func testRemainingSeconds_usesTimestampDirectly() throws {
// Set arrival time 60 seconds in the future
let futureTimestamp = Int(Date().timeIntervalSince1970) + 60
let json: [String: Any] = [
"destination": "Japan",
"timestamp": futureTimestamp,
"departed": futureTimestamp - 1000,
"time_left": 60
]
let travel = try decode(Travel.self, from: json)
// Even with a stale fetchTime, should use timestamp directly
let staleFetchTime = Date().addingTimeInterval(-300) // 5 minutes ago
let remaining = travel.remainingSeconds(from: staleFetchTime)
// Should be approximately 60 seconds (allow 1-2 seconds tolerance for test execution)
XCTAssertGreaterThanOrEqual(remaining, 58)
XCTAssertLessThanOrEqual(remaining, 62)
}
func testRemainingSeconds_fallsBackToTimeLeftWhenTimestampNil() throws {
let json: [String: Any] = [
"destination": "Japan",
"time_left": 120
]
let travel = try decode(Travel.self, from: json)
let fetchTime = Date()
let remaining = travel.remainingSeconds(from: fetchTime)
// Should use timeLeft since timestamp is nil
XCTAssertGreaterThanOrEqual(remaining, 118)
XCTAssertLessThanOrEqual(remaining, 120)
}
func testRemainingSeconds_fallsBackToTimeLeftWhenTimestampZero() throws {
let json: [String: Any] = [
"destination": "Japan",
"timestamp": 0,
"time_left": 90
]
let travel = try decode(Travel.self, from: json)
let fetchTime = Date()
let remaining = travel.remainingSeconds(from: fetchTime)
// Should use timeLeft since timestamp is 0
XCTAssertGreaterThanOrEqual(remaining, 88)
XCTAssertLessThanOrEqual(remaining, 90)
}
func testRemainingSeconds_returnsZeroWhenArrivalPassed() throws {
// Set arrival time in the past
let pastTimestamp = Int(Date().timeIntervalSince1970) - 60
let json: [String: Any] = [
"destination": "Japan",
"timestamp": pastTimestamp,
"departed": pastTimestamp - 1000,
"time_left": 0
]
let travel = try decode(Travel.self, from: json)
let remaining = travel.remainingSeconds(from: Date())
XCTAssertEqual(remaining, 0)
}
func testRemainingSeconds_consistentRegardlessOfFetchTime() throws {
// Set arrival time 120 seconds in the future
let futureTimestamp = Int(Date().timeIntervalSince1970) + 120
let json: [String: Any] = [
"destination": "Japan",
"timestamp": futureTimestamp,
"departed": futureTimestamp - 1000,
"time_left": 120
]
let travel = try decode(Travel.self, from: json)
// Test with different fetchTimes - result should be the same
let recentFetchTime = Date()
let staleFetchTime = Date().addingTimeInterval(-60)
let veryOldFetchTime = Date().addingTimeInterval(-600)
let remaining1 = travel.remainingSeconds(from: recentFetchTime)
let remaining2 = travel.remainingSeconds(from: staleFetchTime)
let remaining3 = travel.remainingSeconds(from: veryOldFetchTime)
// All should return approximately the same value (within 1 second tolerance)
XCTAssertEqual(remaining1, remaining2, accuracy: 1)
XCTAssertEqual(remaining2, remaining3, accuracy: 1)
}
func testRemainingSeconds_zeroWhenNotTraveling() throws {
let json: [String: Any] = [
"destination": "Torn",
"time_left": 0
]
let travel = try decode(Travel.self, from: json)
let remaining = travel.remainingSeconds(from: Date())
XCTAssertEqual(remaining, 0)
}
} }

View file

@ -1,186 +0,0 @@
import XCTest
@testable import MacTorn
@MainActor
final class AppStateFeedbackTests: XCTestCase {
var mockSession: MockNetworkSession!
var appState: AppState!
override func setUp() async throws {
try await super.setUp()
mockSession = MockNetworkSession()
UserDefaults.standard.removeObject(forKey: "appFeedbackState")
appState = AppState(session: mockSession)
}
override func tearDown() async throws {
appState.stopPolling()
appState = nil
mockSession = nil
UserDefaults.standard.removeObject(forKey: "appFeedbackState")
try await super.tearDown()
}
// MARK: - First Launch
func testFirstLaunch_createsFeedbackState() {
XCTAssertNotNil(appState.feedbackState)
XCTAssertFalse(appState.feedbackState!.hasResponded)
XCTAssertEqual(appState.feedbackState!.dismissCount, 0)
XCTAssertNil(appState.feedbackState!.lastDismissedDate)
}
// MARK: - Threshold Logic
func testBeforeOneHour_promptDoesNotShow() {
// firstLaunchDate is just now, so less than 1 hour has elapsed
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterOneHour_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissOnce_needsOneWeek() {
// Set first launch to 2 hours ago, dismiss once
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-2 * 3600)
appState.feedbackState?.dismissCount = 1
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600) // 10 min ago (past cooldown)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
// 2 hours < 1 week, so should not show
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterDismissOnce_afterOneWeek_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-8 * 86400) // 8 days ago
appState.feedbackState?.dismissCount = 1
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissTwice_needsOneMonth() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-14 * 86400) // 14 days ago
appState.feedbackState?.dismissCount = 2
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
// 14 days < 30 days, so should not show
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterDismissTwice_afterOneMonth_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-31 * 86400) // 31 days ago
appState.feedbackState?.dismissCount = 2
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissThreeTimes_neverShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-365 * 86400) // 1 year ago
appState.feedbackState?.dismissCount = 3
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Responses
func testPositiveResponse_setsHasRespondedAndHidesPrompt() {
appState.showFeedbackPrompt = true
appState.feedbackRespondedPositive()
XCTAssertTrue(appState.feedbackState!.hasResponded)
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testNegativeResponse_setsHasRespondedAndHidesPrompt() {
appState.showFeedbackPrompt = true
appState.feedbackRespondedNegative()
XCTAssertTrue(appState.feedbackState!.hasResponded)
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Dismiss
func testDismiss_incrementsDismissCount() {
XCTAssertEqual(appState.feedbackState!.dismissCount, 0)
appState.feedbackDismissed()
XCTAssertEqual(appState.feedbackState!.dismissCount, 1)
XCTAssertFalse(appState.showFeedbackPrompt)
XCTAssertNotNil(appState.feedbackState!.lastDismissedDate)
}
// MARK: - After Responded
func testAfterResponded_neverShowsAgain() {
appState.feedbackState?.hasResponded = true
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-365 * 86400)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Persistence
func testStatePersistsAcrossAppStateInstances() {
// Set a specific first launch date and dismiss once
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-86400)
appState.feedbackState?.dismissCount = 1
appState.saveFeedbackState()
// Create a new AppState instance (simulates app restart)
let newAppState = AppState(session: mockSession)
XCTAssertNotNil(newAppState.feedbackState)
XCTAssertEqual(newAppState.feedbackState!.dismissCount, 1)
// firstLaunchDate should be approximately 1 day ago
let elapsed = Date().timeIntervalSince(newAppState.feedbackState!.firstLaunchDate)
XCTAssertTrue(elapsed > 86300 && elapsed < 86500)
newAppState.stopPolling()
}
// MARK: - Cooldown
func testFiveMinuteCooldown_preventsImmediateReshow() {
// Set eligible threshold (1 hour elapsed, dismissCount 0)
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
// But dismissed just 2 minutes ago
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-120)
// dismissCount is still 0 since we're simulating the state manually
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterCooldown_promptCanShow() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
// Dismissed 6 minutes ago (past the 5-minute cooldown)
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-360)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
}

View file

@ -136,8 +136,8 @@ final class MacTornUITests: XCTestCase {
// MARK: - UI Test Helpers // MARK: - UI Test Helpers
extension XCUIElement { extension XCUIElement {
/// Wait for element to appear within the given timeout /// Wait for element to exist with timeout
func waitForAppearance(timeout: TimeInterval = 5) -> Bool { func waitForExistence(timeout: TimeInterval = 5) -> Bool {
return self.waitForExistence(timeout: timeout) return self.waitForExistence(timeout: timeout)
} }

View file

@ -13,11 +13,6 @@ A native macOS menu bar app for monitoring your **Torn** game status.
<img src="app_dark_1.png" alt="MacTorn Dark Mode" width="320"> <img src="app_dark_1.png" alt="MacTorn Dark Mode" width="320">
</p> </p>
## Documentation
For detailed documentation, visit the [MacTorn Wiki](https://github.com/pawelorzech/MacTorn/wiki).
For community discussion and feedback, see the [Torn forums thread](https://www.torn.com/forums.php#/p=threads&f=67&t=16532308).
## Features ## Features
### 📊 Status Tab ### 📊 Status Tab
@ -28,7 +23,7 @@ For community discussion and feedback, see the [Torn forums thread](https://www.
- Hospital/Jail status badges - Hospital/Jail status badges
- Unread messages badge - Unread messages badge
- Events feed - Events feed
- 8 quick links - 8 customizable quick links
### ✈️ Travel Tab ### ✈️ Travel Tab
- **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32) - **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32)