Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56037885d0 | ||
|
|
22f6e5d8e4 | ||
|
|
464bfea0a4 | ||
|
|
032ff5887c | ||
|
|
0ea44f891a | ||
|
|
c46da1e13f | ||
|
|
7b7fe98666 | ||
|
|
bbeb89b9ba | ||
|
|
bbf977c6c0 | ||
|
|
4391a8b6b4 | ||
|
|
d1166d3218 | ||
|
|
9b8eaed844 | ||
|
|
bdaee4dcf1 | ||
|
|
36d2214c60 | ||
|
|
4b339d86eb | ||
|
|
cd2f6ce653 | ||
|
|
0b5b156182 | ||
|
|
3e214a0b19 |
28 changed files with 698 additions and 49 deletions
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal 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
50
.github/workflows/claude.yml
vendored
Normal 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:*)'
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -5,6 +5,30 @@ 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
|
||||
|
|
|
|||
BIN
MacTorn-1.5.1.zip
Normal file
BIN
MacTorn-1.5.1.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -31,6 +31,8 @@
|
|||
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 */; };
|
||||
|
|
@ -44,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 */
|
||||
|
|
@ -91,6 +94,8 @@
|
|||
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>"; };
|
||||
|
|
@ -105,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>"; };
|
||||
|
|
@ -213,6 +219,7 @@
|
|||
AAA10013 /* ChainView.swift */,
|
||||
AAA10014 /* StatusBadgesView.swift */,
|
||||
AAA10015 /* EventsView.swift */,
|
||||
AAA10026 /* FeedbackPromptView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -221,6 +228,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
AAA10009 /* NotificationManager.swift */,
|
||||
AAA10027 /* BrowserManager.swift */,
|
||||
AAA10011 /* LaunchAtLoginManager.swift */,
|
||||
AAA10012 /* ShortcutsManager.swift */,
|
||||
AAA10016 /* SoundManager.swift */,
|
||||
|
|
@ -292,6 +300,7 @@
|
|||
children = (
|
||||
BBB10011 /* AppStateTests.swift */,
|
||||
BBB10012 /* AppStateWatchlistTests.swift */,
|
||||
BBB10013 /* AppStateFeedbackTests.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -457,6 +466,8 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -476,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;
|
||||
};
|
||||
|
|
@ -639,7 +651,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.6;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -666,7 +678,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.6;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -684,7 +696,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.6;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
@ -702,7 +714,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.6;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
@ -720,7 +732,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.6;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
@ -737,7 +749,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.6;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct MacTornApp: App {
|
|||
.onAppear {
|
||||
updateAppearance()
|
||||
}
|
||||
.onChange(of: appearanceModeRaw) { _ in
|
||||
.onChange(of: appearanceModeRaw) {
|
||||
updateAppearance()
|
||||
}
|
||||
} label: {
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -373,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
|
||||
|
|
@ -386,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 {
|
||||
|
|
@ -734,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
|
||||
|
|
|
|||
112
MacTorn/MacTorn/Utilities/BrowserManager.swift
Normal file
112
MacTorn/MacTorn/Utilities/BrowserManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,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()
|
||||
|
||||
|
|
@ -76,6 +81,7 @@ class AppState: ObservableObject {
|
|||
loadNotificationRules()
|
||||
loadTravelNotificationSettings()
|
||||
loadWatchlist()
|
||||
loadFeedbackState()
|
||||
// Polling and permissions moved to onAppear in UI
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +312,9 @@ class AppState: ObservableObject {
|
|||
} 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)")
|
||||
|
|
@ -572,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")")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -704,7 +714,7 @@ class AppState: ObservableObject {
|
|||
// MARK: - Updates
|
||||
func checkForAppUpdates() {
|
||||
guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
|
||||
|
||||
|
||||
Task {
|
||||
if let release = await updateManager.checkForUpdates(currentVersion: currentVersion) {
|
||||
await MainActor.run {
|
||||
|
|
@ -713,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
|
||||
|
|
|
|||
|
|
@ -58,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) {
|
||||
|
|
@ -128,7 +128,7 @@ struct AttacksView: View {
|
|||
|
||||
private func openURL(_ urlString: String) {
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
BrowserManager.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
MacTorn/MacTorn/Views/Components/FeedbackPromptView.swift
Normal file
74
MacTorn/MacTorn/Views/Components/FeedbackPromptView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ struct ContentView: View {
|
|||
if appState.isLoading && appState.lastUpdated == nil {
|
||||
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.4))
|
||||
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
|
||||
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
|
|
@ -67,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 {
|
||||
|
|
@ -82,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)
|
||||
}
|
||||
|
|
@ -168,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
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ struct CreditsView: View {
|
|||
@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] = [
|
||||
|
|
@ -92,7 +92,9 @@ struct CreditsView: View {
|
|||
}
|
||||
|
||||
Button {
|
||||
openTornProfile(developer.tornID!)
|
||||
if let tornID = developer.tornID {
|
||||
openTornProfile(tornID)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(developer.name)
|
||||
|
|
@ -238,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ struct FactionView: View {
|
|||
|
||||
private func openURL(_ urlString: String) {
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
BrowserManager.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ struct MoneyView: View {
|
|||
|
||||
private func openURL(_ urlString: String) {
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
BrowserManager.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ struct PropertiesView: View {
|
|||
|
||||
private func openURL(_ urlString: String) {
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
BrowserManager.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ 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 {
|
||||
|
|
@ -72,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()
|
||||
}
|
||||
|
|
@ -106,6 +107,20 @@ struct SettingsView: View {
|
|||
.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")
|
||||
|
|
@ -164,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)
|
||||
|
|
@ -215,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,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 {
|
||||
|
|
|
|||
|
|
@ -149,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 {
|
||||
|
|
@ -204,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 {
|
||||
|
|
@ -234,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) {
|
||||
|
|
@ -304,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 {
|
||||
|
|
@ -321,7 +321,7 @@ struct TravelView: View {
|
|||
|
||||
Button {
|
||||
if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") {
|
||||
NSWorkspace.shared.open(url)
|
||||
BrowserManager.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ struct WatchlistView: View {
|
|||
|
||||
private func openURL(_ urlString: String) {
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
BrowserManager.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
186
MacTorn/MacTornTests/ViewModels/AppStateFeedbackTests.swift
Normal file
186
MacTorn/MacTornTests/ViewModels/AppStateFeedbackTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
|||
## 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue