Compare commits

...

31 commits
v1.4.2 ... main

Author SHA1 Message Date
Paweł Orzech
56037885d0
Merge pull request #8 from pawelorzech/fix/medium-priority-phase2
Some checks failed
Tests / Unit Tests (push) Has been cancelled
Tests / UI Tests (push) Has been cancelled
Tests / Build Release (push) Has been cancelled
Fix safety issues, deprecated APIs, and code quality
2026-02-27 23:34:46 +01:00
Paweł Orzech
22f6e5d8e4
Fix safety issues, deprecated APIs, and code quality improvements
- Replace force unwrap developer.tornID! with safe optional binding
- Generate travel notification IDs dynamically from TravelNotificationSetting.defaults
  instead of hardcoded strings, preventing cancellation gaps
- Extract duplicated developer ID (2362436) into TornConstants enum
- Change DateFormatter from computed property to static let to avoid
  expensive re-creation on every render
- Remove redundant objectWillChange.send() already handled by @Published
- Migrate deprecated onChange(of:) { _ in } to new parameterless closure form
2026-02-27 23:34:25 +01:00
Paweł Orzech
464bfea0a4
Merge pull request #7 from pawelorzech/fix/critical-bugs-phase1
Fix 3 critical bugs: infinite recursion, stuck loading, unstable IDs
2026-02-27 23:30:52 +01:00
Paweł Orzech
032ff5887c
Fix 3 critical bugs: infinite recursion, stuck loading state, unstable IDs
- Rename waitForExistence() to waitForAppearance() in UI test helpers
  to fix infinite recursion caused by shadowing XCUIElement's built-in method
- Add else branch in fetchItemPrice() so watchlist items show "Parse Error"
  instead of staying stuck in loading state when JSON parsing fails
- Change AttackResult.id from computed property (generating new UUID on
  every access) to stored property assigned once at init, preventing
  SwiftUI re-render thrashing in ForEach
2026-02-27 23:30:15 +01:00
Paweł Orzech
0ea44f891a
Remove MacTorn v1.4.4–v1.4.7 zip files
Delete binary release archives MacTorn-v1.4.4.zip through MacTorn-v1.4.7.zip from the repository. Removes the four zipped release files from version control to clean up binary assets and reduce repo size.
2026-02-04 14:29:56 +01:00
Paweł Orzech
c46da1e13f
Release version 1.5.1 2026-02-04 14:15:28 +01:00
Paweł Orzech
7b7fe98666
Merge pull request #6 from pawelorzech/codex/added_new_browsers
Include broader browser discovery in picker
2026-02-04 14:12:00 +01:00
Paweł Orzech
bbeb89b9ba
Show installed browser options 2026-02-04 14:06:53 +01:00
Paweł Orzech
bbf977c6c0
Merge pull request #5 from pawelorzech/codex/linktotheforum
Add forum discussion link to README
2026-02-04 14:04:48 +01:00
Paweł Orzech
4391a8b6b4
Add forum link to README 2026-02-04 14:04:31 +01:00
Paweł Orzech
d1166d3218
Release version 1.5.0
- Add preferred browser support for opening Torn links
- Add GitHub Actions integration with Claude Code
- Add BrowserManager utility for managing browser preferences
2026-02-04 13:47:56 +01:00
Paweł Orzech
9b8eaed844
Merge pull request #3 from pawelorzech/codex/github-mention-default-browser-suggested-improvement
Add preferred browser setting for opening links
2026-02-04 13:42:32 +01:00
Paweł Orzech
bdaee4dcf1
Merge pull request #4 from pawelorzech/add-claude-github-actions-1770208442743
Add Claude Code GitHub Workflow
2026-02-04 13:34:28 +01:00
Paweł Orzech
36d2214c60 "Claude Code Review workflow" 2026-02-04 13:34:05 +01:00
Paweł Orzech
4b339d86eb "Claude PR Assistant workflow" 2026-02-04 13:34:04 +01:00
Paweł Orzech
cd2f6ce653 Add BrowserManager to app target 2026-02-04 13:25:19 +01:00
Paweł Orzech
0b5b156182 Add preferred browser support for links 2026-02-04 12:52:12 +01:00
Paweł Orzech
3e214a0b19
Add feedback prompt and release v1.4.7
Add in-app feedback prompt with progressive timing thresholds
(1 hour, 1 week, 1 month) and 5-minute cooldown. Includes
FeedbackPromptView, AppFeedbackState model, and comprehensive
test coverage.
2026-01-27 23:43:17 +01:00
Paweł Orzech
b1804e5a69
fix: Prevent incorrect "Released" notification on travel arrival
The "Released! 🎉 - You are now free" notification was incorrectly
triggering when landing from airplane travel. Changed condition to
only fire when transitioning from Hospital or Jail status, not any
non-Okay state like Traveling.

Bumps version to 1.4.6.
2026-01-25 15:57:49 +01:00
Paweł Orzech
63cef35384
Add new version release instructions and v1.4.5 zip
Added a markdown file with instructions for creating a new version release, including changelog and version updates. Also added the MacTorn-v1.4.5.zip file for direct distribution.
2026-01-25 12:07:44 +01:00
Paweł Orzech
e4c8f6927b
fix: Improve travel timer accuracy with direct timestamp usage
- Use API timestamp directly for travel countdown calculations
- Add fallback to timeLeft for backward compatibility
- Add comprehensive test coverage for remainingSeconds method
- Bump version to 1.4.5
- Add CHANGELOG.md
2026-01-25 12:02:56 +01:00
Paweł Orzech
a55be3c6be
Update README.md 2026-01-20 13:31:42 +00:00
Paweł Orzech
a2d3e6416f
docs: Migrate wiki to GitHub Wiki feature
Move documentation from wiki/ folder to GitHub Wiki repository.
Add wiki link to README for easy access.
2026-01-20 13:30:20 +00:00
Paweł Orzech
715f0877ff
docs: Add comprehensive GitHub wiki documentation
Create wiki/ directory with 11 markdown pages covering:
- Home, Installation, Getting Started guides
- Features documentation for all tabs
- API Setup with permissions and security
- Configuration options and settings
- Troubleshooting and FAQ
- Development guide with architecture overview
- Changelog with version history
- Sidebar navigation
2026-01-20 13:24:55 +00:00
Paweł Orzech
e10add9474
fix: Resolve Swift concurrency errors by extracting MainActor functions
Extract watchlist mutations into dedicated @MainActor functions to avoid
actor-isolated property access issues in async context. This fixes CI
build failures caused by strict concurrency checking.
2026-01-20 13:22:26 +00:00
Paweł Orzech
9724bcbacb
Fix watchlist item mutation to update via copy
Refactored watchlist item updates to use value semantics by copying, modifying, and reassigning the item in the array. This prevents issues with direct mutation of value types in Swift arrays and ensures changes are properly reflected.
2026-01-20 13:18:42 +00:00
Paweł Orzech
8a4fb30cad
chore: Remove root .DS_Store from git tracking 2026-01-20 13:12:19 +00:00
Paweł Orzech
4414a2696a
chore: Remove .DS_Store from git tracking 2026-01-20 13:10:25 +00:00
Paweł Orzech
21ac399269
feat: Improve accessibility support and update README
- Fix Reduce Transparency mode in light mode (dark buttons issue)
- Lower opacity values for better readability when reduceTransparency is enabled
- Add Accessibility section to README documenting macOS accessibility support
- Update README with new light/dark mode screenshots
- Bump version to 1.4.4
2026-01-20 13:00:11 +00:00
Paweł Orzech
273fd31884
feat: Add Universal Binary support for Intel and Apple Silicon Macs
- Add ARCHS="arm64 x86_64" to Release configuration for universal builds
- Update Info.plist to use dynamic version variables (MARKETING_VERSION, CURRENT_PROJECT_VERSION)
- Update Makefile release target with proper universal build settings
- Add Universal Binary badge to README
2026-01-19 16:46:03 +00:00
Paweł Orzech
7f836a0bbd
feat: Display cooldown labels as text instead of icons
Show "Drug", "Medical", "Booster" text labels in cooldowns section
for better clarity. Bump version to 1.4.3.
2026-01-19 16:16:53 +00:00
40 changed files with 1104 additions and 134 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -0,0 +1,15 @@
---
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

@ -0,0 +1,44 @@
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

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
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,3 +68,6 @@ DerivedData/
# Archive folder (old releases)
archive/
# Claude Code
CLAUDE.md

88
CHANGELOG.md Normal file
View file

@ -0,0 +1,88 @@
# 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

BIN
MacTorn-1.5.1.zip Normal file

Binary file not shown.

BIN
MacTorn/.DS_Store vendored

Binary file not shown.

View file

@ -30,6 +30,9 @@
AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.swift */; };
AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; };
AAA00023 /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10024 /* CreditsView.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 */
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
@ -43,6 +46,7 @@
BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; };
BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.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 */
CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; };
/* End PBXBuildFile section */
@ -89,6 +93,9 @@
AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.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>"; };
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; };
/* Unit Test Files */
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
@ -103,6 +110,7 @@
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>"; };
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; };
/* UI Test Files */
CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; };
@ -155,6 +163,7 @@
AAA30005 /* Views */,
AAA30007 /* Utilities */,
AAA30008 /* Networking */,
AAA30009 /* Helpers */,
);
path = MacTorn;
sourceTree = "<group>";
@ -210,6 +219,7 @@
AAA10013 /* ChainView.swift */,
AAA10014 /* StatusBadgesView.swift */,
AAA10015 /* EventsView.swift */,
AAA10026 /* FeedbackPromptView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -218,6 +228,7 @@
isa = PBXGroup;
children = (
AAA10009 /* NotificationManager.swift */,
AAA10027 /* BrowserManager.swift */,
AAA10011 /* LaunchAtLoginManager.swift */,
AAA10012 /* ShortcutsManager.swift */,
AAA10016 /* SoundManager.swift */,
@ -233,6 +244,14 @@
path = Networking;
sourceTree = "<group>";
};
AAA30009 /* Helpers */ = {
isa = PBXGroup;
children = (
AAA10025 /* TransparencyEnvironment.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
/* Unit Tests Groups */
BBB30000 /* MacTornTests */ = {
isa = PBXGroup;
@ -281,6 +300,7 @@
children = (
BBB10011 /* AppStateTests.swift */,
BBB10012 /* AppStateWatchlistTests.swift */,
BBB10013 /* AppStateFeedbackTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -445,6 +465,9 @@
AAA00021 /* NetworkSession.swift in Sources */,
AAA00022 /* TravelView.swift in Sources */,
AAA00023 /* CreditsView.swift in Sources */,
AAA00024 /* TransparencyEnvironment.swift in Sources */,
AAA00025 /* FeedbackPromptView.swift in Sources */,
AAA00026 /* BrowserManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -464,6 +487,7 @@
BBB00010 /* MoneyDataTests.swift in Sources */,
BBB00011 /* AppStateTests.swift in Sources */,
BBB00012 /* AppStateWatchlistTests.swift in Sources */,
BBB00013 /* AppStateFeedbackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -604,6 +628,7 @@
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
ARCHS = "arm64 x86_64";
};
name = Release;
};
@ -626,7 +651,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -653,7 +678,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -671,7 +696,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -689,7 +714,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -707,7 +732,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -724,7 +749,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.2;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;

View file

@ -0,0 +1,13 @@
import SwiftUI
// Environment key for reduce transparency setting
private struct ReduceTransparencyKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var reduceTransparency: Bool {
get { self[ReduceTransparencyKey.self] }
set { self[ReduceTransparencyKey.self] = newValue }
}
}

View file

@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.3</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1.3</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View file

@ -3,16 +3,37 @@ import SwiftUI
@main
struct MacTornApp: App {
@StateObject private var appState = AppState()
@AppStorage("appearanceMode") private var appearanceModeRaw: String = AppearanceMode.system.rawValue
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
var body: some Scene {
MenuBarExtra {
ContentView()
.environmentObject(appState)
.environment(\.reduceTransparency, reduceTransparency)
.onAppear {
updateAppearance()
}
.onChange(of: appearanceModeRaw) {
updateAppearance()
}
} label: {
MenuBarLabel(appState: appState)
}
.menuBarExtraStyle(.window)
}
private func updateAppearance() {
let mode = AppearanceMode(rawValue: appearanceModeRaw) ?? .system
switch mode {
case .system:
NSApp.appearance = nil
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
}
}
}
// MARK: - Menu Bar Label

View file

@ -1,6 +1,11 @@
import Foundation
import SwiftUI
// MARK: - Constants
enum TornConstants {
static let developerID = 2362436
}
// MARK: - Root Response
struct TornResponse: Codable {
let name: String?
@ -115,6 +120,13 @@ struct Travel: Codable, Equatable {
/// Calculate remaining seconds based on fetch time (for live countdown)
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 }
let elapsed = Int(Date().timeIntervalSince(fetchTime))
return max(0, timeLeft - elapsed)
@ -366,7 +378,7 @@ struct AttackResult: Codable, Identifiable {
let result: String?
let respect: Double?
var id: String { code ?? UUID().uuidString }
let id: String
enum CodingKeys: String, CodingKey {
case code
@ -379,6 +391,33 @@ struct AttackResult: Codable, Identifiable {
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 {
let name: String?
if attackerId == userId {
@ -727,6 +766,14 @@ 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
struct KeyboardShortcut: Identifiable, Codable, Equatable {
let id: String

View file

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

View file

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

View file

@ -5,11 +5,27 @@ import os.log
private let logger = Logger(subsystem: "com.mactorn", category: "AppState")
// MARK: - Appearance
enum AppearanceMode: String, CaseIterable {
case system = "System"
case light = "Light"
case dark = "Dark"
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
@MainActor
class AppState: ObservableObject {
// MARK: - Persisted
@AppStorage("apiKey") var apiKey: String = ""
@AppStorage("refreshInterval") var refreshInterval: Int = 30
@AppStorage("appearanceMode") var appearanceMode: String = AppearanceMode.system.rawValue
// MARK: - Published State
@Published var data: TornResponse?
@ -30,6 +46,11 @@ class AppState: ObservableObject {
// MARK: - Update State
@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)
@Published var lastFetchTime: Date = Date()
@ -60,6 +81,7 @@ class AppState: ObservableObject {
loadNotificationRules()
loadTravelNotificationSettings()
loadWatchlist()
loadFeedbackState()
// Polling and permissions moved to onAppear in UI
}
@ -284,26 +306,15 @@ class AppState: ObservableObject {
let sortedListings = allListings.sorted { $0.price < $1.price }
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
await MainActor.run {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
if let best = sortedListings.first {
watchlistItems[index].lowestPrice = best.price
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()
}
if let best = sortedListings.first {
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice)
} else {
await updateItemError(itemId: itemId, error: "No listings")
}
} else {
logger.error("Item \(itemId): failed to parse JSON response")
await updateItemError(itemId: itemId, error: "Parse Error")
}
} catch {
logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)")
@ -311,10 +322,26 @@ 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
private func updateItemError(itemId: Int, error: String) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
watchlistItems[index].error = error
var item = watchlistItems[index]
item.error = error
watchlistItems[index] = item
saveWatchlist()
}
}
@ -554,9 +581,10 @@ class AppState: ObservableObject {
// Manage travel timer after data is set
self.manageTravelTimer()
// Force UI update by triggering objectWillChange
self.objectWillChange.send()
logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
// Check if feedback prompt should be shown
self.checkFeedbackPrompt()
logger.info("Data updated, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
}
}
@ -644,7 +672,7 @@ class AppState: ObservableObject {
}
if let prevStatus = previousStatus, let currentStatus = newData.status {
if !prevStatus.isOkay && currentStatus.isOkay {
if (prevStatus.isInHospital || prevStatus.isInJail) && currentStatus.isOkay {
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released)
}
}
@ -695,6 +723,73 @@ 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

View file

@ -2,6 +2,7 @@ import SwiftUI
struct AttacksView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -39,7 +40,7 @@ struct AttacksView: View {
}
}
.padding()
.background(Color.red.opacity(0.05))
.background(Color.red.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Recent Attacks
@ -57,7 +58,7 @@ struct AttacksView: View {
Button {
if let opponentId = attack.opponentId(forUserId: userId),
let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
} label: {
HStack(spacing: 6) {
@ -91,7 +92,7 @@ struct AttacksView: View {
}
}
.padding()
.background(Color.orange.opacity(0.05))
.background(Color.orange.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Actions
@ -127,13 +128,14 @@ struct AttacksView: View {
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}
// MARK: - Stat Item
struct StatItem: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let label: String
let value: String
let color: Color
@ -149,7 +151,7 @@ struct StatItem: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(color.opacity(0.1))
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(4)
}
}

View file

@ -1,6 +1,7 @@
import SwiftUI
struct ChainView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let chain: Chain
let fetchTime: Date
@ -25,7 +26,7 @@ struct ChainView: View {
}
}
.padding(8)
.background(color.opacity(0.1))
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
} else if chain.isOnCooldown {

View file

@ -1,6 +1,7 @@
import SwiftUI
struct EventsView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let events: [TornEvent]
var body: some View {
@ -33,7 +34,7 @@ struct EventsView: View {
}
}
.padding(8)
.background(Color.blue.opacity(0.05))
.background(Color.blue.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
}

View file

@ -0,0 +1,74 @@
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

@ -1,6 +1,7 @@
import SwiftUI
struct ProgressBarView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let label: String
let current: Int
let maximum: Int
@ -40,10 +41,10 @@ struct ProgressBarView: View {
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.3))
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.3))
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(color.opacity(0.3), lineWidth: 1)
.stroke(color.opacity(reduceTransparency ? 0.5 : 0.3), lineWidth: 1)
)
// Filled progress
@ -51,13 +52,13 @@ struct ProgressBarView: View {
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [color, color.opacity(0.7)],
colors: [color, color.opacity(reduceTransparency ? 0.9 : 0.7)],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(4, geometry.size.width * progress))
.shadow(color: color.opacity(0.5), radius: 2, x: 0, y: 0)
.shadow(color: color.opacity(reduceTransparency ? 0.7 : 0.5), radius: 2, x: 0, y: 0)
}
}
}

View file

@ -1,6 +1,7 @@
import SwiftUI
struct StatusBadgesView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let status: Status
var body: some View {
@ -18,7 +19,7 @@ struct StatusBadgesView: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.1))
.background(Color.red.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
@ -34,7 +35,7 @@ struct StatusBadgesView: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.1))
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
}

View file

@ -22,6 +22,7 @@ enum AppTab: String, CaseIterable {
struct ContentView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
@State private var showSettings = false
@State private var currentTab: AppTab = .status
@ -55,8 +56,8 @@ struct ContentView: View {
// Loading Overlay
if appState.isLoading && appState.lastUpdated == nil {
Color.black.opacity(0.4)
.background(.ultraThinMaterial)
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.4))
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
VStack(spacing: 12) {
ProgressView()
@ -66,6 +67,15 @@ struct ContentView: View {
.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)
.onAppear {
@ -81,7 +91,7 @@ struct ContentView: View {
private var headerView: some View {
HStack {
if let lastUpdated = appState.lastUpdated {
Text("Updated: \(lastUpdated, formatter: timeFormatter)")
Text("Updated: \(lastUpdated, formatter: Self.timeFormatter)")
.font(.caption2)
.foregroundColor(.secondary)
}
@ -106,7 +116,7 @@ struct ContentView: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(currentTab == tab ? Color.accentColor.opacity(0.2) : Color.clear)
.background(currentTab == tab ? Color.accentColor.opacity(reduceTransparency ? 0.3 : 0.2) : Color.clear)
.cornerRadius(6)
.contentShape(Rectangle()) // Make entire area clickable
}
@ -167,9 +177,9 @@ struct ContentView: View {
.padding(.bottom, 8)
}
private var timeFormatter: DateFormatter {
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter
}
}()
}

View file

@ -1,10 +1,11 @@
import SwiftUI
struct CreditsView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
@Binding var showCredits: Bool
// MARK: - Developer
private let developer = TornContributor(name: "bombel", tornID: 2362436)
private let developer = TornContributor(name: "bombel", tornID: TornConstants.developerID)
// MARK: - Special Thanks
private let specialThanks: [TornContributor] = [
@ -91,7 +92,9 @@ struct CreditsView: View {
}
Button {
openTornProfile(developer.tornID!)
if let tornID = developer.tornID {
openTornProfile(tornID)
}
} label: {
HStack {
Text(developer.name)
@ -107,7 +110,7 @@ struct CreditsView: View {
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.orange.opacity(0.1))
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -139,7 +142,7 @@ struct CreditsView: View {
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -171,7 +174,7 @@ struct CreditsView: View {
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -199,7 +202,7 @@ struct CreditsView: View {
}
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -237,21 +240,21 @@ struct CreditsView: View {
private func openTornProfile(_ tornID: Int) {
let urlString = "https://www.torn.com/profiles.php?XID=\(tornID)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
private func openFaction(_ factionID: Int) {
let urlString = "https://www.torn.com/factions.php?step=profile&ID=\(factionID)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
private func openCompany(_ ownerID: Int) {
let urlString = "https://www.torn.com/joblist.php#/p=corpinfo&userID=\(ownerID)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct FactionView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -33,7 +34,7 @@ struct FactionView: View {
.foregroundColor(chainColor(faction.chain))
}
.padding(8)
.background(chainColor(faction.chain).opacity(0.1))
.background(chainColor(faction.chain).opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
@ -58,7 +59,7 @@ struct FactionView: View {
}
}
.padding()
.background(Color.blue.opacity(0.05))
.background(Color.blue.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Armory Quick Actions
@ -85,7 +86,7 @@ struct FactionView: View {
}
}
.padding()
.background(Color.purple.opacity(0.05))
.background(Color.purple.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Actions
@ -131,13 +132,14 @@ struct FactionView: View {
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}
// MARK: - Armory Button
struct ArmoryButton: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let title: String
let icon: String
let color: Color
@ -153,7 +155,7 @@ struct ArmoryButton: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(color.opacity(0.15))
.background(color.opacity(reduceTransparency ? 0.4 : 0.15))
.foregroundColor(color)
.cornerRadius(6)
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct MoneyView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -72,7 +73,7 @@ struct MoneyView: View {
}
}
.padding()
.background(Color.green.opacity(0.05))
.background(Color.green.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Actions
@ -105,13 +106,14 @@ struct MoneyView: View {
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}
// MARK: - Action Button Component
struct ActionButton: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let title: String
let icon: String
let color: Color
@ -127,7 +129,7 @@ struct ActionButton: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(color.opacity(0.1))
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.foregroundColor(color)
.cornerRadius(8)
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct PropertiesView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -51,13 +52,14 @@ struct PropertiesView: View {
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}
// MARK: - Property Card
struct PropertyCard: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let property: PropertyInfo
var body: some View {
@ -72,7 +74,7 @@ struct PropertyCard: View {
.foregroundColor(.orange)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.background(Color.orange.opacity(reduceTransparency ? 0.5 : 0.2))
.cornerRadius(4)
}
}
@ -112,7 +114,7 @@ struct PropertyCard: View {
}
}
.padding()
.background(Color.brown.opacity(0.05))
.background(Color.brown.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
}

View file

@ -2,11 +2,14 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
@AppStorage("preferredBrowser") private var preferredBrowser: String = PreferredBrowser.system.rawValue
@State private var inputKey: String = ""
@State private var showCredits: Bool = false
@State private var availableBrowsers: [PreferredBrowser] = PreferredBrowser.availableBrowsers()
// Developer ID for tip feature (bombel)
private let developerID = 2362436
private let developerID = TornConstants.developerID
var body: some View {
if showCredits {
@ -70,7 +73,7 @@ struct SettingsView: View {
Text("2m").tag(120)
}
.pickerStyle(.segmented)
.onChange(of: appState.refreshInterval) { _ in
.onChange(of: appState.refreshInterval) {
Task { @MainActor in
appState.startPolling()
}
@ -88,6 +91,44 @@ struct SettingsView: View {
))
.toggleStyle(.switch)
}
// Appearance Mode
HStack {
Image(systemName: "moon.circle")
.foregroundColor(.secondary)
.frame(width: 20)
Picker("Appearance", selection: $appearanceMode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
.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)
HStack {
Image(systemName: "eye")
.foregroundColor(.secondary)
.frame(width: 20)
Toggle("Reduce Transparency", isOn: $reduceTransparency)
.toggleStyle(.switch)
}
}
.padding(.horizontal)
@ -116,14 +157,14 @@ struct SettingsView: View {
.font(.caption)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(Color.purple.opacity(0.15))
.background(Color.purple.opacity(reduceTransparency ? 0.4 : 0.15))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
.padding(.vertical, 8)
.padding(.horizontal)
.background(Color.purple.opacity(0.05))
.background(Color.purple.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Update Section
@ -138,7 +179,7 @@ struct SettingsView: View {
Button("Download Update") {
if let url = URL(string: update.htmlUrl) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
@ -146,7 +187,7 @@ struct SettingsView: View {
}
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.green.opacity(0.1))
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
@ -189,6 +230,15 @@ struct SettingsView: View {
.frame(width: 320)
.onAppear {
inputKey = appState.apiKey
refreshAvailableBrowsers()
}
}
private func refreshAvailableBrowsers() {
let browsers = PreferredBrowser.availableBrowsers()
availableBrowsers = browsers
if !browsers.contains(where: { $0.rawValue == preferredBrowser }) {
preferredBrowser = PreferredBrowser.system.rawValue
}
}
@ -242,7 +292,7 @@ struct SettingsView: View {
private func openTornProfile() {
let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
if let url = URL(string: url) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct StatusView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -98,7 +99,7 @@ struct StatusView: View {
private var messagesBadge: some View {
Button {
if let url = URL(string: "https://www.torn.com/messages.php") {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
} label: {
HStack {
@ -109,7 +110,7 @@ struct StatusView: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
@ -150,7 +151,7 @@ struct StatusView: View {
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.1))
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
.transaction { $0.animation = nil }
}
@ -240,7 +241,7 @@ struct StatusView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
@ -270,7 +271,7 @@ struct CooldownItem: View {
var body: some View {
VStack(spacing: 2) {
Image(systemName: icon)
Text(label)
.font(.caption)
.foregroundColor(seconds > 0 ? .orange : .green)
@ -307,7 +308,7 @@ struct LiveCooldownItem: View {
let remaining = max(0, originalSeconds - elapsed)
VStack(spacing: 2) {
Image(systemName: icon)
Text(label)
.font(.caption)
.foregroundColor(remaining > 0 ? .orange : .green)

View file

@ -4,6 +4,7 @@ import AppKit
// MARK: - Flying Status View (separate for proper live updates)
struct FlyingStatusView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
let destination: String
let timestamp: Int
let departed: Int
@ -60,7 +61,7 @@ struct FlyingStatusView: View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.2))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
@ -72,7 +73,7 @@ struct FlyingStatusView: View {
}
}
.padding()
.background(Color.blue.opacity(0.1))
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
.transaction { $0.animation = nil }
}
@ -80,6 +81,7 @@ struct FlyingStatusView: View {
struct TravelView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -147,7 +149,7 @@ struct TravelView: View {
Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
} label: {
HStack {
@ -164,7 +166,7 @@ struct TravelView: View {
.buttonStyle(.plain)
}
.padding()
.background(Color.orange.opacity(0.1))
.background(Color.orange.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
}
@ -183,7 +185,7 @@ struct TravelView: View {
Spacer()
}
.padding()
.background(Color.green.opacity(0.1))
.background(Color.green.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
}
@ -202,7 +204,7 @@ struct TravelView: View {
// Show only return button when abroad
Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
} label: {
HStack {
@ -212,7 +214,7 @@ struct TravelView: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
@ -232,7 +234,7 @@ struct TravelView: View {
private func destinationButton(_ destination: TornDestination) -> some View {
Button {
NSWorkspace.shared.open(destination.travelAgencyURL)
BrowserManager.shared.open(destination.travelAgencyURL)
} label: {
VStack(spacing: 4) {
HStack(spacing: 4) {
@ -248,7 +250,7 @@ struct TravelView: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
@ -283,7 +285,7 @@ struct TravelView: View {
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
}
@ -302,7 +304,7 @@ struct TravelView: View {
HStack(spacing: 8) {
Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
} label: {
HStack {
@ -312,14 +314,14 @@ struct TravelView: View {
.font(.caption)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
Button {
if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
} label: {
HStack {
@ -329,7 +331,7 @@ struct TravelView: View {
.font(.caption)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)

View file

@ -2,6 +2,7 @@ import SwiftUI
struct WatchlistView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
@State private var showAddItem = false
var body: some View {
@ -56,7 +57,7 @@ struct WatchlistView: View {
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.green.opacity(0.1))
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
@ -64,7 +65,7 @@ struct WatchlistView: View {
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.background(Color.gray.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
@ -125,13 +126,14 @@ struct WatchlistView: View {
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
BrowserManager.shared.open(url)
}
}
}
// MARK: - Watchlist Price Row
struct WatchlistPriceRow: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let item: WatchlistItem
let onOpen: () -> Void
let onRemove: () -> Void
@ -200,7 +202,7 @@ struct WatchlistPriceRow: View {
.buttonStyle(.plain)
}
.padding(8)
.background(Color.gray.opacity(0.1))
.background(Color.gray.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}

View file

@ -132,4 +132,108 @@ final class TravelTests: XCTestCase {
let travel = try decode(Travel.self, from: json)
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

@ -0,0 +1,186 @@
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
extension XCUIElement {
/// Wait for element to exist with timeout
func waitForExistence(timeout: TimeInterval = 5) -> Bool {
/// Wait for element to appear within the given timeout
func waitForAppearance(timeout: TimeInterval = 5) -> Bool {
return self.waitForExistence(timeout: timeout)
}

View file

@ -57,13 +57,15 @@ build:
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO
# Build Release
# Build Release (Universal Binary for Intel + Apple Silicon)
release:
xcodebuild build \
-project MacTorn/MacTorn.xcodeproj \
-scheme MacTorn \
-configuration Release \
-destination 'platform=macOS' \
-destination 'generic/platform=macOS' \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO

View file

@ -4,12 +4,20 @@ A native macOS menu bar app for monitoring your **Torn** game status.
![macOS](https://img.shields.io/badge/macOS-13.0+-blue)
![Swift](https://img.shields.io/badge/Swift-5.0-orange)
![Universal](https://img.shields.io/badge/Universal-Intel%20%2B%20Apple%20Silicon-purple)
![License](https://img.shields.io/badge/License-MIT-green)
<p align="center">
<img src="app.png?v=1.2" alt="MacTorn Screenshot" width="600">
<img src="app_light_1.png" alt="MacTorn Light Mode" width="320">
&nbsp;&nbsp;
<img src="app_dark_1.png" alt="MacTorn Dark Mode" width="320">
</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
### 📊 Status Tab
@ -20,7 +28,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
- Hospital/Jail status badges
- Unread messages badge
- Events feed
- 8 customizable quick links
- 8 quick links
### ✈️ Travel Tab
- **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32)
@ -56,6 +64,14 @@ A native macOS menu bar app for monitoring your **Torn** game status.
- **🚀 Launch at Login**: Start seamlessly with macOS.
- **⚡️ Optimized Startup**: Non-blocking data fetching for instant UI responsiveness.
## Accessibility
MacTorn respects macOS accessibility settings:
- **Reduce Transparency**: When enabled in System Settings → Accessibility → Display, the app uses solid backgrounds instead of translucent materials for better readability
- **Light & Dark Mode**: Full support for both appearance modes with optimized contrast
- **Color-coded indicators**: Status bars and badges use distinct colors that work well in both modes
## Installation
1. Download the latest release from [Releases](https://github.com/pawelorzech/MacTorn/releases)
@ -68,6 +84,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
## Requirements
- macOS 13.0 (Ventura) or later
- **Universal Binary**: Supports both Intel (x86_64) and Apple Silicon (arm64) Macs
- Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages, market
## API Data Usage

BIN
app.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

BIN
app_dark_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
app_light_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB