mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-30 04:04:27 +00:00
Make Torn models more robust to missing API data
Updated TornModels.swift to make many properties optional, improving resilience to missing or incomplete API responses. Adjusted ChainView and StatusView to handle optional values safely. Refactored SettingsView for a cleaner layout and removed notification rules UI. Updated README to reflect notification changes and clarify features.
This commit is contained in:
parent
53a234afcd
commit
4813da8c7f
6 changed files with 138 additions and 229 deletions
BIN
MacTorn-v1.0.zip
BIN
MacTorn-v1.0.zip
Binary file not shown.
|
|
@ -85,10 +85,10 @@ struct Cooldowns: Codable, Equatable {
|
||||||
|
|
||||||
// MARK: - Travel
|
// MARK: - Travel
|
||||||
struct Travel: Codable, Equatable {
|
struct Travel: Codable, Equatable {
|
||||||
let destination: String
|
let destination: String?
|
||||||
let timestamp: Int
|
let timestamp: Int?
|
||||||
let departed: Int
|
let departed: Int?
|
||||||
let timeLeft: Int
|
let timeLeft: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case destination
|
case destination
|
||||||
|
|
@ -98,25 +98,27 @@ struct Travel: Codable, Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAbroad: Bool {
|
var isAbroad: Bool {
|
||||||
destination != "Torn" && timeLeft == 0
|
guard let dest = destination, let time = timeLeft else { return false }
|
||||||
|
return dest != "Torn" && time == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var isTraveling: Bool {
|
var isTraveling: Bool {
|
||||||
timeLeft > 0
|
guard let time = timeLeft else { return false }
|
||||||
|
return time > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var arrivalDate: Date? {
|
var arrivalDate: Date? {
|
||||||
guard isTraveling else { return nil }
|
guard isTraveling, let ts = timestamp else { return nil }
|
||||||
return Date(timeIntervalSince1970: TimeInterval(timestamp))
|
return Date(timeIntervalSince1970: TimeInterval(ts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status (Hospital/Jail)
|
// MARK: - Status (Hospital/Jail)
|
||||||
struct Status: Codable, Equatable {
|
struct Status: Codable, Equatable {
|
||||||
let description: String
|
let description: String?
|
||||||
let details: String?
|
let details: String?
|
||||||
let state: String
|
let state: String?
|
||||||
let until: Int
|
let until: Int?
|
||||||
|
|
||||||
var isInHospital: Bool {
|
var isInHospital: Bool {
|
||||||
state == "Hospital"
|
state == "Hospital"
|
||||||
|
|
@ -127,31 +129,35 @@ struct Status: Codable, Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
var isOkay: Bool {
|
var isOkay: Bool {
|
||||||
state == "Okay"
|
state == "Okay" || state == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeRemaining: Int {
|
var timeRemaining: Int {
|
||||||
max(0, until - Int(Date().timeIntervalSince1970))
|
guard let until = until else { return 0 }
|
||||||
|
return max(0, until - Int(Date().timeIntervalSince1970))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chain
|
// MARK: - Chain
|
||||||
struct Chain: Codable, Equatable {
|
struct Chain: Codable, Equatable {
|
||||||
let current: Int
|
let current: Int?
|
||||||
let maximum: Int
|
let maximum: Int?
|
||||||
let timeout: Int
|
let timeout: Int?
|
||||||
let cooldown: Int
|
let cooldown: Int?
|
||||||
|
|
||||||
var isActive: Bool {
|
var isActive: Bool {
|
||||||
current > 0 && timeout > 0
|
guard let current = current, let timeout = timeout else { return false }
|
||||||
|
return current > 0 && timeout > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var isOnCooldown: Bool {
|
var isOnCooldown: Bool {
|
||||||
cooldown > 0
|
guard let cooldown = cooldown else { return false }
|
||||||
|
return cooldown > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeoutRemaining: Int {
|
var timeoutRemaining: Int {
|
||||||
max(0, timeout - Int(Date().timeIntervalSince1970))
|
guard let timeout = timeout else { return 0 }
|
||||||
|
return max(0, timeout - Int(Date().timeIntervalSince1970))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,7 +165,7 @@ struct Chain: Codable, Equatable {
|
||||||
struct TornEvent: Codable, Identifiable {
|
struct TornEvent: Codable, Identifiable {
|
||||||
let timestamp: Int
|
let timestamp: Int
|
||||||
let event: String
|
let event: String
|
||||||
let seen: Int
|
let seen: Int?
|
||||||
|
|
||||||
var id: Int { timestamp }
|
var id: Int { timestamp }
|
||||||
|
|
||||||
|
|
@ -175,11 +181,11 @@ struct TornEvent: Codable, Identifiable {
|
||||||
|
|
||||||
// MARK: - Messages
|
// MARK: - Messages
|
||||||
struct TornMessage: Codable {
|
struct TornMessage: Codable {
|
||||||
let name: String
|
let name: String?
|
||||||
let type: String
|
let type: String?
|
||||||
let title: String
|
let title: String?
|
||||||
let timestamp: Int
|
let timestamp: Int?
|
||||||
let read: Int
|
let read: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error
|
// MARK: - Error
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ struct ChainView: View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "link")
|
Image(systemName: "link")
|
||||||
.foregroundColor(timeoutColor)
|
.foregroundColor(timeoutColor)
|
||||||
Text("Chain: \(chain.current)/\(chain.maximum)")
|
Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,12 @@ import SwiftUI
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@State private var inputKey: String = ""
|
@State private var inputKey: String = ""
|
||||||
@State private var showNotificationSettings = false
|
|
||||||
|
|
||||||
// Developer ID for tip feature (bombel)
|
// Developer ID for tip feature (bombel)
|
||||||
private let developerID = 2362436
|
private let developerID = 2362436
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
VStack(spacing: 20) {
|
||||||
VStack(spacing: 16) {
|
|
||||||
// Header
|
// Header
|
||||||
Image(systemName: "bolt.circle.fill")
|
Image(systemName: "bolt.circle.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
|
|
@ -25,14 +23,10 @@ struct SettingsView: View {
|
||||||
Text("MacTorn")
|
Text("MacTorn")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
|
|
||||||
Text("Enter your Torn API Key")
|
// API Key section
|
||||||
.font(.caption)
|
VStack(spacing: 8) {
|
||||||
.foregroundColor(.secondary)
|
SecureField("Torn API Key", text: $inputKey)
|
||||||
|
|
||||||
// API Key input
|
|
||||||
SecureField("API Key", text: $inputKey)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
Button("Save & Connect") {
|
Button("Save & Connect") {
|
||||||
appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
@ -44,68 +38,20 @@ struct SettingsView: View {
|
||||||
Link("Get API Key from Torn",
|
Link("Get API Key from Torn",
|
||||||
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
|
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
VStack(spacing: 12) {
|
||||||
// Refresh Interval
|
// Refresh Interval
|
||||||
refreshIntervalSection
|
|
||||||
|
|
||||||
// Launch at Login
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { appState.launchAtLogin.isEnabled },
|
|
||||||
set: { _ in appState.launchAtLogin.toggle() }
|
|
||||||
)) {
|
|
||||||
Label("Launch at Login", systemImage: "power")
|
|
||||||
}
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// Notification Settings
|
|
||||||
Button {
|
|
||||||
showNotificationSettings.toggle()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "bell.badge")
|
Image(systemName: "clock")
|
||||||
Text("Notification Rules")
|
.foregroundColor(.secondary)
|
||||||
Spacer()
|
.frame(width: 20)
|
||||||
Image(systemName: showNotificationSettings ? "chevron.up" : "chevron.down")
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
if showNotificationSettings {
|
Picker("Refresh", selection: Binding(
|
||||||
notificationRulesSection
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
|
|
||||||
// Tip Me section
|
|
||||||
tipMeSection
|
|
||||||
|
|
||||||
// GitHub link
|
|
||||||
githubSection
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.onAppear {
|
|
||||||
inputKey = appState.apiKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Refresh Interval
|
|
||||||
private var refreshIntervalSection: some View {
|
|
||||||
HStack {
|
|
||||||
Label("Refresh Interval", systemImage: "clock")
|
|
||||||
.font(.caption)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { appState.refreshInterval },
|
get: { appState.refreshInterval },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
appState.refreshInterval = newValue
|
appState.refreshInterval = newValue
|
||||||
|
|
@ -115,33 +61,30 @@ struct SettingsView: View {
|
||||||
Text("15s").tag(15)
|
Text("15s").tag(15)
|
||||||
Text("30s").tag(30)
|
Text("30s").tag(30)
|
||||||
Text("60s").tag(60)
|
Text("60s").tag(60)
|
||||||
Text("120s").tag(120)
|
Text("2m").tag(120)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 180)
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notification Rules
|
// Launch at Login
|
||||||
private var notificationRulesSection: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
ForEach(appState.notificationRules) { rule in
|
|
||||||
NotificationRuleRow(rule: rule) { updatedRule in
|
|
||||||
appState.updateRule(updatedRule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tip Me Section
|
|
||||||
private var tipMeSection: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
HStack {
|
HStack {
|
||||||
|
Image(systemName: "power")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 20)
|
||||||
|
Toggle("Launch at Login", isOn: Binding(
|
||||||
|
get: { appState.launchAtLogin.isEnabled },
|
||||||
|
set: { _ in appState.launchAtLogin.toggle() }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Support section
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "gift.fill")
|
Image(systemName: "gift.fill")
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
Text("Support the Developer")
|
Text("Support the Developer")
|
||||||
|
|
@ -151,40 +94,44 @@ struct SettingsView: View {
|
||||||
Text("Send me some Xanax or cash :)")
|
Text("Send me some Xanax or cash :)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
openTornProfile()
|
openTornProfile()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "paperplane.fill")
|
Image(systemName: "paperplane.fill")
|
||||||
Text("Send Xanax to bombel")
|
Text("Send to bombel")
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 12)
|
||||||
.background(Color.purple.opacity(0.15))
|
.background(Color.purple.opacity(0.15))
|
||||||
.cornerRadius(8)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal)
|
||||||
.background(Color.purple.opacity(0.05))
|
.background(Color.purple.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - GitHub Section
|
// GitHub
|
||||||
private var githubSection: some View {
|
HStack(spacing: 4) {
|
||||||
HStack {
|
|
||||||
Image(systemName: "chevron.left.forwardslash.chevron.right")
|
Image(systemName: "chevron.left.forwardslash.chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
Link("View Source on GitHub",
|
Link("View on GitHub",
|
||||||
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
|
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(width: 320)
|
||||||
|
.onAppear {
|
||||||
|
inputKey = appState.apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
private func openTornProfile() {
|
private func openTornProfile() {
|
||||||
let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
|
let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
|
||||||
if let url = URL(string: url) {
|
if let url = URL(string: url) {
|
||||||
|
|
@ -192,46 +139,3 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notification Rule Row
|
|
||||||
struct NotificationRuleRow: View {
|
|
||||||
let rule: NotificationRule
|
|
||||||
let onUpdate: (NotificationRule) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { rule.enabled },
|
|
||||||
set: { newValue in
|
|
||||||
var updated = rule
|
|
||||||
updated.enabled = newValue
|
|
||||||
onUpdate(updated)
|
|
||||||
}
|
|
||||||
)) {
|
|
||||||
HStack {
|
|
||||||
Text(rule.barType.rawValue)
|
|
||||||
.font(.caption)
|
|
||||||
Text("@ \(rule.threshold)%")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { rule.soundName },
|
|
||||||
set: { newValue in
|
|
||||||
var updated = rule
|
|
||||||
updated.soundName = newValue
|
|
||||||
onUpdate(updated)
|
|
||||||
}
|
|
||||||
)) {
|
|
||||||
ForEach(NotificationSound.allCases, id: \.rawValue) { sound in
|
|
||||||
Text(sound.displayName).tag(sound.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 80)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ struct StatusView: View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "airplane")
|
Image(systemName: "airplane")
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
Text(travel.isTraveling ? "Traveling to \(travel.destination)" : "In \(travel.destination)")
|
Text(travel.isTraveling ? "Traveling to \(travel.destination ?? "Unknown")" : "In \(travel.destination ?? "Unknown")")
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ struct StatusView: View {
|
||||||
Text("Arriving in:")
|
Text("Arriving in:")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text(formatTime(travel.timeLeft))
|
Text(formatTime(travel.timeLeft ?? 0))
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -9,14 +9,15 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
|
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
|
||||||
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns
|
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns with ready state
|
||||||
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown
|
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown and abroad state
|
||||||
- 🔗 **Chain Timer** - Active chain counter with timeout warning
|
- 🔗 **Chain Timer** - Active chain counter with timeout warning + cooldown state
|
||||||
- 🏥 **Hospital/Jail Status** - Countdown to release
|
- 🏥 **Hospital/Jail Status** - Countdown to release
|
||||||
- 📨 **Unread Messages** - Quick access to inbox
|
- 📨 **Unread Messages** - Inbox badge with one-click open
|
||||||
- 🔔 **Events Feed** - Recent activity at a glance
|
- 🔔 **Events Feed** - Recent activity at a glance
|
||||||
- 🔔 **Smart Notifications** - Custom threshold alerts with sound options
|
- 🔔 **Notifications** - Bars thresholds, cooldown ready, landing, chain expiring, and release
|
||||||
- ⚡ **Quick Links** - 8 configurable shortcuts to Torn pages
|
- ⚡ **Quick Links** - Grid of customizable Torn shortcuts (8 defaults)
|
||||||
|
- 🕒 **Refresh Control** - 15s/30s/60s/2m polling + manual refresh + last updated
|
||||||
- 🚀 **Launch at Login** - Start automatically with macOS
|
- 🚀 **Launch at Login** - Start automatically with macOS
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -26,7 +27,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
||||||
3. Open MacTorn from Applications
|
3. Open MacTorn from Applications
|
||||||
4. Enter your [Torn API Key](https://www.torn.com/preferences.php#tab=api)
|
4. Enter your [Torn API Key](https://www.torn.com/preferences.php#tab=api)
|
||||||
|
|
||||||
> **Note**: On first launch, macOS may show a security warning. Right-click the app and select "Open" to bypass.
|
> **Note**: If you download an unsigned build, macOS Gatekeeper will block it. Right-click the app and select "Open", or go to System Settings → Privacy & Security → Open Anyway.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -38,10 +39,8 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
||||||
### Refresh Interval
|
### Refresh Interval
|
||||||
Choose polling frequency: 15s, 30s, 60s, or 120s
|
Choose polling frequency: 15s, 30s, 60s, or 120s
|
||||||
|
|
||||||
### Notification Rules
|
### Notifications
|
||||||
Customize when to receive alerts:
|
MacTorn sends notifications for bar thresholds, cooldown ready, landing, chain expiring, and release. Notification defaults are stored locally.
|
||||||
- Energy/Nerve/Happy/Life at specific thresholds
|
|
||||||
- Sound selection per notification type
|
|
||||||
|
|
||||||
### Quick Links
|
### Quick Links
|
||||||
8 preset shortcuts to common Torn pages (fully editable)
|
8 preset shortcuts to common Torn pages (fully editable)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue