Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ node_modules/

# internal process docs (not published)
docs/superpowers/
.superpowers/
12 changes: 11 additions & 1 deletion ios/happwn/Store/Settings.swift
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import Foundation
import Combine

/// User-editable request identity, persisted in UserDefaults.
/// User-editable request identity and appearance, persisted in UserDefaults.
final class Settings: ObservableObject {
@Published var userAgent: String {
didSet { defaults.set(userAgent, forKey: Keys.userAgent) }
}
@Published var hwid: String {
didSet { defaults.set(hwid, forKey: Keys.hwid) }
}
@Published var accent: AppAccent {
didSet { defaults.set(accent.rawValue, forKey: Keys.accent) }
}
@Published var appearance: AppAppearance {
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
}

private let defaults: UserDefaults

private enum Keys {
static let userAgent = "happwn.userAgent"
static let hwid = "happwn.hwid"
static let accent = "happwn.accent"
static let appearance = "happwn.appearance"
}

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.userAgent = defaults.string(forKey: Keys.userAgent) ?? "Happ/1.0"
self.hwid = defaults.string(forKey: Keys.hwid) ?? ""
self.accent = AppAccent(rawValue: defaults.string(forKey: Keys.accent) ?? "") ?? .indigo
self.appearance = AppAppearance(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system
}
}
92 changes: 50 additions & 42 deletions ios/happwn/UI/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,66 +11,74 @@ struct AboutView: View {

var body: some View {
ScrollView {
VStack(spacing: 16) {
Image("AppLogo")
.resizable()
.scaledToFit()
.frame(width: 112, height: 112)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
.padding(.top, 24)
VStack(spacing: 18) {
header

VStack(spacing: 4) {
Text("happwn").font(.largeTitle.bold())
Text(version).font(.subheadline).foregroundColor(.secondary)
Text("Happ subscription config extractor")
.font(.callout).foregroundColor(.secondary)
.multilineTextAlignment(.center)
}

VStack(alignment: .leading, spacing: 12) {
section(
"What it does",
"Paste a happ:// link — happwn decrypts it, follows the embedded "
+ "subscription URL with your User-Agent and X-HWID, and extracts every "
+ "config (vless, vmess, trojan, ss, …) for copy and export."
VStack(alignment: .leading, spacing: 14) {
infoCard(
"Что делает",
"Вставь happ://-ссылку — happwn расшифрует её, перейдёт по встроенному "
+ "URL подписки с твоими User-Agent и X-HWID и достанет каждый конфиг "
+ "(vless, vmess, trojan, ss …) для копирования и экспорта."
)
section(
"Schemes",
"crypt, crypt2, crypt3, crypt4 (RSA PKCS#1 v1.5) and crypt5 "
+ "(RSA → ChaCha20-Poly1305). Decryption runs fully on-device."
infoCard(
"Схемы",
"crypt, crypt2, crypt3, crypt4 (RSA PKCS#1 v1.5) и crypt5 "
+ "(RSA → ChaCha20-Poly1305). Расшифровка целиком на устройстве."
)
section(
"Privacy",
"No analytics, no servers of our own. Requests go only to the "
+ "subscription URL contained in your link."
infoCard(
"Приватность",
"Без аналитики и собственных серверов. Запросы идут только на URL "
+ "подписки из твоей ссылки."
)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))

Link(destination: repoURL) {
Label("Source on GitHub", systemImage: "chevron.left.forwardslash.chevron.right")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
}
.buttonStyle(.bordered)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))

Text("Apache-2.0")
.font(.footnote).foregroundColor(.secondary)
.padding(.bottom, 24)
.font(.footnote).foregroundStyle(.secondary)
.padding(.bottom, 8)
}
.padding(.horizontal)
.padding(Layout.screenPadding)
}
.navigationTitle("About")
.background(Color(.systemGroupedBackground))
.navigationTitle("О приложении")
.navigationBarTitleDisplayMode(.inline)
}

private func section(_ title: String, _ body: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(body).font(.callout).foregroundColor(.secondary)
private var header: some View {
VStack(spacing: 8) {
Image("AppLogo")
.resizable()
.scaledToFit()
.frame(width: 104, height: 104)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.shadow(color: Color.accentColor.opacity(0.35), radius: 14, y: 6)
.padding(.top, 16)

Text("happwn").font(.largeTitle.bold())
Text(version).font(.subheadline).foregroundStyle(.secondary)
Text("Happ subscription config extractor")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}

private func infoCard(_ title: String, _ body: String) -> some View {
GroupedCard {
VStack(alignment: .leading, spacing: 5) {
Text(title).font(.headline)
Text(body).font(.callout).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
}
}
55 changes: 0 additions & 55 deletions ios/happwn/UI/ContentView.swift

This file was deleted.

103 changes: 103 additions & 0 deletions ios/happwn/UI/ExtractView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import SwiftUI
import UIKit

struct ExtractView: View {
@EnvironmentObject private var settings: Settings
@StateObject private var vm = ExtractionViewModel()
@FocusState private var fieldFocused: Bool

private var isLoading: Bool {
if case .loading = vm.state { return true }
return false
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text("Вставь happ://-ссылку — расшифрую и достану конфиги")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)

linkSection
PrimaryButton(title: "Извлечь конфиги", isLoading: isLoading) {
fieldFocused = false
Task { await vm.extract(userAgent: settings.userAgent, hwid: settings.hwid) }
}
.disabled(vm.link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)

content
}
.padding(Layout.screenPadding)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("happwn")
}

private var linkSection: some View {
VStack(alignment: .leading, spacing: Layout.rowSpacing) {
SectionLabel("Ссылка")
GroupedCard {
TextField("happ://crypt…", text: $vm.link, axis: .vertical)
.font(.system(.callout, design: .monospaced))
.lineLimit(3...6)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($fieldFocused)
.padding(14)

Divider()

Button {
if let s = UIPasteboard.general.string { vm.link = s }
} label: {
Label("Вставить из буфера", systemImage: "doc.on.clipboard")
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
}
}
}

@ViewBuilder private var content: some View {
switch vm.state {
case .idle:
emptyState
case .loading:
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 40)
case .success(let result):
ResultsView(result: result)
case .failure(let message):
errorCard(message)
}
}

private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "arrow.down.doc")
.font(.system(size: 44, weight: .light))
Text("Результат появится здесь")
.font(.subheadline)
}
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity)
.padding(.top, 48)
}

private func errorCard(_ message: String) -> some View {
GroupedCard {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.title3)
Text(message)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
}
}
}
Loading
Loading