mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
Clean up app
This commit is contained in:
parent
57ede80dad
commit
b4caf9dd8e
9 changed files with 8 additions and 604 deletions
|
|
@ -9,7 +9,6 @@ enum AppFeature: String, CaseIterable, Identifiable {
|
|||
case home
|
||||
case scripts
|
||||
case tools
|
||||
case news
|
||||
case console
|
||||
case deviceInfo = "deviceinfo"
|
||||
case profiles
|
||||
|
|
@ -29,8 +28,6 @@ enum AppFeature: String, CaseIterable, Identifiable {
|
|||
return "Scripts"
|
||||
case .tools:
|
||||
return "Tools"
|
||||
case .news:
|
||||
return "News"
|
||||
case .console:
|
||||
return "Console"
|
||||
case .deviceInfo:
|
||||
|
|
@ -54,8 +51,6 @@ enum AppFeature: String, CaseIterable, Identifiable {
|
|||
return "Manage and run JS scripts"
|
||||
case .tools:
|
||||
return "Access additional tools"
|
||||
case .news:
|
||||
return "Latest StikDebug updates"
|
||||
case .console:
|
||||
return "Live device logs"
|
||||
case .deviceInfo:
|
||||
|
|
@ -88,8 +83,6 @@ enum AppFeature: String, CaseIterable, Identifiable {
|
|||
return "scroll"
|
||||
case .tools:
|
||||
return "wrench.and.screwdriver"
|
||||
case .news:
|
||||
return "newspaper"
|
||||
case .console:
|
||||
return "terminal"
|
||||
case .deviceInfo:
|
||||
|
|
@ -114,8 +107,6 @@ enum AppFeature: String, CaseIterable, Identifiable {
|
|||
ScriptListView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .news:
|
||||
NewsView()
|
||||
case .console:
|
||||
ConsoleLogsView()
|
||||
case .deviceInfo:
|
||||
|
|
@ -133,6 +124,6 @@ enum AppFeature: String, CaseIterable, Identifiable {
|
|||
}
|
||||
|
||||
extension AppFeature {
|
||||
static let mainTabs: [AppFeature] = [.home, .tools, .news, .settings]
|
||||
static let mainTabs: [AppFeature] = [.home, .tools, .settings]
|
||||
static let toolList: [AppFeature] = [.scripts, .console, .deviceInfo, .profiles, .processes, .location]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
function attach() {
|
||||
log("Welcome to use StikDebug's scripting feature! Here you can use JavaScript to customize your debug experience!")
|
||||
log("This script is a demo script that attaches to the connected app and detaches from it.")
|
||||
|
||||
// get pid of the connected app
|
||||
let pid = get_pid();
|
||||
log(`pid = ${pid}`)
|
||||
|
||||
// attach
|
||||
let attachResponse = send_command(`vAttach;${pid.toString(16)}`)
|
||||
log(`attach_response = ${attachResponse}`)
|
||||
|
||||
// detach
|
||||
let detachResponse = send_command(`D`)
|
||||
log(`detachResponse = ${detachResponse}`)
|
||||
}
|
||||
|
||||
attach()
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
// manic.js - iOS 26 TXM JIT Support Script for Manic EMU Emulator
|
||||
// Optimized for Manic EMU using Geode.js's infinite loop mode
|
||||
// Features:
|
||||
// 1. Only handles brk #0x69 (JIT memory mapping requests)
|
||||
// 2. Keeps StikDebug connection alive indefinitely
|
||||
// 3. Supports JIT memory requests for multiple code blocks
|
||||
|
||||
function littleEndianHexStringToNumber(hexStr) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < hexStr.length; i += 2) {
|
||||
bytes.push(parseInt(hexStr.substr(i, 2), 16));
|
||||
}
|
||||
let num = 0n;
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
num = (num << 8n) | BigInt(bytes[i]);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function numberToLittleEndianHexString(num) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
bytes.push(Number(num & 0xFFn));
|
||||
num >>= 8n;
|
||||
}
|
||||
while (bytes.length < 8) {
|
||||
bytes.push(0);
|
||||
}
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function littleEndianHexToU32(hexStr) {
|
||||
return parseInt(hexStr.match(/../g).reverse().join(''), 16);
|
||||
}
|
||||
|
||||
function extractBrkImmediate(u32) {
|
||||
return (u32 >> 5) & 0xFFFF;
|
||||
}
|
||||
|
||||
// Check if the instruction is a BRK instruction
|
||||
// BRK instruction format: 0xD4200000 | (imm16 << 5)
|
||||
// The upper 16 bits should be 0xD420
|
||||
function isBrkInstruction(u32) {
|
||||
return (u32 >>> 16) === 0xD420;
|
||||
}
|
||||
|
||||
// Format size into a human-readable format
|
||||
function formatSize(size) {
|
||||
if (size >= 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
|
||||
} else if (size >= 1024) {
|
||||
return `${(size / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
return `${size} bytes`;
|
||||
}
|
||||
|
||||
log(`[Manic EMU] ========================================`);
|
||||
log(`[Manic EMU] Manic EMU iOS 26 TXM JIT Support Script`);
|
||||
log(`[Manic EMU] ========================================`);
|
||||
|
||||
let pid = get_pid();
|
||||
log(`[Manic EMU] pid = ${pid}`);
|
||||
let attachResponse = send_command(`vAttach;${pid.toString(16)}`);
|
||||
log(`[Manic EMU] attach_response = ${attachResponse}`);
|
||||
|
||||
let validBreakpoints = 0;
|
||||
let totalBreakpoints = 0;
|
||||
let totalMemoryMapped = 0n;
|
||||
|
||||
// Track processed memory areas for debugging.
|
||||
let mappedRegions = [];
|
||||
|
||||
// Infinite Loop - StikDebug Must Stay Connected in TXM Mode
|
||||
// The Manic EMU emulator dynamically creates multiple code blocks, each of which needs to be marked as executable by StikDebug.
|
||||
log(`[Manic EMU] Starting infinite loop - StikDebug will stay connected`);
|
||||
log(`[Manic EMU] Waiting for JIT memory requests (brk #0x69)...`);
|
||||
|
||||
while (true) {
|
||||
totalBreakpoints++;
|
||||
|
||||
let brkResponse = send_command(`c`);
|
||||
|
||||
// Check Exception Types (metype)
|
||||
// metype:6 = EXC_BREAKPOINT
|
||||
let metypeMatch = /metype:(\d+)/.exec(brkResponse);
|
||||
let metype = metypeMatch ? parseInt(metypeMatch[1]) : -1;
|
||||
|
||||
// If it's not a breakpoint exception, just continue (let the program handle it).
|
||||
if (metype !== 6 && metype !== -1) {
|
||||
log(`[Manic EMU] Non-breakpoint exception (metype=${metype}), continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let tidMatch = /T[0-9a-f]+thread:(?<tid>[0-9a-f]+);/.exec(brkResponse);
|
||||
let tid = tidMatch ? tidMatch.groups['tid'] : null;
|
||||
let pcMatch = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let pc = pcMatch ? pcMatch.groups['reg'] : null;
|
||||
let x0Match = /00:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let x0 = x0Match ? x0Match.groups['reg'] : null;
|
||||
let x1Match = /01:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let x1 = x1Match ? x1Match.groups['reg'] : null;
|
||||
|
||||
if (!tid || !pc || !x0) {
|
||||
log(`[Manic EMU] Failed to extract registers, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pcNum = littleEndianHexStringToNumber(pc);
|
||||
const x0Num = littleEndianHexStringToNumber(x0);
|
||||
const x1Num = x1 ? littleEndianHexStringToNumber(x1) : 0n;
|
||||
|
||||
let instructionResponse = send_command(`m${pcNum.toString(16)},4`);
|
||||
let instrU32 = littleEndianHexToU32(instructionResponse);
|
||||
|
||||
// Check if it's a BRK instruction.
|
||||
if (!isBrkInstruction(instrU32)) {
|
||||
// Not a BRK instruction, skip it.
|
||||
log(`[Manic EMU] Not a BRK instruction at PC=0x${pcNum.toString(16)}, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let brkImmediate = extractBrkImmediate(instrU32);
|
||||
|
||||
// Only handle brk #0x69 (JIT memory mapping request).
|
||||
if (brkImmediate === 0x69) {
|
||||
validBreakpoints++;
|
||||
|
||||
let jitPageAddress = x0Num;
|
||||
// If x1 is 0, use the default size of 64KB (0x10000).
|
||||
// Manic EMU's CodeBlock typically requests large memory chunks (tens of MB for CPU JIT)
|
||||
// but also asks for smaller ones (like 4KB/16KB for SpinLock and Shader JIT).
|
||||
let size = x1Num > 0n ? x1Num : 0x10000n;
|
||||
|
||||
log(`[Manic EMU] ----------------------------------------`);
|
||||
log(`[Manic EMU] JIT Request #${validBreakpoints}`);
|
||||
log(`[Manic EMU] Address: 0x${jitPageAddress.toString(16)}`);
|
||||
log(`[Manic EMU] Size: 0x${size.toString(16)} (${formatSize(Number(size))})`);
|
||||
|
||||
// Call prepare_memory_region to mark the memory as executable.
|
||||
let prepareJITPageResponse = prepare_memory_region(Number(jitPageAddress), Number(size));
|
||||
log(`[Manic EMU] prepare_memory_region result: ${prepareJITPageResponse}`);
|
||||
|
||||
// Log statistics
|
||||
totalMemoryMapped += size;
|
||||
mappedRegions.push({
|
||||
address: `0x${jitPageAddress.toString(16)}`,
|
||||
size: Number(size),
|
||||
index: validBreakpoints
|
||||
});
|
||||
|
||||
log(`[Manic EMU] Total JIT memory mapped: ${formatSize(Number(totalMemoryMapped))}`);
|
||||
|
||||
// Set PC+4 to continue program execution
|
||||
let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
|
||||
send_command(`P20=${pcPlus4};thread:${tid};`);
|
||||
|
||||
log(`[Manic EMU] Resumed execution at PC=0x${(pcNum + 4n).toString(16)}`);
|
||||
log(`[Manic EMU] ----------------------------------------`);
|
||||
|
||||
} else if (brkImmediate === 0x70 || brkImmediate === 0x71) {
|
||||
// brk #0x70 and brk #0x71 are breakpoints used by some debuggers.
|
||||
// Skip directly to PC+4 and continue execution.
|
||||
log(`[Manic EMU] Debug breakpoint brk #0x${brkImmediate.toString(16)} at PC=0x${pcNum.toString(16)}, skipping...`);
|
||||
let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
|
||||
send_command(`P20=${pcPlus4};thread:${tid};`);
|
||||
|
||||
} else {
|
||||
// Other BRK instructions, skip PC+4 and continue execution.
|
||||
log(`[Manic EMU] Unknown brk #0x${brkImmediate.toString(16)} at PC=0x${pcNum.toString(16)}, skipping...`);
|
||||
let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
|
||||
send_command(`P20=${pcPlus4};thread:${tid};`);
|
||||
}
|
||||
}
|
||||
|
||||
// This line of code will never run because it's an infinite loop
|
||||
// log(`[Manic EMU] Script ended`);
|
||||
|
|
@ -19,11 +19,16 @@ extension ScriptStore {
|
|||
"MeloCafe",
|
||||
"Manic EMU",
|
||||
"Manic",
|
||||
"Geode",
|
||||
"DukeX",
|
||||
],
|
||||
resource: ScriptResource(resourceName: "universal", fileName: "universal.js")
|
||||
),
|
||||
AutoScriptAssignment(
|
||||
appNames: [
|
||||
"Geode"
|
||||
],
|
||||
resource: ScriptResource(resourceName: "Geode", fileName: "Geode.js")
|
||||
),
|
||||
AutoScriptAssignment(
|
||||
appNames: [
|
||||
"UTM",
|
||||
|
|
|
|||
|
|
@ -17,11 +17,9 @@ enum ScriptStore {
|
|||
static let favoriteAppNamesKey = "favoriteAppNames"
|
||||
static let defaultScriptName = UserDefaults.Keys.defaultScriptNameValue
|
||||
static let bundledResources: [ScriptResource] = [
|
||||
ScriptResource(resourceName: "attachDetach", fileName: "attachDetach.js"),
|
||||
ScriptResource(resourceName: "maciOS", fileName: "maciOS.js"),
|
||||
ScriptResource(resourceName: "universal", fileName: "universal.js"),
|
||||
ScriptResource(resourceName: "Geode", fileName: "Geode.js"),
|
||||
ScriptResource(resourceName: "manic", fileName: "manic.js"),
|
||||
ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js")
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ extension UserDefaults {
|
|||
static let txmOverride = "overrideTXMForScripts"
|
||||
static let bundleScriptMap = "BundleScriptMap"
|
||||
static let defaultScriptName = "DefaultScriptName"
|
||||
static let defaultScriptNameValue = "attachDetach.js"
|
||||
static let defaultScriptNameValue = ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import SwiftUI
|
|||
struct HomeView: View {
|
||||
@AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false
|
||||
@AppStorage("bundleID") private var bundleID: String = ""
|
||||
@AppStorage(UserDefaults.Keys.defaultScriptName) private var selectedScript = UserDefaults.Keys.defaultScriptNameValue
|
||||
|
||||
@ObservedObject private var mounting = MountingProgress.shared
|
||||
|
||||
|
|
@ -114,9 +113,6 @@ struct HomeView: View {
|
|||
return
|
||||
}
|
||||
|
||||
if let name = notification.userInfo?["scriptName"] as? String {
|
||||
selectedScript = name
|
||||
}
|
||||
scriptRunModel = model
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,379 +0,0 @@
|
|||
//
|
||||
// NewsView.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NewsView: View {
|
||||
@State private var items: [NewsItem] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private let feedURL = NewsFeedEndpoint.url
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if items.isEmpty {
|
||||
statusContent
|
||||
} else {
|
||||
if let errorMessage {
|
||||
NewsBanner(message: errorMessage)
|
||||
}
|
||||
|
||||
ForEach(items) { item in
|
||||
NewsCard(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.navigationTitle("News")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
Task { await loadNews() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadNews()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard items.isEmpty, errorMessage == nil else { return }
|
||||
await loadNews()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusContent: some View {
|
||||
if isLoading {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Loading News")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Could Not Load News", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Try Again") {
|
||||
Task { await loadNews() }
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 280)
|
||||
} else {
|
||||
ContentUnavailableView("No News", systemImage: "newspaper")
|
||||
.frame(minHeight: 280)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadNews() async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let feed = try await NewsFeedClient.fetch(from: feedURL)
|
||||
items = feed.items
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewsFeedEndpoint {
|
||||
static let url = URL(string: "https://raw.githubusercontent.com/StephenDev0/StikDebug/main/news.json")!
|
||||
}
|
||||
|
||||
private enum NewsFeedClient {
|
||||
static func fetch(from url: URL) async throws -> NewsFeed {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 15
|
||||
request.cachePolicy = .reloadRevalidatingCacheData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NewsFeedError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw NewsFeedError.badStatus(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(NewsFeed.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewsFeedError: LocalizedError {
|
||||
case invalidResponse
|
||||
case badStatus(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "GitHub returned an invalid news response."
|
||||
case .badStatus(let statusCode):
|
||||
return "GitHub returned status \(statusCode)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsFeed: Decodable {
|
||||
let items: [NewsItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
if let container = try? decoder.singleValueContainer(),
|
||||
let items = try? container.decode([NewsItem].self) {
|
||||
self.items = items
|
||||
return
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = try container.decode([NewsItem].self, forKey: .items)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsItem: Decodable, Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let body: String
|
||||
let date: String
|
||||
let category: String
|
||||
let tint: String
|
||||
let symbol: String
|
||||
let imageURL: URL?
|
||||
let url: URL?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case body
|
||||
case summary
|
||||
case description
|
||||
case date
|
||||
case publishedAt
|
||||
case category
|
||||
case tint
|
||||
case color
|
||||
case symbol
|
||||
case image
|
||||
case imageURL
|
||||
case imageUrl
|
||||
case image_url
|
||||
case link
|
||||
case url
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
body = try container.decodeFirstString(for: [.body, .summary, .description])
|
||||
date = (try? container.decodeFirstString(for: [.date, .publishedAt])) ?? ""
|
||||
category = (try? container.decode(String.self, forKey: .category)) ?? "Update"
|
||||
tint = (try? container.decodeFirstString(for: [.tint, .color])) ?? "blue"
|
||||
symbol = (try? container.decode(String.self, forKey: .symbol)) ?? "sparkles"
|
||||
imageURL = container.decodeFirstURL(for: [.imageURL, .imageUrl, .image_url, .image])
|
||||
url = container.decodeFirstURL(for: [.url, .link])
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? Self.generatedID(title: title, date: date)
|
||||
}
|
||||
|
||||
private static func generatedID(title: String, date: String) -> String {
|
||||
"\(date)-\(title)"
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer where Key == NewsItem.CodingKeys {
|
||||
func decodeFirstString(for keys: [Key]) throws -> String {
|
||||
for key in keys {
|
||||
if let value = try decodeIfPresent(String.self, forKey: key), !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
throw DecodingError.keyNotFound(
|
||||
keys.first ?? NewsItem.CodingKeys.body,
|
||||
DecodingError.Context(codingPath: codingPath, debugDescription: "Expected one of \(keys.map { $0.stringValue }.joined(separator: ", ")).")
|
||||
)
|
||||
}
|
||||
|
||||
func decodeFirstURL(for keys: [Key]) -> URL? {
|
||||
for key in keys {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key),
|
||||
!value.isEmpty,
|
||||
let url = URL(string: value) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsCard: View {
|
||||
let item: NewsItem
|
||||
|
||||
private var accentColor: Color {
|
||||
NewsTint.color(for: item.tint)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
NewsMedia(item: item, accentColor: accentColor)
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(item.category.uppercased())
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(accentColor)
|
||||
Spacer(minLength: 12)
|
||||
if !item.date.isEmpty {
|
||||
Text(item.date)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.title)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(item.body)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let url = item.url {
|
||||
Link(destination: url) {
|
||||
Label("Read More", systemImage: "arrow.up.right")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color(uiColor: .separator).opacity(0.18), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsMedia: View {
|
||||
let item: NewsItem
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncImage(url: imageURL, transaction: Transaction(animation: .easeInOut)) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ZStack {
|
||||
mediaBackground
|
||||
ProgressView()
|
||||
}
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
case .failure:
|
||||
symbolHeader
|
||||
@unknown default:
|
||||
symbolHeader
|
||||
}
|
||||
}
|
||||
} else {
|
||||
symbolHeader
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
|
||||
private var symbolHeader: some View {
|
||||
ZStack {
|
||||
mediaBackground
|
||||
Image(systemName: item.symbol)
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaBackground: some View {
|
||||
Rectangle()
|
||||
.fill(accentColor.opacity(0.14))
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(accentColor)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsBanner: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewsTint {
|
||||
static func color(for value: String) -> Color {
|
||||
switch value.lowercased() {
|
||||
case "green":
|
||||
return .green
|
||||
case "mint":
|
||||
return .mint
|
||||
case "orange":
|
||||
return .orange
|
||||
case "pink":
|
||||
return .pink
|
||||
case "purple":
|
||||
return .purple
|
||||
case "red":
|
||||
return .red
|
||||
case "teal":
|
||||
return .teal
|
||||
case "yellow":
|
||||
return .yellow
|
||||
default:
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
13
news.json
13
news.json
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "stikdebug-3-1-3",
|
||||
"title": "StikDebug 3.1.3 is out",
|
||||
"body": "Includes performance improvements, bug fixes, quality of life improvements, a new news tab, and more.",
|
||||
"date": "2026-05-18",
|
||||
"category": "Update",
|
||||
"symbol": "newspaper.fill",
|
||||
"tint": "blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue