diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c6c6202 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "figma": { + "type": "url", + "url": "https://mcp.figma.com/mcp", + "headers": { + "Authorization": "Bearer figd_WFn1CnWSubbZSaeSMsRW_q2WZLPam49AI1DYicwE" + } + } + } +} diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index b9aee41..a1f0dd1 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; + 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,6 +29,7 @@ buildActionMask = 2147483647; files = ( 853F29992F4B63D20092AD05 /* Lottie in Frameworks */, + 853F29A02F4B63D20092AD05 /* P256K in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -71,6 +73,7 @@ name = Rosetta; packageProductDependencies = ( 853F29982F4B63D20092AD05 /* Lottie */, + 853F29A12F4B63D20092AD05 /* P256K */, ); productName = Rosetta; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; @@ -102,6 +105,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */, + 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 853F29632F4B50410092AD05 /* Products */; @@ -263,8 +267,10 @@ DEVELOPMENT_TEAM = U6DMAKWNV3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -295,8 +301,10 @@ DEVELOPMENT_TEAM = U6DMAKWNV3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -349,6 +357,14 @@ minimumVersion = 4.0.0; }; }; + 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/GigaBitcoin/secp256k1.swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.16.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -357,6 +373,11 @@ package = 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; + 853F29A12F4B63D20092AD05 /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + productName = P256K; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 853F295A2F4B50410092AD05 /* Project object */; diff --git a/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json b/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..daf91f0 100644 --- a/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.902", + "green" : "0.541", + "red" : "0.141" + } + }, "idiom" : "universal" } ], diff --git a/Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..5518ebc Binary files /dev/null and b/Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Rosetta/Assets.xcassets/AppIcon.appiconset/Contents.json b/Rosetta/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..87d4015 100644 --- a/Rosetta/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Rosetta/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Rosetta/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json b/Rosetta/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json new file mode 100644 index 0000000..fe3de53 --- /dev/null +++ b/Rosetta/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.118", + "green" : "0.118", + "red" : "0.118" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/RosettaIcon.imageset/Contents.json b/Rosetta/Assets.xcassets/RosettaIcon.imageset/Contents.json new file mode 100644 index 0000000..2bc9607 --- /dev/null +++ b/Rosetta/Assets.xcassets/RosettaIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "rosetta_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/RosettaIcon.imageset/rosetta_icon.png b/Rosetta/Assets.xcassets/RosettaIcon.imageset/rosetta_icon.png new file mode 100644 index 0000000..5518ebc Binary files /dev/null and b/Rosetta/Assets.xcassets/RosettaIcon.imageset/rosetta_icon.png differ diff --git a/Rosetta/Core/Crypto/BIP39WordList.swift b/Rosetta/Core/Crypto/BIP39WordList.swift new file mode 100644 index 0000000..306a800 --- /dev/null +++ b/Rosetta/Core/Crypto/BIP39WordList.swift @@ -0,0 +1,269 @@ +// BIP39 English word list — 2048 words, standardized by BIP-0039 +// https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt + +enum BIP39 { + static let wordList: [String] = [ + "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", + "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", + "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", + "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", + "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", + "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", + "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", + "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", + "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", + "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", + "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", + "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", + "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", + "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", + "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", + "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", + "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", + "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", + "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", + "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", + "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", + "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", + "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", + "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", + "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", + "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", + "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", + "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", + "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", + "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", + "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", + "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", + "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", + "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", + "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", + "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", + "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", + "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", + "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", + "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", + "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", + "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", + "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", + "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", + "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", + "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", + "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", + "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", + "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", + "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", + "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", + "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", + "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", + "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", + "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", + "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", + "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", + "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", + "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", + "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", + "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", + "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", + "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", + "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", + "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", + "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", + "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", + "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", + "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", + "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", + "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", + "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", + "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", + "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", + "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", + "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", + "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", + "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", + "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", + "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", + "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", + "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", + "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", + "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", + "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", + "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", + "figure", "file", "film", "filter", "final", "find", "fine", "finger", + "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", + "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", + "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", + "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", + "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", + "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", + "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", + "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", + "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", + "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", + "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", + "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", + "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", + "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", + "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", + "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", + "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", + "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", + "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", + "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", + "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", + "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", + "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", + "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", + "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", + "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", + "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", + "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", + "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", + "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", + "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", + "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", + "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", + "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", + "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", + "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", + "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", + "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", + "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", + "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", + "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", + "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", + "library", "license", "life", "lift", "light", "like", "limb", "limit", + "link", "lion", "liquid", "list", "little", "live", "lizard", "load", + "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", + "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", + "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", + "maid", "mail", "main", "major", "make", "mammal", "man", "manage", + "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", + "marine", "market", "marriage", "mask", "mass", "master", "match", "material", + "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", + "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", + "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", + "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", + "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", + "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", + "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", + "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", + "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", + "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", + "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", + "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", + "never", "news", "next", "nice", "night", "noble", "noise", "nominee", + "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", + "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", + "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", + "october", "odor", "off", "offer", "office", "often", "oil", "okay", + "old", "olive", "olympic", "omit", "once", "one", "onion", "online", + "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", + "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", + "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", + "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", + "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", + "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", + "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", + "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", + "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", + "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", + "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", + "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", + "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", + "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", + "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", + "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", + "prison", "private", "prize", "problem", "process", "produce", "profit", "program", + "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", + "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", + "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", + "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", + "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", + "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", + "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", + "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", + "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", + "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", + "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", + "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", + "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", + "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", + "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", + "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", + "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", + "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", + "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", + "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", + "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", + "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", + "search", "season", "seat", "second", "secret", "section", "security", "seed", + "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", + "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", + "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", + "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", + "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", + "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", + "simple", "since", "sing", "siren", "sister", "situate", "six", "size", + "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", + "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", + "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", + "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", + "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", + "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", + "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", + "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", + "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", + "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", + "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", + "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", + "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", + "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", + "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", + "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", + "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", + "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", + "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", + "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", + "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", + "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", + "theme", "then", "theory", "there", "they", "thing", "this", "thought", + "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", + "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", + "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", + "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", + "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", + "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", + "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", + "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", + "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", + "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", + "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", + "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", + "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", + "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", + "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", + "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", + "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", + "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", + "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", + "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", + "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", + "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", + "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", + "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", + "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", + "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", + "wild", "will", "win", "window", "wine", "wing", "wink", "winner", + "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", + "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", + "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", + "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo", + ] + + static let wordSet: Set = Set(wordList) + + static func index(of word: String) -> Int? { + wordList.firstIndex(of: word) + } +} diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift new file mode 100644 index 0000000..1072741 --- /dev/null +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -0,0 +1,379 @@ +import Foundation +import CryptoKit +import CommonCrypto +import Compression +import P256K + +// MARK: - Error Types + +enum CryptoError: LocalizedError { + case invalidEntropy + case invalidChecksum + case invalidMnemonic + case invalidPrivateKey + case encryptionFailed + case decryptionFailed + case keyDerivationFailed + case compressionFailed + case invalidData(String) + + var errorDescription: String? { + switch self { + case .invalidMnemonic: + return "Invalid recovery phrase. Please check each word for typos." + case .decryptionFailed: + return "Decryption failed. The password may be incorrect." + case .invalidData(let reason): + return "Invalid data: \(reason)" + default: + return "A cryptographic operation failed." + } + } +} + +// MARK: - CryptoManager + +/// All methods are `nonisolated` — safe to call from any actor/thread. +final class CryptoManager: @unchecked Sendable { + + static let shared = CryptoManager() + private init() {} + + // MARK: - BIP39: Mnemonic Generation + + /// Generates a cryptographically secure 12-word BIP39 mnemonic. + nonisolated func generateMnemonic() throws -> [String] { + var entropy = Data(count: 16) // 128 bits + let status = entropy.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!) + } + guard status == errSecSuccess else { throw CryptoError.invalidEntropy } + return try mnemonicFromEntropy(entropy) + } + + // MARK: - BIP39: Mnemonic Validation + + /// Returns `true` if all 12 words are in the BIP39 word list and the checksum is valid. + nonisolated func validateMnemonic(_ words: [String]) -> Bool { + guard words.count == 12 else { return false } + let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) } + guard normalized.allSatisfy({ BIP39.wordSet.contains($0) }) else { return false } + return (try? entropyFromMnemonic(normalized)) != nil + } + + // MARK: - BIP39: Mnemonic → Seed (PBKDF2-SHA512) + + /// Derives the 64-byte seed from a mnemonic using PBKDF2-SHA512 with 2048 iterations. + /// Compatible with BIP39 specification (no passphrase). + nonisolated func mnemonicToSeed(_ words: [String]) -> Data { + let phrase = words.joined(separator: " ") + return pbkdf2(password: phrase, salt: "mnemonic", iterations: 2048, keyLength: 64, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512)) + } + + // MARK: - Key Pair Derivation (secp256k1) + + /// Derives a secp256k1 key pair from a mnemonic phrase. + /// Returns (privateKey: 32 bytes, publicKey: 33 bytes compressed). + nonisolated func deriveKeyPair(from mnemonic: [String]) throws -> (privateKey: Data, publicKey: Data) { + let seed = mnemonicToSeed(mnemonic) + let seedHex = seed.hexString + + // SHA256 of the UTF-8 bytes of the hex-encoded seed string + let privateKey = Data(SHA256.hash(data: Data(seedHex.utf8))) + + let publicKey = try deriveCompressedPublicKey(from: privateKey) + return (privateKey, publicKey) + } + + // MARK: - Account Encryption (PBKDF2-SHA1 + zlib + AES-256-CBC) + + /// Encrypts `data` with a password using PBKDF2-HMAC-SHA1 + zlib deflate + AES-256-CBC. + /// Compatible with Android (crypto-js uses SHA1 by default) and JS (pako.deflate). + /// Output format: `Base64(IV):Base64(ciphertext)`. + nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String { + let compressed = try rawDeflate(data) + let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)) + let iv = try randomBytes(count: 16) + let ciphertext = try aesCBCEncrypt(compressed, key: key, iv: iv) + return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" + } + + /// Decrypts data encrypted with `encryptWithPassword(_:password:)`. + /// Tries PBKDF2-HMAC-SHA1 + zlib (Android-compatible) first, then falls back to + /// legacy PBKDF2-HMAC-SHA256 without compression (old iOS format) for migration. + nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data { + let parts = encrypted.components(separatedBy: ":") + guard parts.count == 2, + let iv = Data(base64Encoded: parts[0]), + let ciphertext = Data(base64Encoded: parts[1]) else { + throw CryptoError.invalidData("Malformed encrypted string") + } + + // Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate + if let result = try? { + let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)) + let decrypted = try aesCBCDecrypt(ciphertext, key: key, iv: iv) + return try rawInflate(decrypted) + }() { + return result + } + + // Fallback: legacy iOS format (PBKDF2-SHA256, no compression) + let legacyKey = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)) + return try aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv) + } + + // MARK: - Utilities + + nonisolated func sha256(_ data: Data) -> Data { + Data(SHA256.hash(data: data)) + } + + /// Generates the private key hash used for server handshake authentication. + /// Formula: SHA256(privateKeyHex + "rosetta") → lowercase hex string. + nonisolated func generatePrivateKeyHash(privateKeyHex: String) -> String { + let combined = Data((privateKeyHex + "rosetta").utf8) + return sha256(combined).hexString + } +} + +// MARK: - BIP39 Internal + +private extension CryptoManager { + + func mnemonicFromEntropy(_ entropy: Data) throws -> [String] { + guard entropy.count == 16 else { throw CryptoError.invalidEntropy } + + let hashBytes = Data(SHA256.hash(data: entropy)) + let checksumByte = hashBytes[0] + + // Build bit array: 128 entropy bits + 4 checksum bits = 132 bits + var bits = [Bool]() + bits.reserveCapacity(132) + for byte in entropy { + for shift in stride(from: 7, through: 0, by: -1) { + bits.append((byte >> shift) & 1 == 1) + } + } + // Top 4 bits of SHA256 hash are the checksum + for shift in stride(from: 7, through: 4, by: -1) { + bits.append((checksumByte >> shift) & 1 == 1) + } + + // Split into 12 × 11-bit groups, map to words + return try (0..<12).map { chunk in + let index = (0..<11).reduce(0) { acc, bit in + acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0) + } + guard index < BIP39.wordList.count else { throw CryptoError.invalidEntropy } + return BIP39.wordList[index] + } + } + + func entropyFromMnemonic(_ words: [String]) throws -> Data { + guard words.count == 12 else { throw CryptoError.invalidMnemonic } + + // Convert 12 × 11-bit word indices into a 132-bit array + var bits = [Bool]() + bits.reserveCapacity(132) + for word in words { + guard let index = BIP39.index(of: word) else { throw CryptoError.invalidMnemonic } + for shift in stride(from: 10, through: 0, by: -1) { + bits.append((index >> shift) & 1 == 1) + } + } + + // First 128 bits = entropy, last 4 bits = checksum + var entropy = Data(count: 16) + for byteIdx in 0..<16 { + let value: UInt8 = (0..<8).reduce(0) { acc, bit in + acc * 2 + (bits[byteIdx * 8 + bit] ? 1 : 0) + } + entropy[byteIdx] = value + } + + // Verify checksum + let hashBytes = Data(SHA256.hash(data: entropy)) + let expectedTopNibble = hashBytes[0] >> 4 + let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in + bits[128 + bit] ? acc | (1 << (3 - bit)) : acc + } + guard actualTopNibble == expectedTopNibble else { throw CryptoError.invalidChecksum } + + return entropy + } +} + +// MARK: - secp256k1 + +extension CryptoManager { + + /// Computes the 33-byte compressed secp256k1 public key from a 32-byte private key. + nonisolated func deriveCompressedPublicKey(from privateKey: Data) throws -> Data { + guard privateKey.count == 32 else { throw CryptoError.invalidPrivateKey } + // P256K v0.21+: init is `dataRepresentation:format:`, public key via `dataRepresentation` + let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: privateKey, format: .compressed) + // .compressed format → dataRepresentation returns 33-byte compressed public key + return signingKey.publicKey.dataRepresentation + } +} + +// MARK: - Crypto Primitives + +private extension CryptoManager { + + func pbkdf2( + password: String, + salt: String, + iterations: Int, + keyLength: Int, + prf: CCPseudoRandomAlgorithm + ) -> Data { + var derivedKey = Data(repeating: 0, count: keyLength) + derivedKey.withUnsafeMutableBytes { keyPtr in + password.withCString { passPtr in + salt.withCString { saltPtr in + _ = CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passPtr, strlen(passPtr), + saltPtr, strlen(saltPtr), + prf, + UInt32(iterations), + keyPtr.bindMemory(to: UInt8.self).baseAddress!, + keyLength + ) + } + } + } + return derivedKey + } + + func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data { + let outputSize = data.count + kCCBlockSizeAES128 + var ciphertext = Data(count: outputSize) + var numBytes = 0 + let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in + data.withUnsafeBytes { dataPtr in + key.withUnsafeBytes { keyPtr in + iv.withUnsafeBytes { ivPtr in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyPtr.baseAddress!, key.count, + ivPtr.baseAddress!, + dataPtr.baseAddress!, data.count, + ciphertextPtr.baseAddress!, outputSize, + &numBytes + ) + } + } + } + } + guard status == kCCSuccess else { throw CryptoError.encryptionFailed } + return ciphertext.prefix(numBytes) + } + + func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data { + let outputSize = data.count + kCCBlockSizeAES128 + var plaintext = Data(count: outputSize) + var numBytes = 0 + let status = plaintext.withUnsafeMutableBytes { plaintextPtr in + data.withUnsafeBytes { dataPtr in + key.withUnsafeBytes { keyPtr in + iv.withUnsafeBytes { ivPtr in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyPtr.baseAddress!, key.count, + ivPtr.baseAddress!, + dataPtr.baseAddress!, data.count, + plaintextPtr.baseAddress!, outputSize, + &numBytes + ) + } + } + } + } + guard status == kCCSuccess else { throw CryptoError.decryptionFailed } + return plaintext.prefix(numBytes) + } + + func randomBytes(count: Int) throws -> Data { + var data = Data(count: count) + let status = data.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!) + } + guard status == errSecSuccess else { throw CryptoError.invalidEntropy } + return data + } + + // MARK: - zlib Raw Deflate / Inflate + + /// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)). + func rawDeflate(_ data: Data) throws -> Data { + // Compression framework uses COMPRESSION_ZLIB which is raw deflate + let sourceSize = data.count + // Worst case: input size + 512 bytes overhead + let destinationSize = sourceSize + 512 + var destination = Data(count: destinationSize) + + let compressedSize = destination.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + compression_encode_buffer( + destPtr.bindMemory(to: UInt8.self).baseAddress!, + destinationSize, + srcPtr.bindMemory(to: UInt8.self).baseAddress!, + sourceSize, + nil, + COMPRESSION_ZLIB + ) + } + } + + guard compressedSize > 0 else { throw CryptoError.compressionFailed } + return destination.prefix(compressedSize) + } + + /// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)). + func rawInflate(_ data: Data) throws -> Data { + let sourceSize = data.count + // Decompressed data can be much larger; start with 4x, retry if needed + var destinationSize = sourceSize * 4 + if destinationSize < 256 { destinationSize = 256 } + + for multiplier in [4, 8, 16, 32] { + destinationSize = sourceSize * multiplier + if destinationSize < 256 { destinationSize = 256 } + var destination = Data(count: destinationSize) + + let decompressedSize = destination.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + compression_decode_buffer( + destPtr.bindMemory(to: UInt8.self).baseAddress!, + destinationSize, + srcPtr.bindMemory(to: UInt8.self).baseAddress!, + sourceSize, + nil, + COMPRESSION_ZLIB + ) + } + } + + if decompressedSize > 0 && decompressedSize < destinationSize { + return destination.prefix(decompressedSize) + } + } + + throw CryptoError.compressionFailed + } +} + +// MARK: - Data Extension + +extension Data { + nonisolated var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Rosetta/Core/Crypto/KeychainManager.swift b/Rosetta/Core/Crypto/KeychainManager.swift new file mode 100644 index 0000000..ee8468b --- /dev/null +++ b/Rosetta/Core/Crypto/KeychainManager.swift @@ -0,0 +1,119 @@ +import Foundation +import Security + +// MARK: - KeychainError + +enum KeychainError: LocalizedError { + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case deleteFailed(OSStatus) + case notFound + case unexpectedData + + var errorDescription: String? { + switch self { + case .saveFailed(let status): return "Keychain save failed (OSStatus \(status))" + case .loadFailed(let status): return "Keychain load failed (OSStatus \(status))" + case .deleteFailed(let status): return "Keychain delete failed (OSStatus \(status))" + case .notFound: return "Item not found in Keychain" + case .unexpectedData: return "Unexpected data format in Keychain" + } + } +} + +// MARK: - KeychainManager + +/// Thread-safe iOS Keychain wrapper. +/// Stores data under `com.rosetta.messenger.`. +final class KeychainManager: @unchecked Sendable { + + static let shared = KeychainManager() + private init() {} + + private let service = "com.rosetta.messenger" + + // MARK: - CRUD + + func save(_ data: Data, forKey key: String) throws { + let query = baseQuery(forKey: key).merging([ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ]) { $1 } + + // Delete existing entry first (update not atomic without SecItemUpdate) + SecItemDelete(query as CFDictionary) + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } + } + + func load(forKey key: String) throws -> Data { + let query = baseQuery(forKey: key).merging([ + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ]) { $1 } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data else { throw KeychainError.unexpectedData } + return data + case errSecItemNotFound: + throw KeychainError.notFound + default: + throw KeychainError.loadFailed(status) + } + } + + func delete(forKey key: String) throws { + let query = baseQuery(forKey: key) + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status) + } + } + + func contains(key: String) -> Bool { + (try? load(forKey: key)) != nil + } + + // MARK: - Private + + private func baseQuery(forKey key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} + +// MARK: - Convenience + +extension KeychainManager { + + func saveString(_ string: String, forKey key: String) throws { + guard let data = string.data(using: .utf8) else { return } + try save(data, forKey: key) + } + + func loadString(forKey key: String) throws -> String { + let data = try load(forKey: key) + guard let string = String(data: data, encoding: .utf8) else { + throw KeychainError.unexpectedData + } + return string + } + + func saveCodable(_ value: T, forKey key: String) throws { + let data = try JSONEncoder().encode(value) + try save(data, forKey: key) + } + + func loadCodable(_ type: T.Type, forKey key: String) throws -> T { + let data = try load(forKey: key) + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift new file mode 100644 index 0000000..785f60a --- /dev/null +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -0,0 +1,725 @@ +import Foundation +import CommonCrypto +import P256K + +// MARK: - MessageCrypto + +/// Handles message-level encryption/decryption using XChaCha20-Poly1305 + ECDH. +/// Matches the Android `MessageCrypto` implementation for cross-platform compatibility. +enum MessageCrypto { + + // MARK: - Public API + + /// Decrypts an incoming message using ECDH key exchange + XChaCha20-Poly1305. + /// - Parameters: + /// - ciphertext: Hex-encoded XChaCha20-Poly1305 encrypted content (ciphertext + 16-byte tag). + /// - encryptedKey: Base64-encoded ECDH-encrypted key+nonce (`iv:encryptedKey:ephemeralPrivateKey`). + /// - myPrivateKeyHex: Recipient's secp256k1 private key (hex). + /// - Returns: Decrypted plaintext string. + static func decryptIncoming( + ciphertext: String, + encryptedKey: String, + myPrivateKeyHex: String + ) throws -> String { + // Step 1: ECDH decrypt the XChaCha20 key+nonce + let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex) + + guard keyAndNonce.count >= 56 else { + throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)") + } + + let key = keyAndNonce[0..<32] // 32-byte XChaCha20 key + let nonce = keyAndNonce[32..<56] // 24-byte XChaCha20 nonce + + // Step 2: XChaCha20-Poly1305 decrypt + let ciphertextData = Data(hexString: ciphertext) + let plaintext = try xchacha20Poly1305Decrypt( + ciphertextWithTag: ciphertextData, + key: Data(key), + nonce: Data(nonce) + ) + + guard let text = String(data: plaintext, encoding: .utf8) else { + throw CryptoError.invalidData("Decrypted data is not valid UTF-8") + } + + return text + } + + /// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient. + /// - Parameters: + /// - plaintext: The message text. + /// - recipientPublicKeyHex: Recipient's secp256k1 compressed public key (hex). + /// - senderPrivateKeyHex: Sender's private key (hex) — used to also encrypt for self. + /// - Returns: Tuple of (content: hex ciphertext+tag, chachaKey: base64 encrypted key for recipient, aesChachaKey: base64 encrypted key for sender). + static func encryptOutgoing( + plaintext: String, + recipientPublicKeyHex: String, + senderPrivateKeyHex: String + ) throws -> (content: String, chachaKey: String, aesChachaKey: String) { + guard let plaintextData = plaintext.data(using: .utf8) else { + throw CryptoError.invalidData("Cannot encode plaintext as UTF-8") + } + + // Generate random 32-byte key + 24-byte nonce + let key = try randomBytes(count: 32) + let nonce = try randomBytes(count: 24) + let keyAndNonce = key + nonce + + // XChaCha20-Poly1305 encrypt + let ciphertextWithTag = try xchacha20Poly1305Encrypt( + plaintext: plaintextData, key: key, nonce: nonce + ) + + // Encrypt key+nonce for recipient via ECDH + let chachaKey = try encryptKeyForRecipient( + keyAndNonce: keyAndNonce, + recipientPublicKeyHex: recipientPublicKeyHex + ) + + // Encrypt key+nonce for sender (self) via ECDH with sender's own public key + let senderPrivKey = try P256K.Signing.PrivateKey( + dataRepresentation: Data(hexString: senderPrivateKeyHex), + format: .compressed + ) + let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString + + let aesChachaKey = try encryptKeyForRecipient( + keyAndNonce: keyAndNonce, + recipientPublicKeyHex: senderPublicKeyHex + ) + + return ( + content: ciphertextWithTag.hexString, + chachaKey: chachaKey, + aesChachaKey: aesChachaKey + ) + } +} + +// MARK: - ECDH Key Exchange + +private extension MessageCrypto { + + /// Decrypts the XChaCha20 key+nonce from the sender using ECDH. + /// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex) + static func decryptKeyFromSender(encryptedKey: String, myPrivateKeyHex: String) throws -> Data { + guard let decoded = Data(base64Encoded: encryptedKey), + let combined = String(data: decoded, encoding: .utf8) else { + throw CryptoError.invalidData("Cannot decode Base64 key") + } + + let parts = combined.split(separator: ":", maxSplits: 2).map(String.init) + guard parts.count == 3 else { + throw CryptoError.invalidData("Expected iv:encrypted:ephemeralKey, got \(parts.count) parts") + } + + let ivHex = parts[0] + let encryptedKeyHex = parts[1] + var ephemeralPrivateKeyHex = parts[2] + + // Handle odd-length hex from JS toString(16) + if ephemeralPrivateKeyHex.count % 2 != 0 { + ephemeralPrivateKeyHex = "0" + ephemeralPrivateKeyHex + } + + let iv = Data(hexString: ivHex) + let encryptedKeyData = Data(hexString: encryptedKeyHex) + + // ECDH: compute shared secret = myPublicKey × ephemeralPrivateKey + // Using P256K: create ephemeral private key, derive my public key, compute shared secret + let ephemeralPrivKeyData = Data(hexString: ephemeralPrivateKeyHex) + let myPrivKeyData = Data(hexString: myPrivateKeyHex) + + let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey( + dataRepresentation: ephemeralPrivKeyData, format: .compressed + ) + let myPrivKey = try P256K.KeyAgreement.PrivateKey( + dataRepresentation: myPrivKeyData, format: .compressed + ) + let myPublicKey = myPrivKey.publicKey + + // ECDH: ephemeralPrivateKey × myPublicKey → shared point + // P256K returns compressed format (1 + 32 bytes), we need just x-coordinate (bytes 1...32) + let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: myPublicKey, format: .compressed) + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + + // Extract x-coordinate (skip the 1-byte prefix) + let sharedKey: Data + if sharedSecretData.count == 33 { + sharedKey = sharedSecretData[1..<33] + } else if sharedSecretData.count == 32 { + sharedKey = sharedSecretData + } else { + throw CryptoError.invalidData("Unexpected shared secret length: \(sharedSecretData.count)") + } + + // AES-256-CBC decrypt + let decryptedBytes = try aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv) + + // UTF-8 → Latin1 conversion (reverse of JS crypto-js compatibility) + // The Android code does: String(bytes, UTF-8) → toByteArray(ISO_8859_1) + guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else { + throw CryptoError.invalidData("Decrypted key is not valid UTF-8") + } + let originalBytes = Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) }) + + return originalBytes + } + + /// Encrypts the XChaCha20 key+nonce for a recipient using ECDH. + static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String { + // Generate ephemeral key pair + let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey() + + // Parse recipient public key + let recipientPubKey = try P256K.KeyAgreement.PublicKey( + dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed + ) + + // ECDH: ephemeralPrivKey × recipientPubKey → shared secret + let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: recipientPubKey, format: .compressed) + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + + // Extract x-coordinate + let sharedKey: Data + if sharedSecretData.count == 33 { + sharedKey = sharedSecretData[1..<33] + } else { + sharedKey = sharedSecretData + } + + // Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility) + let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) }) + guard let dataToEncrypt = utf8Representation.data(using: .utf8) else { + throw CryptoError.encryptionFailed + } + + // AES-256-CBC encrypt + let iv = try randomBytes(count: 16) + let ciphertext = try aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv) + + // Get ephemeral private key hex + let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString + + // Format: Base64(ivHex:ciphertextHex:ephemeralPrivateKeyHex) + let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)" + guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else { + throw CryptoError.encryptionFailed + } + + return base64 + } +} + +// MARK: - XChaCha20-Poly1305 + +private extension MessageCrypto { + + static let poly1305TagSize = 16 + + /// XChaCha20-Poly1305 decryption matching Android implementation. + static func xchacha20Poly1305Decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data { + guard ciphertextWithTag.count > poly1305TagSize else { + throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag") + } + guard key.count == 32, nonce.count == 24 else { + throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes") + } + + let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)] + let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...] + + // Step 1: HChaCha20 — derive subkey from key + first 16 bytes of nonce + let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16])) + + // Step 2: Build ChaCha20 nonce: [0,0,0,0] + nonce[16..<24] + var chacha20Nonce = Data(repeating: 0, count: 12) + chacha20Nonce[4..<12] = nonce[16..<24] + + // Step 3: Generate Poly1305 key from first 64 bytes of keystream (counter=0) + let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32] + + // Step 4: Verify Poly1305 tag + let computedTag = poly1305MAC( + data: Data(ciphertext), + key: Data(poly1305Key) + ) + + guard constantTimeEqual(Data(tag), computedTag) else { + throw CryptoError.decryptionFailed + } + + // Step 5: Decrypt with ChaCha20 (counter starts at 1) + let plaintext = chacha20Encrypt( + data: Data(ciphertext), + key: subkey, + nonce: chacha20Nonce, + initialCounter: 1 + ) + + return plaintext + } + + /// XChaCha20-Poly1305 encryption. + static func xchacha20Poly1305Encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data { + guard key.count == 32, nonce.count == 24 else { + throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes") + } + + // Step 1: HChaCha20 — derive subkey + let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16])) + + // Step 2: Build ChaCha20 nonce + var chacha20Nonce = Data(repeating: 0, count: 12) + chacha20Nonce[4..<12] = nonce[16..<24] + + // Step 3: Generate Poly1305 key + let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32] + + // Step 4: Encrypt with ChaCha20 (counter starts at 1) + let ciphertext = chacha20Encrypt( + data: plaintext, + key: subkey, + nonce: chacha20Nonce, + initialCounter: 1 + ) + + // Step 5: Compute Poly1305 tag + let tag = poly1305MAC(data: ciphertext, key: Data(poly1305Key)) + + return ciphertext + tag + } +} + +// MARK: - ChaCha20 Core + +private extension MessageCrypto { + + /// ChaCha20 quarter round. + static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { + state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 16) | (state[d] >> 16) + state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 12) | (state[b] >> 20) + state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 8) | (state[d] >> 24) + state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 7) | (state[b] >> 25) + } + + /// Generates a 64-byte ChaCha20 block. + static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data { + var state = [UInt32](repeating: 0, count: 16) + + // Constants: "expand 32-byte k" + state[0] = 0x61707865 + state[1] = 0x3320646e + state[2] = 0x79622d32 + state[3] = 0x6b206574 + + // Key + for i in 0..<8 { + state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian } + } + + // Counter + state[12] = counter + + // Nonce + for i in 0..<3 { + state[13 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian } + } + + var working = state + + // 20 rounds (10 double rounds) + for _ in 0..<10 { + quarterRound(&working, 0, 4, 8, 12) + quarterRound(&working, 1, 5, 9, 13) + quarterRound(&working, 2, 6, 10, 14) + quarterRound(&working, 3, 7, 11, 15) + quarterRound(&working, 0, 5, 10, 15) + quarterRound(&working, 1, 6, 11, 12) + quarterRound(&working, 2, 7, 8, 13) + quarterRound(&working, 3, 4, 9, 14) + } + + // Add initial state + for i in 0..<16 { + working[i] = working[i] &+ state[i] + } + + // Serialize to bytes (little-endian) + var result = Data(count: 64) + for i in 0..<16 { + let val = working[i].littleEndian + result[i * 4] = UInt8(truncatingIfNeeded: val) + result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8) + result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16) + result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24) + } + + return result + } + + /// ChaCha20 stream cipher encryption/decryption. + static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data { + var result = Data(count: data.count) + var counter = initialCounter + + for offset in stride(from: 0, to: data.count, by: 64) { + let block = chacha20Block(key: key, nonce: nonce, counter: counter) + let blockSize = min(64, data.count - offset) + + for i in 0.. Data { + var state = [UInt32](repeating: 0, count: 16) + + // Constants + state[0] = 0x61707865 + state[1] = 0x3320646e + state[2] = 0x79622d32 + state[3] = 0x6b206574 + + // Key + for i in 0..<8 { + state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian } + } + + // Nonce (16 bytes → 4 uint32s) + for i in 0..<4 { + state[12 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian } + } + + // 20 rounds + for _ in 0..<10 { + quarterRound(&state, 0, 4, 8, 12) + quarterRound(&state, 1, 5, 9, 13) + quarterRound(&state, 2, 6, 10, 14) + quarterRound(&state, 3, 7, 11, 15) + quarterRound(&state, 0, 5, 10, 15) + quarterRound(&state, 1, 6, 11, 12) + quarterRound(&state, 2, 7, 8, 13) + quarterRound(&state, 3, 4, 9, 14) + } + + // Output: first 4 words + last 4 words + var result = Data(count: 32) + for i in 0..<4 { + let val = state[i].littleEndian + result[i * 4] = UInt8(truncatingIfNeeded: val) + result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8) + result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16) + result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24) + } + for i in 0..<4 { + let val = state[12 + i].littleEndian + result[16 + i * 4] = UInt8(truncatingIfNeeded: val) + result[16 + i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8) + result[16 + i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16) + result[16 + i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24) + } + + return result + } +} + +// MARK: - Poly1305 + +private extension MessageCrypto { + + /// Poly1305 MAC computation matching the AEAD construction. + static func poly1305MAC(data: Data, key: Data) -> Data { + // Clamp r (first 16 bytes of key) + var r = [UInt8](key[0..<16]) + r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15 + r[4] &= 252; r[8] &= 252; r[12] &= 252 + + // s = last 16 bytes of key + let s = [UInt8](key[16..<32]) + + // Convert r and s to big integers using UInt128-like arithmetic + var rVal: (UInt64, UInt64) = (0, 0) // (low, high) + for i in stride(from: 15, through: 8, by: -1) { + rVal.1 = rVal.1 << 8 | UInt64(r[i]) + } + for i in stride(from: 7, through: 0, by: -1) { + rVal.0 = rVal.0 << 8 | UInt64(r[i]) + } + + // Use arrays for big number arithmetic (limbs approach) + // For simplicity and correctness, use a big-number representation + var accumulator = [UInt64](repeating: 0, count: 5) // 130-bit number in 26-bit limbs + let rLimbs = toLimbs26(r) + let p: UInt64 = (1 << 26) // 2^26 + + // Build padded data: data + padding to 16-byte boundary + lengths + var macInput = Data(data) + let padding = (16 - (data.count % 16)) % 16 + if padding > 0 { + macInput.append(Data(repeating: 0, count: padding)) + } + // AAD length (0 for our case — no associated data) + macInput.append(Data(repeating: 0, count: 8)) + // Ciphertext length (little-endian 64-bit) + var ctLen = UInt64(data.count).littleEndian + macInput.append(Data(bytes: &ctLen, count: 8)) + + // Process in 16-byte blocks + for offset in stride(from: 0, to: macInput.count, by: 16) { + let blockEnd = min(offset + 16, macInput.count) + var block = [UInt8](macInput[offset.. [UInt64] { + let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count) + var val: UInt64 = 0 + var limbs = [UInt64](repeating: 0, count: 5) + + // Read as little-endian 128-bit number + for i in stride(from: 15, through: 0, by: -1) { + val = val << 8 | UInt64(b[i]) + if i == 0 { + limbs[0] = val & 0x3FFFFFF + limbs[1] = (val >> 26) & 0x3FFFFFF + limbs[2] = (val >> 52) & 0x3FFFFFF + } + } + + // Re-read properly + var full = [UInt8](repeating: 0, count: 17) + for i in 0..> 26) & 0x3FFFFFF + limbs[2] = ((lo >> 52) | (hi << 12)) & 0x3FFFFFF + limbs[3] = (hi >> 14) & 0x3FFFFFF + limbs[4] = (hi >> 40) & 0x3FFFFFF + + return limbs + } + + /// Multiply two numbers in 26-bit limb form, reduce mod 2^130 - 5. + static func poly1305Multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] { + // Full multiply into 10 limbs, then reduce + let r0 = r[0], r1 = r[1], r2 = r[2], r3 = r[3], r4 = r[4] + let s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5 + let a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4] + + var h0 = a0 * r0 + a1 * s4 + a2 * s3 + a3 * s2 + a4 * s1 + var h1 = a0 * r1 + a1 * r0 + a2 * s4 + a3 * s3 + a4 * s2 + var h2 = a0 * r2 + a1 * r1 + a2 * r0 + a3 * s4 + a4 * s3 + var h3 = a0 * r3 + a1 * r2 + a2 * r1 + a3 * r0 + a4 * s4 + var h4 = a0 * r4 + a1 * r3 + a2 * r2 + a3 * r1 + a4 * r0 + + // Carry propagation + var c: UInt64 + c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF + c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF + c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF + c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF + c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF + c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF + + return [h0, h1, h2, h3, h4] + } + + /// Final reduction and add s. + static func poly1305Freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] { + var h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4] + + // Full carry + var c: UInt64 + c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF + c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF + c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF + c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF + c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF + c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF + + // Compute h + -(2^130 - 5) = h - p + var g0 = h0 &+ 5; c = g0 >> 26; g0 &= 0x3FFFFFF + var g1 = h1 &+ c; c = g1 >> 26; g1 &= 0x3FFFFFF + var g2 = h2 &+ c; c = g2 >> 26; g2 &= 0x3FFFFFF + var g3 = h3 &+ c; c = g3 >> 26; g3 &= 0x3FFFFFF + let g4 = h4 &+ c &- (1 << 26) + + // If g4 didn't underflow (bit 63 not set), use g (h >= p) + let mask = (g4 >> 63) &- 1 // 0 if g4 underflowed, 0xFFF...F otherwise + let nmask = ~mask + h0 = (h0 & nmask) | (g0 & mask) + h1 = (h1 & nmask) | (g1 & mask) + h2 = (h2 & nmask) | (g2 & mask) + h3 = (h3 & nmask) | (g3 & mask) + h4 = (h4 & nmask) | (g4 & mask) + + // Reassemble into 128-bit number + let f0 = h0 | (h1 << 26) + let f1 = (h1 >> 38) | (h2 << 12) | (h3 << 38) + let f2 = (h3 >> 26) | (h4 << 0) // unused high bits + + // Convert to two 64-bit values + let lo = h0 | (h1 << 26) | (h2 << 52) + let hi = (h2 >> 12) | (h3 << 14) | (h4 << 40) + + // Add s (little-endian) + var sLo: UInt64 = 0 + var sHi: UInt64 = 0 + for i in stride(from: 7, through: 0, by: -1) { + sLo = sLo << 8 | UInt64(s[i]) + } + for i in stride(from: 15, through: 8, by: -1) { + sHi = sHi << 8 | UInt64(s[i]) + } + + var resultLo = lo &+ sLo + var carry: UInt64 = resultLo < lo ? 1 : 0 + var resultHi = hi &+ sHi &+ carry + + // Output 16 bytes little-endian + var output = [UInt8](repeating: 0, count: 16) + for i in 0..<8 { + output[i] = UInt8(truncatingIfNeeded: resultLo >> (i * 8)) + } + for i in 0..<8 { + output[8 + i] = UInt8(truncatingIfNeeded: resultHi >> (i * 8)) + } + + return output + } + + static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool { + guard a.count == b.count else { return false } + var result: UInt8 = 0 + for i in 0.. Data { + let outputSize = data.count + kCCBlockSizeAES128 + var ciphertext = Data(count: outputSize) + var numBytes = 0 + let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in + data.withUnsafeBytes { dataPtr in + key.withUnsafeBytes { keyPtr in + iv.withUnsafeBytes { ivPtr in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyPtr.baseAddress!, key.count, + ivPtr.baseAddress!, + dataPtr.baseAddress!, data.count, + ciphertextPtr.baseAddress!, outputSize, + &numBytes + ) + } + } + } + } + guard status == kCCSuccess else { throw CryptoError.encryptionFailed } + return ciphertext.prefix(numBytes) + } + + static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data { + let outputSize = data.count + kCCBlockSizeAES128 + var plaintext = Data(count: outputSize) + var numBytes = 0 + let status = plaintext.withUnsafeMutableBytes { plaintextPtr in + data.withUnsafeBytes { dataPtr in + key.withUnsafeBytes { keyPtr in + iv.withUnsafeBytes { ivPtr in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyPtr.baseAddress!, key.count, + ivPtr.baseAddress!, + dataPtr.baseAddress!, data.count, + plaintextPtr.baseAddress!, outputSize, + &numBytes + ) + } + } + } + } + guard status == kCCSuccess else { throw CryptoError.decryptionFailed } + return plaintext.prefix(numBytes) + } + + static func randomBytes(count: Int) throws -> Data { + var data = Data(count: count) + let status = data.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!) + } + guard status == errSecSuccess else { throw CryptoError.invalidEntropy } + return data + } +} + +// MARK: - Data Hex Extension + +extension Data { + /// Initialize from a hex string. + init(hexString: String) { + let hex = hexString.lowercased() + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex + if nextIndex == hex.endIndex && hex.distance(from: index, to: nextIndex) < 2 { + // Odd hex character at end + let byte = UInt8(hex[index...index], radix: 16) ?? 0 + data.append(byte) + } else { + let byte = UInt8(hex[index.. $1.lastMessageTimestamp + } + } + + private init() {} + + // MARK: - Updates + + func upsertDialog(_ dialog: Dialog) { + dialogs[dialog.opponentKey] = dialog + } + + /// Creates or updates a dialog from an incoming message packet. + func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) { + let fromMe = packet.fromPublicKey == myPublicKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + + var dialog = dialogs[opponentKey] ?? Dialog( + id: opponentKey, + account: myPublicKey, + opponentKey: opponentKey, + opponentTitle: "", + opponentUsername: "", + lastMessage: "", + lastMessageTimestamp: 0, + unreadCount: 0, + isOnline: false, + lastSeen: 0, + isVerified: false, + iHaveSent: false, + isPinned: false, + isMuted: false, + lastMessageFromMe: false, + lastMessageDelivered: .waiting + ) + + dialog.lastMessage = decryptedText + dialog.lastMessageTimestamp = Int64(packet.timestamp) + dialog.lastMessageFromMe = fromMe + dialog.lastMessageDelivered = fromMe ? .waiting : .delivered + + if fromMe { + dialog.iHaveSent = true + } else { + dialog.unreadCount += 1 + } + + dialogs[opponentKey] = dialog + } + + func updateOnlineState(publicKey: String, isOnline: Bool) { + guard var dialog = dialogs[publicKey] else { return } + dialog.isOnline = isOnline + if !isOnline { + dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) + } + dialogs[publicKey] = dialog + } + + func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.lastMessageDelivered = status + dialogs[opponentKey] = dialog + } + + func updateUserInfo(publicKey: String, title: String, username: String) { + guard var dialog = dialogs[publicKey] else { return } + if !title.isEmpty { dialog.opponentTitle = title } + if !username.isEmpty { dialog.opponentUsername = username } + dialogs[publicKey] = dialog + } + + func markAsRead(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.unreadCount = 0 + dialogs[opponentKey] = dialog + } + + func deleteDialog(opponentKey: String) { + dialogs.removeValue(forKey: opponentKey) + } + + func togglePin(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.isPinned.toggle() + dialogs[opponentKey] = dialog + } + + func toggleMute(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.isMuted.toggle() + dialogs[opponentKey] = dialog + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift new file mode 100644 index 0000000..2de9f7a --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Base protocol for all Rosetta binary packets. +protocol Packet { + static var packetId: Int { get } + func write(to stream: Stream) + mutating func read(from stream: Stream) +} + +// MARK: - Packet Registry + +enum PacketRegistry { + + /// All known packet factories, keyed by packet ID. + private static let factories: [Int: () -> any Packet] = [ + 0x00: { PacketHandshake() }, + 0x01: { PacketUserInfo() }, + 0x02: { PacketResult() }, + 0x03: { PacketSearch() }, + 0x05: { PacketOnlineState() }, + 0x06: { PacketMessage() }, + 0x07: { PacketRead() }, + 0x08: { PacketDelivery() }, + 0x0B: { PacketTyping() }, + 0x19: { PacketSync() }, + ] + + /// Deserializes a packet from raw binary data. + static func decode(from data: Data) -> (packetId: Int, packet: any Packet)? { + let stream = Stream(data: data) + let packetId = stream.readInt16() + + guard let factory = factories[packetId] else { + return nil + } + + var packet = factory() + packet.read(from: stream) + return (packetId, packet) + } + + /// Serializes a packet to raw binary data (including the 2-byte packet ID header). + static func encode(_ packet: any Packet) -> Data { + let stream = Stream() + stream.writeInt16(type(of: packet).packetId) + packet.write(to: stream) + return stream.toData() + } +} + +// MARK: - Attachment Types + +enum AttachmentType: Int, Codable { + case image = 0 + case messages = 1 + case file = 2 +} + +struct MessageAttachment: Codable { + var id: String = "" + var preview: String = "" + var blob: String = "" + var type: AttachmentType = .image +} + +// MARK: - Search User + +struct SearchUser { + var username: String = "" + var title: String = "" + var publicKey: String = "" + var verified: Int = 0 + var online: Int = 0 +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift b/Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift new file mode 100644 index 0000000..cdde60b --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Delivery packet (0x08) — delivery confirmation from server. +/// Field order matches TypeScript server: toPublicKey, messageId. +struct PacketDelivery: Packet { + static let packetId = 0x08 + + var toPublicKey: String = "" + var messageId: String = "" + + func write(to stream: Stream) { + stream.writeString(toPublicKey) + stream.writeString(messageId) + } + + mutating func read(from stream: Stream) { + toPublicKey = stream.readString() + messageId = stream.readString() + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift b/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift new file mode 100644 index 0000000..5834bb2 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift @@ -0,0 +1,57 @@ +import Foundation + +// MARK: - HandshakeState + +enum HandshakeState: Int { + case completed = 0 + case needDeviceVerification = 1 + + init(value: Int) { + self = HandshakeState(rawValue: value) ?? .completed + } +} + +// MARK: - HandshakeDevice + +struct HandshakeDevice { + var deviceId: String = "" + var deviceName: String = "" + var deviceOs: String = "" +} + +// MARK: - PacketHandshake (0x00) + +struct PacketHandshake: Packet { + static let packetId = 0x00 + + var privateKey: String = "" // SHA256(privateKey + "rosetta") + var publicKey: String = "" // Compressed secp256k1 public key (hex) + var protocolVersion: Int = 1 + var heartbeatInterval: Int = 15 + var device = HandshakeDevice() + var handshakeState: HandshakeState = .needDeviceVerification + + func write(to stream: Stream) { + stream.writeString(privateKey) + stream.writeString(publicKey) + stream.writeInt8(protocolVersion) + stream.writeInt8(heartbeatInterval) + stream.writeString(device.deviceId) + stream.writeString(device.deviceName) + stream.writeString(device.deviceOs) + stream.writeInt8(handshakeState.rawValue) + } + + mutating func read(from stream: Stream) { + privateKey = stream.readString() + publicKey = stream.readString() + protocolVersion = stream.readInt8() + heartbeatInterval = stream.readInt8() + device = HandshakeDevice( + deviceId: stream.readString(), + deviceName: stream.readString(), + deviceOs: stream.readString() + ) + handshakeState = HandshakeState(value: stream.readInt8()) + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift b/Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift new file mode 100644 index 0000000..2094327 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Message packet (0x06) — sending and receiving encrypted messages. +struct PacketMessage: Packet { + static let packetId = 0x06 + + var fromPublicKey: String = "" + var toPublicKey: String = "" + var content: String = "" // XChaCha20-Poly1305 encrypted (hex) + var chachaKey: String = "" // ECDH-encrypted key+nonce + var timestamp: Int32 = 0 + var privateKey: String = "" // Hash for server auth + var messageId: String = "" + var attachments: [MessageAttachment] = [] + var aesChachaKey: String = "" // ChaCha key+nonce encrypted by sender + + func write(to stream: Stream) { + // Match Android field order exactly + stream.writeString(fromPublicKey) + stream.writeString(toPublicKey) + stream.writeString(content) + stream.writeString(chachaKey) + stream.writeInt32(Int(timestamp)) + stream.writeString(privateKey) + stream.writeString(messageId) + stream.writeInt8(attachments.count) + + for attachment in attachments { + stream.writeString(attachment.id) + stream.writeString(attachment.preview) + stream.writeString(attachment.blob) + stream.writeInt8(attachment.type.rawValue) + } + // No aesChachaKey — Android doesn't send it + } + + mutating func read(from stream: Stream) { + fromPublicKey = stream.readString() + toPublicKey = stream.readString() + content = stream.readString() + chachaKey = stream.readString() + timestamp = Int32(stream.readInt32()) + privateKey = stream.readString() + messageId = stream.readString() + + let attachmentCount = stream.readInt8() + var list: [MessageAttachment] = [] + for _ in 0.. Void)? + var onDeliveryReceived: ((PacketDelivery) -> Void)? + var onReadReceived: ((PacketRead) -> Void)? + var onOnlineStateReceived: ((PacketOnlineState) -> Void)? + var onUserInfoReceived: ((PacketUserInfo) -> Void)? + var onSearchResult: ((PacketSearch) -> Void)? + var onTypingReceived: ((PacketTyping) -> Void)? + var onSyncReceived: ((PacketSync) -> Void)? + var onHandshakeCompleted: ((PacketHandshake) -> Void)? + + // MARK: - Private + + private let client = WebSocketClient() + private var packetQueue: [any Packet] = [] + private var handshakeComplete = false + private var heartbeatTask: Task? + private var handshakeTimeoutTask: Task? + + // Saved credentials for auto-reconnect + private var savedPublicKey: String? + private var savedPrivateHash: String? + + var publicKey: String? { savedPublicKey } + var privateHash: String? { savedPrivateHash } + + private init() { + setupClientCallbacks() + } + + // MARK: - Connection + + /// Connect to server and perform handshake. + func connect(publicKey: String, privateKeyHash: String) { + savedPublicKey = publicKey + savedPrivateHash = privateKeyHash + + if connectionState == .authenticated || connectionState == .handshaking { + Self.logger.info("Already connected/handshaking, skipping") + return + } + + connectionState = .connecting + client.connect() + } + + func disconnect() { + Self.logger.info("Disconnecting") + heartbeatTask?.cancel() + handshakeTimeoutTask?.cancel() + handshakeComplete = false + client.disconnect() + connectionState = .disconnected + savedPublicKey = nil + savedPrivateHash = nil + } + + // MARK: - Sending + + func sendPacket(_ packet: any Packet) { + if !handshakeComplete && !(packet is PacketHandshake) { + Self.logger.info("Queueing packet \(type(of: packet).packetId)") + packetQueue.append(packet) + return + } + sendPacketDirect(packet) + } + + // MARK: - Private Setup + + private func setupClientCallbacks() { + client.onConnected = { [weak self] in + guard let self else { return } + Self.logger.info("WebSocket connected") + + Task { @MainActor in + self.connectionState = .connected + } + + // Auto-handshake with saved credentials + if let pk = savedPublicKey, let hash = savedPrivateHash { + startHandshake(publicKey: pk, privateHash: hash) + } + } + + client.onDisconnected = { [weak self] error in + guard let self else { return } + if let error { + Self.logger.error("Disconnected: \(error.localizedDescription)") + } + heartbeatTask?.cancel() + handshakeComplete = false + + Task { @MainActor in + self.connectionState = .disconnected + } + } + + client.onDataReceived = { [weak self] data in + self?.handleIncomingData(data) + } + } + + // MARK: - Handshake + + private func startHandshake(publicKey: String, privateHash: String) { + Self.logger.info("Starting handshake for \(publicKey.prefix(20))...") + + Task { @MainActor in + connectionState = .handshaking + } + + let device = HandshakeDevice( + deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "unknown", + deviceName: UIDevice.current.name, + deviceOs: "iOS \(UIDevice.current.systemVersion)" + ) + + let handshake = PacketHandshake( + privateKey: privateHash, + publicKey: publicKey, + protocolVersion: 1, + heartbeatInterval: 15, + device: device, + handshakeState: .needDeviceVerification + ) + + sendPacketDirect(handshake) + + // Timeout + handshakeTimeoutTask?.cancel() + handshakeTimeoutTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + if !self.handshakeComplete { + Self.logger.error("Handshake timeout") + self.client.disconnect() + } + } + } + + // MARK: - Packet Handling + + private func handleIncomingData(_ data: Data) { + print("[Protocol] Incoming data: \(data.count) bytes, first bytes: \(data.prefix(min(8, data.count)).map { String(format: "%02x", $0) }.joined(separator: " "))") + + guard let (packetId, packet) = PacketRegistry.decode(from: data) else { + // Try to read the packet ID manually to see what it is + if data.count >= 2 { + let stream = Stream(data: data) + let rawId = stream.readInt16() + print("[Protocol] Unknown packet ID: 0x\(String(rawId, radix: 16)) (\(rawId)), data size: \(data.count)") + } else { + print("[Protocol] Packet too small: \(data.count) bytes") + } + return + } + + print("[Protocol] Received packet 0x\(String(packetId, radix: 16)) (\(type(of: packet)))") + + switch packetId { + case 0x00: + if let p = packet as? PacketHandshake { + handleHandshakeResponse(p) + } + case 0x01: + if let p = packet as? PacketUserInfo { + print("[Protocol] UserInfo received: username='\(p.username)', title='\(p.title)'") + onUserInfoReceived?(p) + } + case 0x02: + if let p = packet as? PacketResult { + let code = ResultCode(rawValue: p.resultCode) + print("[Protocol] Result received: code=\(p.resultCode) (\(code.map { "\($0)" } ?? "unknown"))") + } + case 0x03: + if let p = packet as? PacketSearch { + print("[Protocol] Search result received: \(p.users.count) users") + onSearchResult?(p) + } + case 0x05: + if let p = packet as? PacketOnlineState { + onOnlineStateReceived?(p) + } + case 0x06: + if let p = packet as? PacketMessage { + onMessageReceived?(p) + } + case 0x07: + if let p = packet as? PacketRead { + onReadReceived?(p) + } + case 0x08: + if let p = packet as? PacketDelivery { + onDeliveryReceived?(p) + } + case 0x0B: + if let p = packet as? PacketTyping { + onTypingReceived?(p) + } + case 0x19: + if let p = packet as? PacketSync { + onSyncReceived?(p) + } + default: + break + } + } + + private func handleHandshakeResponse(_ packet: PacketHandshake) { + // Set handshakeComplete BEFORE cancelling timeout to prevent race + handshakeComplete = true + handshakeTimeoutTask?.cancel() + handshakeTimeoutTask = nil + + switch packet.handshakeState { + case .completed: + Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s") + + Task { @MainActor in + self.connectionState = .authenticated + } + + flushPacketQueue() + startHeartbeat(interval: packet.heartbeatInterval) + onHandshakeCompleted?(packet) + + case .needDeviceVerification: + Self.logger.info("Server requires device verification") + startHeartbeat(interval: packet.heartbeatInterval) + } + } + + // MARK: - Heartbeat + + private func startHeartbeat(interval: Int) { + heartbeatTask?.cancel() + let intervalMs = UInt64(interval) * 1_000_000_000 / 3 + + heartbeatTask = Task { + // Send first heartbeat immediately + client.sendText("heartbeat") + + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: intervalMs) + guard !Task.isCancelled else { break } + client.sendText("heartbeat") + } + } + } + + // MARK: - Packet Queue + + private func sendPacketDirect(_ packet: any Packet) { + let data = PacketRegistry.encode(packet) + Self.logger.info("Sending packet 0x\(String(type(of: packet).packetId, radix: 16)) (\(data.count) bytes)") + client.send(data) + } + + private func flushPacketQueue() { + Self.logger.info("Flushing \(self.packetQueue.count) queued packets") + let packets = packetQueue + packetQueue.removeAll() + for packet in packets { + sendPacketDirect(packet) + } + } +} diff --git a/Rosetta/Core/Network/Protocol/Stream.swift b/Rosetta/Core/Network/Protocol/Stream.swift new file mode 100644 index 0000000..a9c78de --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Stream.swift @@ -0,0 +1,176 @@ +import Foundation + +/// Bit-aligned binary stream for protocol packets. +/// Matches the React Native / Android implementation exactly. +final class Stream: @unchecked Sendable { + + private var bytes: [Int] + private var readPointer: Int = 0 + private var writePointer: Int = 0 + + // MARK: - Init + + init() { + bytes = [] + } + + init(data: Data) { + bytes = data.map { Int($0) & 0xFF } + } + + // MARK: - Output + + func toData() -> Data { + Data(bytes.map { UInt8($0 & 0xFF) }) + } + + // MARK: - Bit-Level I/O + + func writeBit(_ value: Int) { + let bit = value & 1 + let byteIndex = writePointer >> 3 + ensureCapacity(byteIndex) + bytes[byteIndex] = bytes[byteIndex] | (bit << (7 - (writePointer & 7))) + writePointer += 1 + } + + func readBit() -> Int { + let byteIndex = readPointer >> 3 + let bit = (bytes[byteIndex] >> (7 - (readPointer & 7))) & 1 + readPointer += 1 + return bit + } + + // MARK: - Bool + + func writeBoolean(_ value: Bool) { + writeBit(value ? 1 : 0) + } + + func readBoolean() -> Bool { + readBit() == 1 + } + + // MARK: - Int8 (9 bits: 1 sign + 8 data) + + func writeInt8(_ value: Int) { + let negationBit = value < 0 ? 1 : 0 + let int8Value = abs(value) & 0xFF + + let byteIndex = writePointer >> 3 + ensureCapacity(byteIndex) + bytes[byteIndex] = bytes[byteIndex] | (negationBit << (7 - (writePointer & 7))) + writePointer += 1 + + for i in 0..<8 { + let bit = (int8Value >> (7 - i)) & 1 + let idx = writePointer >> 3 + ensureCapacity(idx) + bytes[idx] = bytes[idx] | (bit << (7 - (writePointer & 7))) + writePointer += 1 + } + } + + func readInt8() -> Int { + var value = 0 + let negationBit = (bytes[readPointer >> 3] >> (7 - (readPointer & 7))) & 1 + readPointer += 1 + + for i in 0..<8 { + let bit = (bytes[readPointer >> 3] >> (7 - (readPointer & 7))) & 1 + value = value | (bit << (7 - i)) + readPointer += 1 + } + + return negationBit == 1 ? -value : value + } + + // MARK: - Int16 (2 × Int8) + + func writeInt16(_ value: Int) { + writeInt8(value >> 8) + writeInt8(value & 0xFF) + } + + func readInt16() -> Int { + let high = readInt8() << 8 + return high | readInt8() + } + + // MARK: - Int32 (2 × Int16) + + func writeInt32(_ value: Int) { + writeInt16(value >> 16) + writeInt16(value & 0xFFFF) + } + + func readInt32() -> Int { + let high = readInt16() << 16 + return high | readInt16() + } + + // MARK: - Int64 (2 × Int32) + + func writeInt64(_ value: Int64) { + let high = Int((value >> 32) & 0xFFFFFFFF) + let low = Int(value & 0xFFFFFFFF) + writeInt32(high) + writeInt32(low) + } + + func readInt64() -> Int64 { + let high = Int64(readInt32()) + let low = Int64(readInt32()) & 0xFFFFFFFF + return (high << 32) | low + } + + // MARK: - String (Int32 length + UTF-16 code units) + + func writeString(_ value: String) { + writeInt32(value.count) + for char in value { + for scalar in char.utf16 { + writeInt16(Int(scalar)) + } + } + } + + func readString() -> String { + let length = readInt32() + var result = "" + result.reserveCapacity(length) + for _ in 0.. Data { + let length = readInt32() + var result = Data(capacity: length) + for _ in 0..? + private var hasNotifiedConnected = false + + var onConnected: (() -> Void)? + var onDisconnected: ((Error?) -> Void)? + var onDataReceived: ((Data) -> Void)? + + override init() { + super.init() + let config = URLSessionConfiguration.default + config.waitsForConnectivity = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + // MARK: - Connection + + func connect() { + guard webSocketTask == nil else { return } + isManuallyClosed = false + hasNotifiedConnected = false + + Self.logger.info("Connecting to \(self.url.absoluteString)") + + let task = session.webSocketTask(with: url) + webSocketTask = task + task.resume() + + receiveLoop() + } + + func disconnect() { + Self.logger.info("Manual disconnect") + isManuallyClosed = true + reconnectTask?.cancel() + reconnectTask = nil + webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + } + + func send(_ data: Data) { + guard let task = webSocketTask else { + Self.logger.warning("Cannot send: no active connection") + return + } + task.send(.data(data)) { error in + if let error { + Self.logger.error("Send error: \(error.localizedDescription)") + } + } + } + + func sendText(_ text: String) { + guard let task = webSocketTask else { return } + task.send(.string(text)) { error in + if let error { + Self.logger.error("Send text error: \(error.localizedDescription)") + } + } + } + + // MARK: - URLSessionWebSocketDelegate + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + Self.logger.info("WebSocket didOpen") + guard !isManuallyClosed else { return } + hasNotifiedConnected = true + onConnected?() + } + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Self.logger.info("WebSocket didClose: \(closeCode.rawValue)") + handleDisconnect(error: nil) + } + + // MARK: - Receive Loop + + private func receiveLoop() { + guard let task = webSocketTask else { return } + + task.receive { [weak self] result in + guard let self, !isManuallyClosed else { return } + + switch result { + case .success(let message): + switch message { + case .data(let data): + self.onDataReceived?(data) + case .string(let text): + Self.logger.debug("Received text: \(text)") + @unknown default: + break + } + self.receiveLoop() + + case .failure(let error): + Self.logger.error("Receive error: \(error.localizedDescription)") + self.handleDisconnect(error: error) + } + } + } + + // MARK: - Reconnection + + private func handleDisconnect(error: Error?) { + webSocketTask = nil + onDisconnected?(error) + + guard !isManuallyClosed else { return } + + reconnectTask?.cancel() + reconnectTask = Task { [weak self] in + Self.logger.info("Reconnecting in 5 seconds...") + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, !isManuallyClosed, !Task.isCancelled else { return } + self.connect() + } + } +} diff --git a/Rosetta/Core/Services/AccountManager.swift b/Rosetta/Core/Services/AccountManager.swift new file mode 100644 index 0000000..efe7f59 --- /dev/null +++ b/Rosetta/Core/Services/AccountManager.swift @@ -0,0 +1,129 @@ +import Foundation +import Observation +import os + +// MARK: - AccountManager + +/// Manages the current user account: creation, import, persistence, and retrieval. +/// Persists encrypted account data to the iOS Keychain. +@Observable +@MainActor +final class AccountManager { + + static let shared = AccountManager() + + private(set) var currentAccount: Account? + + private let crypto = CryptoManager.shared + private let keychain = KeychainManager.shared + + private init() { + currentAccount = loadCachedAccount() + } + + // MARK: - Account Creation + + /// Creates a new account from a BIP39 mnemonic and password. + /// Derives secp256k1 key pair, encrypts private key and seed phrase, saves to Keychain. + func createAccount(seedPhrase: [String], password: String) async throws -> Account { + let account = try await Task.detached(priority: .userInitiated) { [crypto] in + let (privateKey, publicKey) = try crypto.deriveKeyPair(from: seedPhrase) + + let privateKeyEncrypted = try crypto.encryptWithPassword(privateKey, password: password) + let seedEncrypted = try crypto.encryptWithPassword( + Data(seedPhrase.joined(separator: " ").utf8), + password: password + ) + + return Account( + publicKey: publicKey.hexString, + privateKeyEncrypted: privateKeyEncrypted, + seedPhraseEncrypted: seedEncrypted + ) + }.value + + try saveAccount(account) + currentAccount = account + return account + } + + // MARK: - Account Import + + /// Imports an existing account from a BIP39 mnemonic and new password. + func importAccount(seedPhrase: [String], password: String) async throws -> Account { + // Import uses the same derivation as create — just different UI flow + try await createAccount(seedPhrase: seedPhrase, password: password) + } + + // MARK: - Account Unlock + + /// Decrypts and verifies the stored account with the given password. + /// Returns `true` if the password is correct. + func unlock(password: String) async throws -> Bool { + guard let account = currentAccount else { return false } + _ = try await Task.detached(priority: .userInitiated) { [crypto] in + try crypto.decryptWithPassword(account.privateKeyEncrypted, password: password) + }.value + return true + } + + /// Decrypts the private key and returns the raw hex string. + /// Used to derive the handshake hash for server authentication. + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager") + + func decryptPrivateKey(password: String) async throws -> String { + guard let account = currentAccount else { + Self.logger.error("No account found for decryption") + throw CryptoError.decryptionFailed + } + let expectedPublicKey = account.publicKey + Self.logger.info("Attempting to decrypt private key, expected pubkey: \(expectedPublicKey.prefix(20))...") + let privateKeyData = try await Task.detached(priority: .userInitiated) { [crypto] in + let data = try crypto.decryptWithPassword(account.privateKeyEncrypted, password: password) + Self.logger.info("Decrypted data length: \(data.count) bytes") + guard data.count == 32 else { + Self.logger.error("Wrong password: decrypted data is \(data.count) bytes, expected 32") + throw CryptoError.decryptionFailed + } + let derivedPublicKey = try crypto.deriveCompressedPublicKey(from: data) + Self.logger.info("Derived pubkey: \(derivedPublicKey.hexString.prefix(20))...") + guard derivedPublicKey.hexString == expectedPublicKey else { + Self.logger.error("Wrong password: derived pubkey doesn't match stored pubkey") + throw CryptoError.decryptionFailed + } + Self.logger.info("Password validation passed") + return data + }.value + return privateKeyData.hexString + } + + /// Updates the display name and username on the current account. + func updateProfile(displayName: String?, username: String?) { + guard var account = currentAccount else { return } + if let displayName { account.displayName = displayName } + if let username { account.username = username } + currentAccount = account + try? saveAccount(account) + } + + // MARK: - Persistence + + func saveAccount(_ account: Account) throws { + try keychain.saveCodable(account, forKey: Account.KeychainKey.account) + } + + func deleteAccount() throws { + try keychain.delete(forKey: Account.KeychainKey.account) + currentAccount = nil + } + + var hasAccount: Bool { + keychain.contains(key: Account.KeychainKey.account) + } + + // MARK: - Private + + private func loadCachedAccount() -> Account? { + try? keychain.loadCodable(Account.self, forKey: Account.KeychainKey.account) + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift new file mode 100644 index 0000000..c077918 --- /dev/null +++ b/Rosetta/Core/Services/SessionManager.swift @@ -0,0 +1,221 @@ +import Foundation +import Observation +import os + +/// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle. +@Observable +@MainActor +final class SessionManager { + + static let shared = SessionManager() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session") + + private(set) var isAuthenticated = false + private(set) var currentPublicKey: String = "" + private(set) var displayName: String = "" + private(set) var username: String = "" + + /// Hex-encoded private key hash, kept in memory for the session duration. + private(set) var privateKeyHash: String? + /// Hex-encoded raw private key, kept in memory for message decryption. + private(set) var privateKeyHex: String? + + private init() { + setupProtocolCallbacks() + } + + // MARK: - Session Lifecycle + + /// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake. + func startSession(password: String) async throws { + let accountManager = AccountManager.shared + let crypto = CryptoManager.shared + + // Decrypt private key + let privateKeyHex = try await accountManager.decryptPrivateKey(password: password) + self.privateKeyHex = privateKeyHex + Self.logger.info("Private key decrypted") + + guard let account = accountManager.currentAccount else { + throw CryptoError.decryptionFailed + } + + currentPublicKey = account.publicKey + displayName = account.displayName ?? "" + username = account.username ?? "" + + // Generate private key hash for handshake + let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) + privateKeyHash = hash + + Self.logger.info("Connecting to server...") + + // Connect + handshake + ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash) + + isAuthenticated = true + } + + // MARK: - Message Sending + + /// Sends an encrypted message to a recipient, matching Android's outgoing flow. + func sendMessage(text: String, toPublicKey: String) async throws { + guard let privKey = privateKeyHex, let hash = privateKeyHash else { + throw CryptoError.decryptionFailed + } + + let messageId = UUID().uuidString + let timestamp = Int32(Date().timeIntervalSince1970) + + // Encrypt the message + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: text, + recipientPublicKeyHex: toPublicKey, + senderPrivateKeyHex: privKey + ) + + // Build packet + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = toPublicKey + packet.content = encrypted.content + packet.chachaKey = encrypted.chachaKey + packet.timestamp = timestamp + packet.privateKey = hash + packet.messageId = messageId + + // Optimistic UI update — show message immediately as "waiting" + DialogRepository.shared.updateFromMessage( + packet, myPublicKey: currentPublicKey, decryptedText: text + ) + + // Send via WebSocket + ProtocolManager.shared.sendPacket(packet) + } + + /// Ends the session and disconnects. + func endSession() { + ProtocolManager.shared.disconnect() + privateKeyHash = nil + privateKeyHex = nil + isAuthenticated = false + currentPublicKey = "" + displayName = "" + username = "" + } + + // MARK: - Protocol Callbacks + + private func setupProtocolCallbacks() { + let proto = ProtocolManager.shared + + proto.onMessageReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + let myKey = self.currentPublicKey + var text: String + + if let privKey = self.privateKeyHex, !packet.content.isEmpty, !packet.chachaKey.isEmpty { + do { + text = try MessageCrypto.decryptIncoming( + ciphertext: packet.content, + encryptedKey: packet.chachaKey, + myPrivateKeyHex: privKey + ) + } catch { + Self.logger.error("Message decryption failed: \(error.localizedDescription)") + text = "(decryption failed)" + } + } else { + text = packet.content.isEmpty ? "" : "(encrypted)" + } + + DialogRepository.shared.updateFromMessage( + packet, myPublicKey: myKey, decryptedText: text + ) + + // Request user info for unknown opponents + let fromMe = packet.fromPublicKey == myKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let dialog = DialogRepository.shared.dialogs[opponentKey] + if dialog?.opponentTitle.isEmpty == true, let hash = self.privateKeyHash { + var searchPacket = PacketSearch() + searchPacket.privateKey = hash + searchPacket.search = opponentKey + ProtocolManager.shared.sendPacket(searchPacket) + } + } + } + + proto.onDeliveryReceived = { packet in + Task { @MainActor in + DialogRepository.shared.updateDeliveryStatus( + messageId: packet.messageId, + opponentKey: packet.toPublicKey, + status: .delivered + ) + } + } + + proto.onReadReceived = { packet in + Task { @MainActor in + DialogRepository.shared.markAsRead(opponentKey: packet.toPublicKey) + } + } + + proto.onOnlineStateReceived = { packet in + Task { @MainActor in + for entry in packet.entries { + DialogRepository.shared.updateOnlineState( + publicKey: entry.publicKey, + isOnline: entry.isOnline + ) + } + } + } + + proto.onUserInfoReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + print("[Session] UserInfo received: username='\(packet.username)', title='\(packet.title)'") + if !packet.title.isEmpty { + self.displayName = packet.title + AccountManager.shared.updateProfile(displayName: packet.title, username: nil) + } + if !packet.username.isEmpty { + self.username = packet.username + AccountManager.shared.updateProfile(displayName: nil, username: packet.username) + } + } + } + + // Note: onSearchResult is set by ChatListViewModel for user search. + // SessionManager does NOT override it — the ViewModel handles both + // displaying results and updating dialog user info. + + proto.onHandshakeCompleted = { [weak self] _ in + guard let self else { return } + Task { @MainActor in + Self.logger.info("Handshake completed") + + guard let hash = self.privateKeyHash else { return } + + // Only send UserInfo if we have profile data to update + let name = self.displayName + let uname = self.username + if !name.isEmpty || !uname.isEmpty { + var userInfoPacket = PacketUserInfo() + userInfoPacket.username = uname + userInfoPacket.avatar = "" + userInfoPacket.title = name + userInfoPacket.privateKey = hash + print("[Session] Sending UserInfo: username='\(uname)', title='\(name)'") + ProtocolManager.shared.sendPacket(userInfoPacket) + } else { + print("[Session] Skipping UserInfo — no profile data to send") + } + } + } + } +} diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 8f47633..d95eb71 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit // MARK: - Rosetta Color Tokens @@ -33,15 +34,22 @@ enum RosettaColors { static let subtleBorder = Color.white.opacity(0.15) static let cardFill = Color.white.opacity(0.06) + // MARK: Chat-Specific (from Figma) + + /// Figma subtitle/time/icon color in light mode: #3C3C43 at ~60% opacity + static let chatSubtitle = Color(hex: 0x3C3C43).opacity(0.6) + /// Figma accent blue used for badges, delivery status: #008BFF + static let figmaBlue = Color(hex: 0x008BFF) + // MARK: Light Theme enum Light { static let background = Color.white - static let backgroundSecondary = Color(hex: 0xF2F3F5) + static let backgroundSecondary = Color(hex: 0xF2F2F7) // iOS system grouped bg static let surface = Color(hex: 0xF5F5F5) static let text = Color.black - static let textSecondary = Color(hex: 0x666666) - static let textTertiary = Color(hex: 0x999999) + static let textSecondary = Color(hex: 0x3C3C43).opacity(0.6) // Figma subtitle gray + static let textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray static let border = Color(hex: 0xE0E0E0) static let divider = Color(hex: 0xEEEEEE) static let messageBubble = Color(hex: 0xF5F5F5) @@ -65,6 +73,30 @@ enum RosettaColors { static let inputBackground = Color(hex: 0x2A2A2A) } + // MARK: Adaptive Colors (light/dark based on system appearance) + + static func adaptive(light: Color, dark: Color) -> Color { + Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(dark) + : UIColor(light) + }) + } + + enum Adaptive { + static let background = RosettaColors.adaptive(light: RosettaColors.Light.background, dark: RosettaColors.Dark.background) + static let backgroundSecondary = RosettaColors.adaptive(light: RosettaColors.Light.backgroundSecondary, dark: RosettaColors.Dark.backgroundSecondary) + static let surface = RosettaColors.adaptive(light: RosettaColors.Light.surface, dark: RosettaColors.Dark.surface) + static let text = RosettaColors.adaptive(light: RosettaColors.Light.text, dark: RosettaColors.Dark.text) + static let textSecondary = RosettaColors.adaptive(light: RosettaColors.Light.textSecondary, dark: RosettaColors.Dark.textSecondary) + static let textTertiary = RosettaColors.adaptive(light: RosettaColors.Light.textTertiary, dark: RosettaColors.Dark.textTertiary) + static let border = RosettaColors.adaptive(light: RosettaColors.Light.border, dark: RosettaColors.Dark.border) + static let divider = RosettaColors.adaptive(light: RosettaColors.Light.divider, dark: RosettaColors.Dark.divider) + static let messageBubble = RosettaColors.adaptive(light: RosettaColors.Light.messageBubble, dark: RosettaColors.Dark.messageBubble) + static let messageBubbleOwn = RosettaColors.adaptive(light: RosettaColors.Light.messageBubbleOwn, dark: RosettaColors.Dark.messageBubbleOwn) + static let inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground) + } + // MARK: Seed Word Colors (12 unique, matching Android) static let seedWordColors: [Color] = [ @@ -82,18 +114,52 @@ enum RosettaColors { Color(hex: 0xF7DC6F), ] - // MARK: Avatar Palette + // MARK: Avatar Palette (11 colors, matching rosetta-android dark theme) static let avatarColors: [(background: Color, text: Color)] = [ - (Color(hex: 0xFF6B6B), .white), - (Color(hex: 0x4ECDC4), .white), - (Color(hex: 0x45B7D1), .white), - (Color(hex: 0xF7B731), .white), - (Color(hex: 0x5F27CD), .white), - (Color(hex: 0x00D2D3), .white), - (Color(hex: 0xFF9FF3), .white), - (Color(hex: 0x54A0FF), .white), + (Color(hex: 0x2D3548), Color(hex: 0x7DD3FC)), // blue + (Color(hex: 0x2D4248), Color(hex: 0x67E8F9)), // cyan + (Color(hex: 0x39334C), Color(hex: 0xD8B4FE)), // grape + (Color(hex: 0x2D3F32), Color(hex: 0x86EFAC)), // green + (Color(hex: 0x333448), Color(hex: 0xA5B4FC)), // indigo + (Color(hex: 0x383F2D), Color(hex: 0xBEF264)), // lime + (Color(hex: 0x483529), Color(hex: 0xFDBA74)), // orange + (Color(hex: 0x482D3D), Color(hex: 0xF9A8D4)), // pink + (Color(hex: 0x482D2D), Color(hex: 0xFCA5A5)), // red + (Color(hex: 0x2D4340), Color(hex: 0x5EEAD4)), // teal + (Color(hex: 0x3A334C), Color(hex: 0xC4B5FD)), // violet ] + + static func avatarColorIndex(for key: String) -> Int { + var hash: Int32 = 0 + for char in key.unicodeScalars { + hash = hash &* 31 &+ Int32(truncatingIfNeeded: char.value) + } + let count = Int32(avatarColors.count) + var index = hash % count + if index < 0 { index += count } + return Int(index) + } + + static func avatarText(publicKey: String) -> String { + String(publicKey.prefix(2)).uppercased() + } + + static func initials(name: String, publicKey: String) -> String { + let words = name.trimmingCharacters(in: .whitespaces) + .split(whereSeparator: { $0.isWhitespace }) + .filter { !$0.isEmpty } + switch words.count { + case 0: + return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased() + case 1: + return String(words[0].prefix(2)).uppercased() + default: + let first = words[0].first.map(String.init) ?? "" + let second = words[1].first.map(String.init) ?? "" + return (first + second).uppercased() + } + } } // MARK: - Color Hex Initializer diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift new file mode 100644 index 0000000..e31eec6 --- /dev/null +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +// MARK: - AvatarView + +struct AvatarView: View { + let initials: String + let colorIndex: Int + let size: CGFloat + var isOnline: Bool = false + var isSavedMessages: Bool = false + + private var backgroundColor: Color { + RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].background + } + + private var textColor: Color { + RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].text + } + + private var fontSize: CGFloat { size * 0.38 } + private var badgeSize: CGFloat { size * 0.31 } + + var body: some View { + ZStack { + Circle() + .fill(isSavedMessages ? RosettaColors.primaryBlue : backgroundColor) + + if isSavedMessages { + Image(systemName: "bookmark.fill") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(.white) + } else { + Text(initials) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(textColor) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + } + .frame(width: size, height: size) + .overlay(alignment: .bottomTrailing) { + if isOnline { + Circle() + .fill(RosettaColors.online) + .frame(width: badgeSize, height: badgeSize) + .overlay { + Circle() + .stroke(RosettaColors.Adaptive.background, lineWidth: 2) + } + .offset(x: 1, y: 1) + } + } + .accessibilityLabel(isSavedMessages ? "Saved Messages" : initials) + .accessibilityAddTraits(isOnline ? [.isStaticText] : []) + } +} + +// MARK: - Preview + +#Preview { + HStack(spacing: 16) { + AvatarView(initials: "AJ", colorIndex: 0, size: 56, isOnline: true) + AvatarView(initials: "BS", colorIndex: 2, size: 56, isOnline: false) + AvatarView(initials: "S", colorIndex: 4, size: 56, isSavedMessages: true) + AvatarView(initials: "CD", colorIndex: 6, size: 40, isOnline: true) + } + .padding() + .background(RosettaColors.Adaptive.background) +} diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index b797d2e..3265eb9 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -23,10 +23,16 @@ struct GlassCard: View { content() .background { RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color.white.opacity(fillOpacity)) + .fill(RosettaColors.adaptive( + light: Color.black.opacity(fillOpacity), + dark: Color.white.opacity(fillOpacity) + )) .overlay { RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + .stroke(RosettaColors.adaptive( + light: Color.black.opacity(0.06), + dark: Color.white.opacity(0.08) + ), lineWidth: 0.5) } } } diff --git a/Rosetta/DesignSystem/Components/LottieView.swift b/Rosetta/DesignSystem/Components/LottieView.swift index 51952a6..ac1166c 100644 --- a/Rosetta/DesignSystem/Components/LottieView.swift +++ b/Rosetta/DesignSystem/Components/LottieView.swift @@ -1,14 +1,57 @@ import SwiftUI import Lottie -struct LottieView: UIViewRepresentable { +// MARK: - Animation Cache + +final class LottieAnimationCache { + static let shared = LottieAnimationCache() + private var cache: [String: LottieAnimation] = [:] + private let lock = NSLock() + + private init() {} + + func animation(named name: String) -> LottieAnimation? { + lock.lock() + defer { lock.unlock() } + if let cached = cache[name] { + return cached + } + if let animation = LottieAnimation.named(name) { + cache[name] = animation + return animation + } + return nil + } + + func preload(_ names: [String]) { + for name in names { + _ = animation(named: name) + } + } +} + +// MARK: - LottieView + +struct LottieView: UIViewRepresentable, Equatable { let animationName: String var loopMode: LottieLoopMode = .playOnce var animationSpeed: CGFloat = 1.5 var isPlaying: Bool = true + static func == (lhs: LottieView, rhs: LottieView) -> Bool { + lhs.animationName == rhs.animationName && + lhs.loopMode == rhs.loopMode && + lhs.animationSpeed == rhs.animationSpeed && + lhs.isPlaying == rhs.isPlaying + } + func makeUIView(context: Context) -> LottieAnimationView { - let animationView = LottieAnimationView(name: animationName) + let animationView: LottieAnimationView + if let cached = LottieAnimationCache.shared.animation(named: animationName) { + animationView = LottieAnimationView(animation: cached) + } else { + animationView = LottieAnimationView(name: animationName) + } animationView.contentMode = .scaleAspectFit animationView.loopMode = loopMode animationView.animationSpeed = animationSpeed @@ -21,13 +64,14 @@ struct LottieView: UIViewRepresentable { } func updateUIView(_ uiView: LottieAnimationView, context: Context) { - uiView.loopMode = loopMode - uiView.animationSpeed = animationSpeed - - if isPlaying && !uiView.isAnimationPlaying { - uiView.play() - } else if !isPlaying { - uiView.stop() + if isPlaying { + if !uiView.isAnimationPlaying { + uiView.play() + } + } else { + if uiView.isAnimationPlaying { + uiView.stop() + } } } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift new file mode 100644 index 0000000..da34410 --- /dev/null +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -0,0 +1,234 @@ +import SwiftUI +import UIKit + +// MARK: - Tab + +enum RosettaTab: CaseIterable { + case chats + case settings + case search + + var label: String { + switch self { + case .chats: return "Chats" + case .settings: return "Settings" + case .search: return "" + } + } + + var icon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right" + case .settings: return "gearshape" + case .search: return "magnifyingglass" + } + } + + var selectedIcon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right.fill" + case .settings: return "gearshape.fill" + case .search: return "magnifyingglass" + } + } +} + +// MARK: - Tab Badge + +struct TabBadge { + let tab: RosettaTab + let text: String +} + +// MARK: - RosettaTabBar +/// Figma spec: +/// Container: padding(25h, 16t, 25b), gap=8 +/// Main pill: 282x62, r=296, padding(4h, 3v), glass+shadow +/// Each tab: 99x56, icon 30pt, label 10pt +/// Selected: #EDEDED rect r=100, icon+label #008BFF, label bold +/// Unselected: icon+label #404040 +/// Search pill: 62x62, glass+shadow, icon 17pt #404040 + +struct RosettaTabBar: View { + let selectedTab: RosettaTab + var onTabSelected: ((RosettaTab) -> Void)? + var badges: [TabBadge] = [] + + var body: some View { + HStack(spacing: 8) { + mainTabsPill + searchPill + } + .padding(.horizontal, 25) + .padding(.top, 16) + .padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25) + } +} + +// MARK: - Main Tabs Pill + +private extension RosettaTabBar { + var mainTabsPill: some View { + HStack(spacing: 0) { + ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in + tabItem(tab) + } + } + .padding(.horizontal, 4) + .padding(.top, 3) + .padding(.bottom, 3) + .frame(height: 62) + .applyGlassPill() + } + + func tabItem(_ tab: RosettaTab) -> some View { + let isSelected = tab == selectedTab + let badgeText = badges.first(where: { $0.tab == tab })?.text + + return Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + onTabSelected?(tab) + } + } label: { + VStack(spacing: 1) { + ZStack(alignment: .topTrailing) { + Image(systemName: isSelected ? tab.selectedIcon : tab.icon) + .font(.system(size: 22)) + .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + .frame(height: 30) + + if let badgeText { + badgeView(badgeText) + } + } + + Text(tab.label) + .font(.system(size: 10, weight: isSelected ? .bold : .medium)) + .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + } + .padding(.horizontal, 8) + .padding(.top, 6) + .padding(.bottom, 7) + .frame(maxWidth: .infinity) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 100) + .fill(RosettaColors.adaptive( + light: Color(hex: 0xEDEDED), + dark: Color.white.opacity(0.12) + )) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(tab.label) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - Search Pill + +private extension RosettaTabBar { + var searchPill: some View { + let isSelected = selectedTab == .search + + return Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + onTabSelected?(.search) + } + } label: { + Image(systemName: isSelected ? "magnifyingglass" : "magnifyingglass") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + .frame(width: 54, height: 54) + .background { + if isSelected { + Circle() + .fill(RosettaColors.adaptive( + light: Color(hex: 0xEDEDED), + dark: Color.white.opacity(0.12) + )) + } + } + } + .buttonStyle(.plain) + .padding(4) + .frame(width: 62, height: 62) + .applyGlassPill() + .accessibilityLabel("Search") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - Glass Pill + +private struct GlassPillModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .glassEffect(.regular, in: .capsule) + } else { + content + .background( + Capsule() + .fill(RosettaColors.adaptive( + light: Color.white.opacity(0.65), + dark: Color(hex: 0x2A2A2A).opacity(0.8) + )) + .shadow(color: RosettaColors.adaptive( + light: Color(hex: 0xDDDDDD).opacity(0.5), + dark: Color.black.opacity(0.3) + ), radius: 16, y: 4) + ) + } + } +} + +private extension View { + func applyGlassPill() -> some View { + modifier(GlassPillModifier()) + } +} + +// MARK: - Helpers + +private extension RosettaTabBar { + func badgeView(_ text: String) -> some View { + Text(text) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, text.count > 2 ? 4 : 0) + .frame(minWidth: 18, minHeight: 18) + .background(Capsule().fill(RosettaColors.error)) + .offset(x: 10, y: -4) + } + + var safeAreaBottom: CGFloat { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first(where: \.isKeyWindow) else { return 0 } + return window.safeAreaInsets.bottom + } +} + +// MARK: - Preview + +#Preview { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background.ignoresSafeArea() + + VStack { + Spacer() + Text("Content here") + .foregroundStyle(RosettaColors.Adaptive.text) + Spacer() + } + + RosettaTabBar( + selectedTab: .chats, + badges: [ + TabBadge(tab: .chats, text: "7"), + ] + ) + } +} diff --git a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index fea6c35..b9ea0a0 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -280,7 +280,8 @@ private extension ConfirmSeedPhraseView { #Preview { ConfirmSeedPhraseView( - seedPhrase: SeedPhraseGenerator.generate(), + seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident"], onConfirmed: {}, onBack: {} ) diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index 3c0023b..42e2a28 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -194,8 +194,12 @@ private extension ImportSeedPhraseView { showError("Please fill in all words") return } - // TODO: Validate seed phrase with CryptoManager.validateSeedPhrase() - seedPhrase = importedWords + let normalized = importedWords.map { $0.lowercased().trimmingCharacters(in: .whitespaces) } + guard CryptoManager.shared.validateMnemonic(normalized) else { + showError("Invalid recovery phrase. Check each word for typos.") + return + } + seedPhrase = normalized onContinue() } label: { Text("Continue") diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index 24c5dc8..77ccfb3 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -174,31 +174,16 @@ private extension SeedPhraseView { isContentVisible = true return } - // TODO: Replace with real BIP39 generation from CryptoManager - seedPhrase = SeedPhraseGenerator.generate() + do { + seedPhrase = try CryptoManager.shared.generateMnemonic() + } catch { + // Entropy failure is extremely rare; show empty state rather than crash + seedPhrase = [] + } withAnimation { isContentVisible = true } } } -// MARK: - Placeholder BIP39 Generator - -enum SeedPhraseGenerator { - private static let wordList = [ - "abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident", - "account", "accuse", "achieve", "acid", "acoustic", "acquire", - "across", "act", "action", "actor", "actress", "actual", - "adapt", "add", "addict", "address", "adjust", "admit", - "adult", "advance", "advice", "aerobic", "affair", "afford", - "afraid", "again", "age", "agent", "agree", "ahead", - "aim", "air", "airport", "aisle", "alarm", "album", - ] - - static func generate() -> [String] { - (0..<12).map { _ in wordList.randomElement() ?? "abandon" } - } -} - #Preview { SeedPhraseView(seedPhrase: .constant([]), onContinue: {}, onBack: {}) .preferredColorScheme(.dark) diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 85644b8..b052e13 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -11,6 +11,7 @@ struct SetPasswordView: View { @State private var showPassword = false @State private var showConfirmPassword = false @State private var isCreating = false + @State private var errorMessage: String? @FocusState private var focusedField: Field? fileprivate enum Field { @@ -46,6 +47,14 @@ struct SetPasswordView: View { WeakPasswordWarning(password: password) infoCard + + if let message = errorMessage { + Text(message) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.error) + .multilineTextAlignment(.center) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } } .padding(.horizontal, 24) .padding(.top, 8) @@ -219,23 +228,28 @@ private extension SetPasswordView { guard canCreate else { return } isCreating = true - // TODO: Implement real account creation: - // 1. CryptoManager.generateKeyPairFromSeed(seedPhrase) - // 2. CryptoManager.encryptWithPassword(privateKey, password) - // 3. CryptoManager.encryptWithPassword(seedPhrase.joined(separator: " "), password) - // 4. Save EncryptedAccount to persistence - // 5. Authenticate with server via Protocol handshake - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - isCreating = false - onAccountCreated() + Task { + do { + _ = try await AccountManager.shared.createAccount( + seedPhrase: seedPhrase, + password: password + ) + // Start session (WebSocket + handshake) immediately after account creation + try await SessionManager.shared.startSession(password: password) + isCreating = false + onAccountCreated() + } catch { + isCreating = false + errorMessage = error.localizedDescription + } } } } #Preview { SetPasswordView( - seedPhrase: SeedPhraseGenerator.generate(), + seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident"], isImportMode: false, onAccountCreated: {}, onBack: {} diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift new file mode 100644 index 0000000..f672a4c --- /dev/null +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +/// Password unlock screen matching rosetta-android design with liquid glass styling. +struct UnlockView: View { + let onUnlocked: () -> Void + + @State private var password = "" + @State private var isUnlocking = false + @State private var errorMessage: String? + @State private var showPassword = false + + // Staggered fade-in animation + @State private var showAvatar = false + @State private var showTitle = false + @State private var showSubtitle = false + @State private var showInput = false + @State private var showButton = false + @State private var showFooter = false + + private var account: Account? { AccountManager.shared.currentAccount } + + private var publicKey: String { + account?.publicKey ?? "" + } + + /// First 2 chars of public key, uppercased — matching Android's `getAvatarText()`. + private var avatarText: String { + RosettaColors.avatarText(publicKey: publicKey) + } + + /// Color index using Java-compatible hashCode — matching Android's `getAvatarColor()`. + private var avatarColorIndex: Int { + RosettaColors.avatarColorIndex(for: publicKey) + } + + /// Display name, or first 20 chars of public key if no name set. + private var displayName: String { + let name = account?.displayName ?? "" + if name.isEmpty { + return publicKey.isEmpty ? "Rosetta" : String(publicKey.prefix(20)) + "..." + } + return name + } + + /// Truncated public key for subtitle. + private var publicKeyPreview: String { + guard publicKey.count > 20 else { return publicKey } + return String(publicKey.prefix(20)) + "..." + } + + var body: some View { + ZStack { + RosettaColors.authBackground + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + Spacer().frame(height: 80) + + // Avatar + AvatarView( + initials: avatarText, + colorIndex: avatarColorIndex, + size: 100, + isSavedMessages: false + ) + .opacity(showAvatar ? 1 : 0) + .scaleEffect(showAvatar ? 1 : 0.8) + + Spacer().frame(height: 20) + + // Display name + Text(displayName) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.white) + .opacity(showTitle ? 1 : 0) + .offset(y: showTitle ? 0 : 8) + + // Public key preview (below name) + if !(account?.displayName ?? "").isEmpty { + Text(publicKeyPreview) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.secondaryText) + .padding(.top, 4) + .opacity(showTitle ? 1 : 0) + .offset(y: showTitle ? 0 : 8) + } + + Spacer().frame(height: 8) + + // Subtitle + Text("Enter password to unlock") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.secondaryText) + .opacity(showSubtitle ? 1 : 0) + .offset(y: showSubtitle ? 0 : 8) + + Spacer().frame(height: 40) + + // Password input — glass card + VStack(alignment: .leading, spacing: 8) { + GlassCard(cornerRadius: 14, fillOpacity: 0.08) { + HStack(spacing: 12) { + Group { + if showPassword { + TextField("Password", text: $password) + } else { + SecureField("Password", text: $password) + } + } + .font(.system(size: 16)) + .foregroundStyle(.white) + .textContentType(.password) + .submitLabel(.done) + .onSubmit { unlock() } + + Button { + showPassword.toggle() + } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + .font(.system(size: 18)) + .foregroundStyle(Color(white: 0.45)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke( + errorMessage != nil ? RosettaColors.error : Color.clear, + lineWidth: 1 + ) + } + + if let error = errorMessage { + Text(error) + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.error) + .padding(.leading, 4) + .transition(.opacity) + } + } + .padding(.horizontal, 24) + .opacity(showInput ? 1 : 0) + .offset(y: showInput ? 0 : 12) + + Spacer().frame(height: 24) + + // Unlock button + Button(action: unlock) { + HStack(spacing: 10) { + if isUnlocking { + ProgressView() + .tint(.white) + .scaleEffect(0.9) + } else { + Image(systemName: "lock.open.fill") + .font(.system(size: 16)) + Text("Unlock") + .font(.system(size: 16, weight: .semibold)) + } + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(password.isEmpty ? RosettaColors.primaryBlue.opacity(0.4) : RosettaColors.primaryBlue) + ) + } + .disabled(password.isEmpty || isUnlocking) + .padding(.horizontal, 24) + .opacity(showButton ? 1 : 0) + .offset(y: showButton ? 0 : 12) + + Spacer().frame(height: 40) + + // Footer — "or" divider + secondary actions + VStack(spacing: 16) { + HStack(spacing: 12) { + Rectangle() + .fill(RosettaColors.secondaryText.opacity(0.3)) + .frame(height: 0.5) + Text("or") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.secondaryText) + Rectangle() + .fill(RosettaColors.secondaryText.opacity(0.3)) + .frame(height: 0.5) + } + .padding(.horizontal, 40) + + Button { + // TODO: Recover account flow + } label: { + HStack(spacing: 8) { + Image(systemName: "key.fill") + .font(.system(size: 14)) + Text("Recover account") + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(RosettaColors.primaryBlue) + } + + Button { + // TODO: Create new account flow + } label: { + HStack(spacing: 8) { + Image(systemName: "person.badge.plus") + .font(.system(size: 14)) + Text("Create new account") + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(RosettaColors.secondaryText) + } + } + .opacity(showFooter ? 1 : 0) + + Spacer().frame(height: 40) + } + } + .scrollDismissesKeyboard(.interactively) + } + .onAppear { startAnimations() } + } + + // MARK: - Actions + + private func unlock() { + guard !password.isEmpty, !isUnlocking else { return } + isUnlocking = true + errorMessage = nil + + Task { + do { + try await SessionManager.shared.startSession(password: password) + onUnlocked() + } catch { + withAnimation(.easeInOut(duration: 0.2)) { + errorMessage = "Wrong password. Please try again." + } + isUnlocking = false + } + } + } + + private func startAnimations() { + withAnimation(.easeOut(duration: 0.3)) { showAvatar = true } + withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true } + withAnimation(.easeOut(duration: 0.3).delay(0.12)) { showSubtitle = true } + withAnimation(.easeOut(duration: 0.3).delay(0.16)) { showInput = true } + withAnimation(.easeOut(duration: 0.3).delay(0.20)) { showButton = true } + withAnimation(.easeOut(duration: 0.3).delay(0.24)) { showFooter = true } + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift new file mode 100644 index 0000000..2ea9825 --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -0,0 +1,383 @@ +import SwiftUI + +// MARK: - ChatListView + +struct ChatListView: View { + @State private var viewModel = ChatListViewModel() + @State private var searchText = "" + @State private var isSearchPresented = false + + var body: some View { + NavigationStack { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + chatContent + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .toolbarBackground(.visible, for: .navigationBar) + .applyGlassNavBar() + .searchable( + text: $searchText, + isPresented: $isSearchPresented, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search" + ) + .onChange(of: searchText) { _, newValue in + viewModel.setSearchQuery(newValue) + } + } + .tint(RosettaColors.figmaBlue) + } +} + +// MARK: - Glass Nav Bar Modifier + +private struct GlassNavBarModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + } else { + content + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + } + } +} + +private extension View { + func applyGlassNavBar() -> some View { + modifier(GlassNavBarModifier()) + } +} + +// MARK: - Chat Content + +private extension ChatListView { + var chatContent: some View { + List { + if viewModel.isLoading { + ForEach(0..<8, id: \.self) { _ in + ChatRowShimmerView() + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults { + emptyState + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + // Local dialog results + if !viewModel.pinnedDialogs.isEmpty { + pinnedSection + } + + ForEach(viewModel.unpinnedDialogs) { dialog in + chatRow(dialog) + } + + // Server search results + if viewModel.showServerResults { + serverSearchSection + } + } + + Color.clear + .frame(height: 80) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + } +} + +// MARK: - Pinned Section + +private extension ChatListView { + var pinnedSection: some View { + ForEach(viewModel.pinnedDialogs) { dialog in + chatRow(dialog) + .listRowBackground(RosettaColors.Adaptive.backgroundSecondary) + } + } + + func chatRow(_ dialog: Dialog) -> some View { + ChatRowView(dialog: dialog) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.visible) + .listRowSeparatorTint(RosettaColors.Adaptive.divider) + .alignmentGuide(.listRowSeparatorLeading) { _ in 82 } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + withAnimation { viewModel.deleteDialog(dialog) } + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + viewModel.toggleMute(dialog) + } label: { + Label( + dialog.isMuted ? "Unmute" : "Mute", + systemImage: dialog.isMuted ? "bell" : "bell.slash" + ) + } + .tint(.indigo) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + viewModel.markAsRead(dialog) + } label: { + Label("Read", systemImage: "envelope.open") + } + .tint(RosettaColors.figmaBlue) + + Button { + viewModel.togglePin(dialog) + } label: { + Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin") + } + .tint(.orange) + } + } +} + +// MARK: - Toolbar + +private extension ChatListView { + @ToolbarContentBuilder + var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + // TODO: Edit mode + } label: { + Text("Edit") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + + ToolbarItem(placement: .principal) { + HStack(spacing: 4) { + storiesAvatars + + Text("Chats") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.figmaBlue) + } + } + + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + // TODO: Camera + } label: { + Image(systemName: "camera") + .font(.system(size: 18)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .accessibilityLabel("Camera") + + Button { + // TODO: Compose new message + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 18)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .accessibilityLabel("New chat") + } + } + + @ViewBuilder + private var storiesAvatars: some View { + let pk = AccountManager.shared.currentAccount?.publicKey ?? "" + let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk) + let colorIdx = RosettaColors.avatarColorIndex(for: pk) + + ZStack { + AvatarView(initials: initials, colorIndex: colorIdx, size: 28) + } + } +} + +// MARK: - Server Search Results + +private extension ChatListView { + @ViewBuilder + var serverSearchSection: some View { + if viewModel.isServerSearching { + HStack { + Spacer() + ProgressView() + .tint(RosettaColors.Adaptive.textSecondary) + Text("Searching users...") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Spacer() + } + .padding(.vertical, 16) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else if !viewModel.serverSearchResults.isEmpty { + Section { + ForEach(viewModel.serverSearchResults, id: \.publicKey) { user in + serverSearchRow(user) + } + } header: { + Text("GLOBAL SEARCH") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + } else if viewModel.filteredDialogs.isEmpty { + emptyState + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + + func serverSearchRow(_ user: SearchUser) -> some View { + let isSelf = user.publicKey == SessionManager.shared.currentPublicKey + let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) + let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) + + return Button { + // TODO: Navigate to ChatDetailView + } label: { + HStack(spacing: 12) { + AvatarView( + initials: initials, + colorIndex: colorIdx, + size: 52, + isOnline: user.online == 1, + isSavedMessages: isSelf + ) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title)) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if user.verified > 0 { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.figmaBlue) + } + } + + if !user.username.isEmpty { + Text("@\(user.username)") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + } + + Spacer() + + if user.online == 1 { + Circle() + .fill(RosettaColors.online) + .frame(width: 8, height: 8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets()) + } +} + +// MARK: - Empty State + +private extension ChatListView { + var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: searchText.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass") + .font(.system(size: 52)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + .padding(.top, 80) + + Text(searchText.isEmpty ? "No chats yet" : "No results for \"\(searchText)\"") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + if searchText.isEmpty { + Text("Start a conversation by tapping the search tab") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Shimmer Row + +private struct ChatRowShimmerView: View { + @State private var phase: CGFloat = 0 + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(shimmerGradient) + .frame(width: 62, height: 62) + + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 140, height: 14) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 200, height: 12) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .onAppear { + withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } + + var shimmerGradient: LinearGradient { + let baseOpacity = colorScheme == .dark ? 0.06 : 0.08 + let peakOpacity = colorScheme == .dark ? 0.12 : 0.16 + return LinearGradient( + colors: [ + Color.gray.opacity(baseOpacity), + Color.gray.opacity(peakOpacity), + Color.gray.opacity(baseOpacity), + ], + startPoint: UnitPoint(x: phase - 0.4, y: 0), + endPoint: UnitPoint(x: phase + 0.4, y: 0) + ) + } +} + +// MARK: - Preview + +#Preview { + ChatListView() +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift new file mode 100644 index 0000000..b8fdbfc --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -0,0 +1,153 @@ +import Foundation + +// MARK: - ChatListViewModel + +@Observable +@MainActor +final class ChatListViewModel { + + // MARK: - State + + private(set) var isLoading = false + private(set) var searchQuery = "" + + // Server search state + private(set) var serverSearchResults: [SearchUser] = [] + private(set) var isServerSearching = false + private var searchTask: Task? + private var lastSearchedText = "" + + init() { + setupSearchCallback() + } + + // MARK: - Computed (local dialog filtering) + + var filteredDialogs: [Dialog] { + var result = DialogRepository.shared.sortedDialogs + + let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased() + if !query.isEmpty { + result = result.filter { + $0.opponentTitle.lowercased().contains(query) + || $0.opponentUsername.lowercased().contains(query) + || $0.lastMessage.lowercased().contains(query) + } + } + + return result + } + + var pinnedDialogs: [Dialog] { + filteredDialogs.filter(\.isPinned) + } + + var unpinnedDialogs: [Dialog] { + filteredDialogs.filter { !$0.isPinned } + } + + var totalUnreadCount: Int { + DialogRepository.shared.sortedDialogs + .filter { !$0.isMuted } + .reduce(0) { $0 + $1.unreadCount } + } + + var hasUnread: Bool { totalUnreadCount > 0 } + + /// True when searching and no local results — shows server results section + var showServerResults: Bool { + let query = searchQuery.trimmingCharacters(in: .whitespaces) + return !query.isEmpty + } + + // MARK: - Actions + + func setSearchQuery(_ query: String) { + searchQuery = query + triggerServerSearch() + } + + func deleteDialog(_ dialog: Dialog) { + DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey) + } + + func togglePin(_ dialog: Dialog) { + DialogRepository.shared.togglePin(opponentKey: dialog.opponentKey) + } + + func toggleMute(_ dialog: Dialog) { + DialogRepository.shared.toggleMute(opponentKey: dialog.opponentKey) + } + + func markAsRead(_ dialog: Dialog) { + DialogRepository.shared.markAsRead(opponentKey: dialog.opponentKey) + } + + // MARK: - Server Search + + private func triggerServerSearch() { + searchTask?.cancel() + searchTask = nil + + let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + serverSearchResults = [] + isServerSearching = false + lastSearchedText = "" + return + } + + if trimmed == lastSearchedText { + return + } + + isServerSearching = true + + searchTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + + guard let self, !Task.isCancelled else { return } + + let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) + guard !currentQuery.isEmpty, currentQuery == trimmed else { return } + + let connState = ProtocolManager.shared.connectionState + let hash = SessionManager.shared.privateKeyHash + print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'") + + guard connState == .authenticated, let hash else { + print("[Search] NOT AUTHENTICATED - aborting") + self.isServerSearching = false + return + } + + self.lastSearchedText = currentQuery + + var packet = PacketSearch() + packet.privateKey = hash + packet.search = currentQuery + print("[Search] Sending PacketSearch for '\(currentQuery)'") + ProtocolManager.shared.sendPacket(packet) + } + } + + private func setupSearchCallback() { + print("[Search] Setting up search callback") + ProtocolManager.shared.onSearchResult = { [weak self] packet in + print("[Search] CALLBACK: received \(packet.users.count) users") + Task { @MainActor [weak self] in + guard let self else { return } + self.serverSearchResults = packet.users + self.isServerSearching = false + + for user in packet.users { + DialogRepository.shared.updateUserInfo( + publicKey: user.publicKey, + title: user.title, + username: user.username + ) + } + } + } + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift new file mode 100644 index 0000000..4670637 --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +// MARK: - ChatRowView + +/// Chat row matching Figma spec: +/// Row: paddingLeft=10, paddingRight=16, height=78 +/// Avatar: 62px + 10pt right padding +/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt +/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60% +struct ChatRowView: View { + let dialog: Dialog + + var body: some View { + HStack(spacing: 0) { + avatarSection + .padding(.trailing, 10) + + contentSection + } + .padding(.leading, 10) + .padding(.trailing, 16) + .frame(height: 78) + .contentShape(Rectangle()) + } +} + +// MARK: - Avatar + +private extension ChatRowView { + var avatarSection: some View { + AvatarView( + initials: dialog.initials, + colorIndex: dialog.avatarColorIndex, + size: 62, + isOnline: dialog.isOnline, + isSavedMessages: dialog.isSavedMessages + ) + } +} + +// MARK: - Content Section + +private extension ChatRowView { + var contentSection: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 0) + titleRow + Spacer().frame(height: 3) + subtitleRow + Spacer(minLength: 0) + } + } +} + +// MARK: - Title Row (name + badges + delivery + time) + +private extension ChatRowView { + var titleRow: some View { + HStack(spacing: 4) { + Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle) + .font(.system(size: 17, weight: .medium)) + .tracking(-0.43) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if dialog.isVerified { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.figmaBlue) + } + + if dialog.isMuted { + Image(systemName: "speaker.slash.fill") + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + + Spacer(minLength: 4) + + if dialog.lastMessageFromMe && !dialog.isSavedMessages { + deliveryIcon + } + + Text(formattedTime) + .font(.system(size: 14)) + .foregroundStyle( + dialog.unreadCount > 0 && !dialog.isMuted + ? RosettaColors.figmaBlue + : RosettaColors.Adaptive.textSecondary + ) + } + } +} + +// MARK: - Subtitle Row (message + pin + badge) + +private extension ChatRowView { + var subtitleRow: some View { + HStack(spacing: 4) { + Text(messageText) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + + Spacer(minLength: 4) + + if dialog.isPinned && dialog.unreadCount == 0 { + Image(systemName: "pin.fill") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .rotationEffect(.degrees(45)) + } + + if dialog.unreadCount > 0 { + unreadBadge + } + } + } + + var messageText: String { + if dialog.lastMessage.isEmpty { + return "No messages yet" + } + return dialog.lastMessage + } + + @ViewBuilder + var deliveryIcon: some View { + switch dialog.lastMessageDelivered { + case .waiting: + Image(systemName: "clock") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + case .delivered: + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + case .read: + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(RosettaColors.figmaBlue) + .overlay(alignment: .leading) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(RosettaColors.figmaBlue) + .offset(x: -4) + } + .padding(.trailing, 2) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.error) + } + } + + var unreadBadge: some View { + let count = dialog.unreadCount + let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)") + let isMuted = dialog.isMuted + + return Text(text) + .font(.system(size: 15)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .frame(minWidth: 20, minHeight: 20) + .background { + Capsule() + .fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue) + } + } +} + +// MARK: - Time Formatting + +private extension ChatRowView { + var formattedTime: String { + guard dialog.lastMessageTimestamp > 0 else { return "" } + + let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000) + let now = Date() + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + let f = DateFormatter() + f.dateFormat = "h:mm a" + return f.string(from: date) + } else if calendar.isDateInYesterday(date) { + return "Yesterday" + } else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { + let f = DateFormatter() + f.dateFormat = "EEE" + return f.string(from: date) + } else { + let f = DateFormatter() + f.dateFormat = "dd.MM.yy" + return f.string(from: date) + } + } +} + +// MARK: - Preview + +#Preview { + let sampleDialog = Dialog( + id: "preview", account: "mykey", opponentKey: "abc001", + opponentTitle: "Alice Johnson", + opponentUsername: "alice", + lastMessage: "Hey, how are you?", + lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000), + unreadCount: 3, isOnline: true, lastSeen: 0, + isVerified: true, iHaveSent: true, + isPinned: false, isMuted: false, + lastMessageFromMe: true, lastMessageDelivered: .read + ) + + VStack(spacing: 0) { + ChatRowView(dialog: sampleDialog) + } + .background(RosettaColors.Adaptive.background) +} diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift new file mode 100644 index 0000000..94b5207 --- /dev/null +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -0,0 +1,340 @@ +import SwiftUI + +// MARK: - SearchView + +struct SearchView: View { + @State private var viewModel = SearchViewModel() + @State private var searchText = "" + @FocusState private var isSearchFocused: Bool + + var body: some View { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + if searchText.isEmpty { + recentSection + } else { + searchResultsContent + } + + Spacer().frame(height: 120) + } + } + .scrollDismissesKeyboard(.interactively) + + searchBar + } + .onChange(of: searchText) { _, newValue in + print("[SearchView] onChange fired: '\(newValue)'") + viewModel.setSearchQuery(newValue) + } + } +} + +// MARK: - Search Bar (bottom, Figma style) + +private extension SearchView { + var searchBar: some View { + HStack(spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.adaptive( + light: Color(hex: 0x8C8C8C), + dark: Color(hex: 0x8E8E93) + )) + + TextField("Search", text: $searchText) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($isSearchFocused) + .submitLabel(.search) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + if !searchText.isEmpty { + Button { + searchText = "" + viewModel.clearSearch() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.adaptive( + light: Color(hex: 0x8C8C8C), + dark: Color(hex: 0x8E8E93) + )) + } + } + } + .padding(.horizontal, 11) + .frame(height: 42) + .background { + Capsule() + .fill(RosettaColors.adaptive( + light: Color(hex: 0xF7F7F7), + dark: Color(hex: 0x2A2A2A) + )) + } + .applyGlassSearchBar() + + Button { + // TODO: Compose new chat + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.adaptive( + light: Color(hex: 0x404040), + dark: Color(hex: 0x8E8E93) + )) + .frame(width: 42, height: 42) + .background { + Circle() + .fill(RosettaColors.adaptive( + light: Color(hex: 0xF7F7F7), + dark: Color(hex: 0x2A2A2A) + )) + } + .applyGlassSearchBar() + } + .accessibilityLabel("New chat") + } + .padding(.horizontal, 28) + .padding(.bottom, 8) + .background { + RosettaColors.Adaptive.background + .opacity(0.95) + .ignoresSafeArea() + } + } +} + +// MARK: - Glass Search Bar Modifier + +private struct GlassSearchBarModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .glassEffect(.regular, in: .capsule) + } else { + content + } + } +} + +private extension View { + func applyGlassSearchBar() -> some View { + modifier(GlassSearchBarModifier()) + } +} + +// MARK: - Recent Section + +private extension SearchView { + @ViewBuilder + var recentSection: some View { + if viewModel.recentSearches.isEmpty { + emptyState + } else { + VStack(spacing: 0) { + // Section header + HStack { + Text("RECENT") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + Spacer() + + Button { + viewModel.clearRecentSearches() + } label: { + Text("Clear") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) + + // Recent items + ForEach(viewModel.recentSearches, id: \.publicKey) { user in + recentRow(user) + } + } + } + } + + var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 52)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + .padding(.top, 100) + + Text("Search for users") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + Text("Find people by username or public key") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity) + } + + func recentRow(_ user: RecentSearch) -> some View { + let isSelf = user.publicKey == SessionManager.shared.currentPublicKey + let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) + let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) + + return Button { + searchText = user.username.isEmpty ? user.publicKey : user.username + } label: { + HStack(spacing: 12) { + ZStack(alignment: .topTrailing) { + AvatarView( + initials: initials, + colorIndex: colorIdx, + size: 42, + isSavedMessages: isSelf + ) + + // Close button to remove from recent + Button { + viewModel.removeRecentSearch(publicKey: user.publicKey) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(Circle().fill(RosettaColors.figmaBlue)) + } + .offset(x: 4, y: -4) + } + + VStack(alignment: .leading, spacing: 1) { + Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title)) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if !user.lastSeenText.isEmpty { + Text(user.lastSeenText) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 5) + } + .buttonStyle(.plain) + } +} + +// MARK: - Search Results Content + +private extension SearchView { + @ViewBuilder + var searchResultsContent: some View { + if viewModel.isSearching { + VStack(spacing: 12) { + Spacer().frame(height: 40) + ProgressView() + .tint(RosettaColors.Adaptive.textSecondary) + Text("Searching...") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .frame(maxWidth: .infinity) + } else if viewModel.searchResults.isEmpty { + VStack(spacing: 12) { + Spacer().frame(height: 40) + Image(systemName: "person.slash") + .font(.system(size: 40)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + Text("No users found") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Text("Try a different username or public key") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + } + .frame(maxWidth: .infinity) + } else { + VStack(spacing: 0) { + ForEach(viewModel.searchResults, id: \.publicKey) { user in + searchResultRow(user) + } + } + } + } + + func searchResultRow(_ user: SearchUser) -> some View { + let isSelf = user.publicKey == SessionManager.shared.currentPublicKey + let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) + let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) + + return Button { + viewModel.addToRecent(user) + // TODO: Navigate to ChatDetailView for user.publicKey + } label: { + HStack(spacing: 12) { + AvatarView( + initials: initials, + colorIndex: colorIdx, + size: 42, + isOnline: user.online == 1, + isSavedMessages: isSelf + ) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title)) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if user.verified > 0 { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.figmaBlue) + } + } + + if !user.username.isEmpty { + Text("@\(user.username)") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + } + + Spacer() + + if user.online == 1 { + Circle() + .fill(RosettaColors.online) + .frame(width: 8, height: 8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 5) + } + .buttonStyle(.plain) + } +} + +// MARK: - Preview + +#Preview { + SearchView() +} diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift new file mode 100644 index 0000000..84acdea --- /dev/null +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -0,0 +1,180 @@ +import Foundation +import os + +// MARK: - Recent Search Model + +struct RecentSearch: Codable, Equatable { + let publicKey: String + var title: String + var username: String + var lastSeenText: String +} + +// MARK: - SearchViewModel + +@Observable +@MainActor +final class SearchViewModel { + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search") + + // MARK: - State + + var searchQuery = "" + + private(set) var searchResults: [SearchUser] = [] + private(set) var isSearching = false + private(set) var recentSearches: [RecentSearch] = [] + + private var searchTask: Task? + private var lastSearchedText = "" + + private static let recentKey = "rosetta_recent_searches" + private static let maxRecent = 20 + + // MARK: - Init + + init() { + loadRecentSearches() + setupSearchCallback() + } + + // MARK: - Search Logic + + func setSearchQuery(_ query: String) { + print("[Search] setSearchQuery called: '\(query)'") + searchQuery = query + onSearchQueryChanged() + } + + private func onSearchQueryChanged() { + searchTask?.cancel() + searchTask = nil + + let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + searchResults = [] + isSearching = false + lastSearchedText = "" + return + } + + if trimmed == lastSearchedText { + print("[Search] Query unchanged, skipping") + return + } + + isSearching = true + print("[Search] Starting debounce for '\(trimmed)'") + + // Debounce 1 second (like Android) + searchTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + + guard let self, !Task.isCancelled else { + print("[Search] Task cancelled during debounce") + return + } + + let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) + guard !currentQuery.isEmpty, currentQuery == trimmed else { + print("[Search] Query changed during debounce, aborting") + return + } + + let connState = ProtocolManager.shared.connectionState + let hash = SessionManager.shared.privateKeyHash + print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'") + + guard connState == .authenticated, let hash else { + print("[Search] NOT AUTHENTICATED - aborting search") + self.isSearching = false + return + } + + self.lastSearchedText = currentQuery + + var packet = PacketSearch() + packet.privateKey = hash + packet.search = currentQuery + print("[Search] Sending PacketSearch for '\(currentQuery)' with hash prefix: \(String(hash.prefix(16)))...") + ProtocolManager.shared.sendPacket(packet) + } + } + + func clearSearch() { + searchQuery = "" + searchResults = [] + isSearching = false + lastSearchedText = "" + searchTask?.cancel() + searchTask = nil + } + + // MARK: - Search Callback + + private func setupSearchCallback() { + print("[Search] Setting up search callback on ProtocolManager") + ProtocolManager.shared.onSearchResult = { [weak self] packet in + print("[Search] CALLBACK: received \(packet.users.count) users") + Task { @MainActor [weak self] in + guard let self else { return } + self.searchResults = packet.users + self.isSearching = false + + // Update dialog info from results + for user in packet.users { + DialogRepository.shared.updateUserInfo( + publicKey: user.publicKey, + title: user.title, + username: user.username + ) + } + } + } + } + + // MARK: - Recent Searches + + func addToRecent(_ user: SearchUser) { + let recent = RecentSearch( + publicKey: user.publicKey, + title: user.title, + username: user.username, + lastSeenText: user.online == 1 ? "online" : "last seen recently" + ) + + // Remove duplicate if exists + recentSearches.removeAll { $0.publicKey == user.publicKey } + // Insert at top + recentSearches.insert(recent, at: 0) + // Keep max + if recentSearches.count > Self.maxRecent { + recentSearches = Array(recentSearches.prefix(Self.maxRecent)) + } + saveRecentSearches() + } + + func removeRecentSearch(publicKey: String) { + recentSearches.removeAll { $0.publicKey == publicKey } + saveRecentSearches() + } + + func clearRecentSearches() { + recentSearches = [] + saveRecentSearches() + } + + private func loadRecentSearches() { + guard let data = UserDefaults.standard.data(forKey: Self.recentKey), + let list = try? JSONDecoder().decode([RecentSearch].self, from: data) else { + return + } + recentSearches = list + } + + private func saveRecentSearches() { + guard let data = try? JSONEncoder().encode(recentSearches) else { return } + UserDefaults.standard.set(data, forKey: Self.recentKey) + } +} diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift new file mode 100644 index 0000000..6443b9d --- /dev/null +++ b/Rosetta/Features/MainTabView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Main container view with tab-based navigation. +struct MainTabView: View { + var onLogout: (() -> Void)? + @State private var selectedTab: RosettaTab = .chats + + var body: some View { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + Group { + switch selectedTab { + case .chats: + ChatListView() + case .settings: + SettingsView(onLogout: onLogout) + case .search: + SearchView() + } + } + + RosettaTabBar( + selectedTab: selectedTab, + onTabSelected: { tab in + withAnimation(.easeInOut(duration: 0.15)) { + selectedTab = tab + } + }, + badges: tabBadges + ) + .ignoresSafeArea(.keyboard) + } + } + + private var tabBadges: [TabBadge] { + var result: [TabBadge] = [] + let unread = DialogRepository.shared.sortedDialogs + .filter { !$0.isMuted } + .reduce(0) { $0 + $1.unreadCount } + if unread > 0 { + result.append(TabBadge(tab: .chats, text: unread > 999 ? "\(unread / 1000)K" : "\(unread)")) + } + return result + } +} + +// MARK: - Placeholder + +struct PlaceholderTabView: View { + let title: String + let icon: String + + var body: some View { + NavigationStack { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 52)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + Text("Coming soon") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } + } +} diff --git a/Rosetta/Features/Onboarding/OnboardingPager.swift b/Rosetta/Features/Onboarding/OnboardingPager.swift new file mode 100644 index 0000000..a8574ae --- /dev/null +++ b/Rosetta/Features/Onboarding/OnboardingPager.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// UIPageViewController wrapper that handles paging entirely in UIKit. +/// SwiftUI state is only updated after a page fully settles — zero overhead during swipe. +struct OnboardingPager: UIViewControllerRepresentable { + @Binding var currentIndex: Int + let count: Int + let buildPage: (Int) -> Page + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIViewController(context: Context) -> UIPageViewController { + let vc = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + vc.view.backgroundColor = .clear + vc.dataSource = context.coordinator + vc.delegate = context.coordinator + vc.setViewControllers( + [context.coordinator.controllers[currentIndex]], + direction: .forward, + animated: false + ) + // Make the inner scroll view transparent + for sub in vc.view.subviews { + (sub as? UIScrollView)?.backgroundColor = .clear + } + return vc + } + + func updateUIViewController(_ vc: UIPageViewController, context: Context) { + context.coordinator.parent = self + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + var parent: OnboardingPager + let controllers: [UIHostingController] + + init(_ parent: OnboardingPager) { + self.parent = parent + // Create hosting controllers without triggering view loading + self.controllers = (0.. UIViewController? { + guard let idx = controllers.firstIndex(where: { $0 === vc }), idx > 0 else { return nil } + return controllers[idx - 1] + } + + func pageViewController( + _ pvc: UIPageViewController, + viewControllerAfter vc: UIViewController + ) -> UIViewController? { + guard let idx = controllers.firstIndex(where: { $0 === vc }), + idx < parent.count - 1 else { return nil } + return controllers[idx + 1] + } + + func pageViewController( + _ pvc: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + guard completed, + let current = pvc.viewControllers?.first, + let idx = controllers.firstIndex(where: { $0 === current }) else { return } + parent.currentIndex = idx + } + } +} diff --git a/Rosetta/Features/Onboarding/OnboardingView.swift b/Rosetta/Features/Onboarding/OnboardingView.swift index a90a426..a45ebc4 100644 --- a/Rosetta/Features/Onboarding/OnboardingView.swift +++ b/Rosetta/Features/Onboarding/OnboardingView.swift @@ -5,33 +5,29 @@ struct OnboardingView: View { let onStartMessaging: () -> Void @State private var currentPage = 0 - @State private var dragOffset: CGFloat = 0 - @State private var activeIndex: CGFloat = 0 - private let pages = OnboardingPages.all - private let springResponse: Double = 0.4 - private let springDamping: Double = 0.85 var body: some View { GeometryReader { geometry in - let screenWidth = geometry.size.width + let pagerHeight = geometry.size.height * 0.32 + 152 ZStack { RosettaColors.Dark.background.ignoresSafeArea() VStack(spacing: 0) { - Spacer() - .frame(height: geometry.size.height * 0.1) + OnboardingPager( + currentIndex: $currentPage, + count: pages.count + ) { i in + OnboardingPageSlide( + page: pages[i], + screenWidth: geometry.size.width, + screenHeight: geometry.size.height + ) + } + .frame(height: pagerHeight) - lottieSection(screenWidth: screenWidth) - .frame(height: geometry.size.height * 0.22) - - Spacer().frame(height: 32) - - textSection(screenWidth: screenWidth) - .frame(height: 120) - - pageIndicator(screenWidth: screenWidth) + pageIndicator() .padding(.top, 24) Spacer() @@ -41,71 +37,72 @@ struct OnboardingView: View { .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) } } - .gesture(swipeGesture(screenWidth: screenWidth)) } .preferredColorScheme(.dark) .statusBarHidden(false) } } -// MARK: - Lottie Section +// MARK: - Page Slide +// Standalone view — has no dependency on parent state. +// Lottie playback controlled entirely by .onAppear / .onDisappear. -private extension OnboardingView { - func lottieSection(screenWidth: CGFloat) -> some View { - ZStack { - ForEach(pages) { page in - LottieView( - animationName: page.animationName, - loopMode: .playOnce, - animationSpeed: 1.5, - isPlaying: currentPage == page.id - ) - .frame(width: screenWidth * 0.42, height: screenWidth * 0.42) - .offset(x: textOffset(for: page.id, screenWidth: screenWidth)) - } +private struct OnboardingPageSlide: View { + let page: OnboardingPage + let screenWidth: CGFloat + let screenHeight: CGFloat + + @State private var isPlaying = false + + var body: some View { + VStack(spacing: 0) { + Spacer().frame(height: screenHeight * 0.1) + + LottieView( + animationName: page.animationName, + loopMode: .playOnce, + animationSpeed: 1.5, + isPlaying: isPlaying + ) + .frame(width: screenWidth * 0.42, height: screenHeight * 0.22) + + Spacer().frame(height: 32) + + textBlock() + .frame(height: 120) } - .frame(width: screenWidth, height: screenWidth * 0.42) - .clipped() - .accessibilityLabel("Illustration for \(pages[currentPage].title)") - } -} - -// MARK: - Text Section - -private extension OnboardingView { - func textSection(screenWidth: CGFloat) -> some View { - ZStack { - ForEach(pages) { page in - VStack(spacing: 14) { - Text(page.title) - .font(.system(size: 32, weight: .bold)) - .foregroundStyle(.white) - - highlightedDescription(page: page) - } - .frame(width: screenWidth) - .offset(x: textOffset(for: page.id, screenWidth: screenWidth)) - .opacity(textOpacity(for: page.id, screenWidth: screenWidth)) - } - } - .clipped() - .accessibilityElement(children: .combine) - .accessibilityLabel("\(pages[currentPage].title). \(pages[currentPage].description)") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RosettaColors.Dark.background) + .onAppear { isPlaying = true } + .onDisappear { isPlaying = false } + .accessibilityElement(children: .contain) + .accessibilityLabel("\(page.title). \(page.description)") } - func highlightedDescription(page: OnboardingPage) -> some View { - let lines = page.description.components(separatedBy: "\n") - return VStack(spacing: 4) { - ForEach(lines, id: \.self) { line in - buildHighlightedLine(line, highlights: page.highlightedWords) - } + private func textBlock() -> some View { + VStack(spacing: 14) { + Text(page.title) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(.white) + + highlightedText() } + .frame(width: screenWidth) .multilineTextAlignment(.center) } - func buildHighlightedLine(_ line: String, highlights: [String]) -> some View { + private func highlightedText() -> some View { + let lines = page.description.components(separatedBy: "\n") + return VStack(spacing: 4) { + ForEach(lines, id: \.self) { line in + highlightedLine(line) + } + } + } + + private func highlightedLine(_ line: String) -> some View { var attributed = AttributedString(line) - for word in highlights { + for word in page.highlightedWords { if let range = attributed.range(of: word, options: .caseInsensitive) { attributed[range].foregroundColor = RosettaColors.primaryBlue attributed[range].font = .system(size: 17, weight: .semibold) @@ -115,56 +112,27 @@ private extension OnboardingView { .font(.system(size: 17)) .foregroundStyle(RosettaColors.secondaryText) } - - func textOffset(for index: Int, screenWidth: CGFloat) -> CGFloat { - CGFloat(index - currentPage) * screenWidth + dragOffset - } - - func textOpacity(for index: Int, screenWidth: CGFloat) -> Double { - let offset = abs(textOffset(for: index, screenWidth: screenWidth)) - return max(1.0 - (offset / screenWidth) * 1.5, 0.0) - } } -// MARK: - Page Indicator +// MARK: - Bottom Controls private extension OnboardingView { - func pageIndicator(screenWidth: CGFloat) -> some View { + func pageIndicator() -> some View { let dotSize: CGFloat = 7 let spacing: CGFloat = 15 - let count = pages.count - let center = CGFloat(count - 1) / 2.0 - let frac = activeIndex - floor(activeIndex) - let distFromWhole = min(frac, 1.0 - frac) - let stretchFactor = distFromWhole * 2.0 - - let capsuleWidth = dotSize + stretchFactor * spacing * 0.85 - let capsuleHeight = dotSize * (1.0 - stretchFactor * 0.12) - let capsuleX = (activeIndex - center) * spacing - - return ZStack { - ForEach(0.. some View { Button(action: onStartMessaging) { Text("Start Messaging") @@ -179,41 +147,6 @@ private extension OnboardingView { } } -// MARK: - Swipe Gesture - -private extension OnboardingView { - func swipeGesture(screenWidth: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 20) - .onChanged { value in - let translation = value.translation.width - let isAtStart = currentPage == 0 && translation > 0 - let isAtEnd = currentPage == pages.count - 1 && translation < 0 - - dragOffset = (isAtStart || isAtEnd) ? translation * 0.25 : translation - - let progress = -dragOffset / screenWidth - activeIndex = min(max(CGFloat(currentPage) + progress, 0), CGFloat(pages.count - 1)) - } - .onEnded { value in - let threshold = screenWidth * 0.25 - let velocity = value.predictedEndTranslation.width - value.translation.width - var newPage = currentPage - - if value.translation.width < -threshold || velocity < -150 { - newPage = min(currentPage + 1, pages.count - 1) - } else if value.translation.width > threshold || velocity > 150 { - newPage = max(currentPage - 1, 0) - } - - withAnimation(.spring(response: springResponse, dampingFraction: springDamping)) { - currentPage = newPage - dragOffset = 0 - activeIndex = CGFloat(newPage) - } - } - } -} - // MARK: - Preview #Preview { diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..0ce6414 --- /dev/null +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +/// Settings / Profile screen with Telegram iOS-style grouped glass cards. +struct SettingsView: View { + var onLogout: (() -> Void)? + + @State private var viewModel = SettingsViewModel() + @State private var showCopiedToast = false + @State private var showLogoutConfirmation = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + profileHeader + accountSection + generalSection + dangerSection + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 100) + } + .background(RosettaColors.Adaptive.background) + .scrollContentBackground(.hidden) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Settings") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Edit") {} + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.primaryBlue) + } + } + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .alert("Log Out", isPresented: $showLogoutConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Log Out", role: .destructive) { + SessionManager.shared.endSession() + onLogout?() + } + } message: { + Text("Are you sure you want to log out?") + } + } + .overlay(alignment: .top) { + if showCopiedToast { + copiedToast + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } + + // MARK: - Profile Header + + private var profileHeader: some View { + VStack(spacing: 12) { + AvatarView( + initials: viewModel.initials, + colorIndex: viewModel.avatarColorIndex, + size: 80, + isSavedMessages: false + ) + + VStack(spacing: 4) { + Text(viewModel.displayName.isEmpty ? "Set Display Name" : viewModel.displayName) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + if !viewModel.username.isEmpty { + Text("@\(viewModel.username)") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.secondaryText) + } + + // Connection status + HStack(spacing: 6) { + Circle() + .fill(viewModel.isConnected ? RosettaColors.online : RosettaColors.tertiaryText) + .frame(width: 8, height: 8) + Text(viewModel.connectionStatus) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .padding(.top, 4) + } + + // Public key + Button { + viewModel.copyPublicKey() + withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true } + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + withAnimation { showCopiedToast = false } + } + } label: { + HStack(spacing: 6) { + Text(formatPublicKey(viewModel.publicKey)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(RosettaColors.tertiaryText) + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + .foregroundStyle(RosettaColors.primaryBlue) + } + } + } + .padding(.vertical, 16) + } + + // MARK: - Account Section + + private var accountSection: some View { + GlassCard(cornerRadius: 12, fillOpacity: 0.08) { + VStack(spacing: 0) { + settingsRow(icon: "person.fill", title: "My Profile", color: .red) {} + sectionDivider + settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: .purple) {} + sectionDivider + settingsRow(icon: "desktopcomputer", title: "Devices", color: .orange) {} + sectionDivider + settingsRow(icon: "folder.fill", title: "Chat Folders", color: .blue) {} + } + } + } + + // MARK: - General Section + + private var generalSection: some View { + GlassCard(cornerRadius: 12, fillOpacity: 0.08) { + VStack(spacing: 0) { + settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {} + sectionDivider + settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {} + sectionDivider + settingsRow(icon: "paintbrush.fill", title: "Appearance", color: .blue) {} + sectionDivider + settingsRow(icon: "globe", title: "Language", color: .purple) {} + } + } + } + + // MARK: - Danger Section + + private var dangerSection: some View { + GlassCard(cornerRadius: 12, fillOpacity: 0.08) { + Button { + showLogoutConfirmation = true + } label: { + HStack { + Spacer() + Text("Log Out") + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.error) + Spacer() + } + .padding(.vertical, 14) + } + } + } + + // MARK: - Helpers + + private func settingsRow( + icon: String, + title: String, + color: Color, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(width: 30, height: 30) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 7)) + + Text(title) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + + private var sectionDivider: some View { + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.leading, 60) + } + + private func formatPublicKey(_ key: String) -> String { + guard key.count > 16 else { return key } + return String(key.prefix(8)) + "..." + String(key.suffix(6)) + } + + private var copiedToast: some View { + Text("Public key copied") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + .padding(.top, 60) + } +} diff --git a/Rosetta/Features/Settings/SettingsViewModel.swift b/Rosetta/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..f7bc4c6 --- /dev/null +++ b/Rosetta/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,52 @@ +import Foundation +import Observation +import UIKit + +@Observable +@MainActor +final class SettingsViewModel { + + var displayName: String { + SessionManager.shared.displayName.isEmpty + ? (AccountManager.shared.currentAccount?.displayName ?? "") + : SessionManager.shared.displayName + } + + var username: String { + SessionManager.shared.username.isEmpty + ? (AccountManager.shared.currentAccount?.username ?? "") + : SessionManager.shared.username + } + + var publicKey: String { + AccountManager.shared.currentAccount?.publicKey ?? "" + } + + var initials: String { + RosettaColors.initials(name: displayName, publicKey: publicKey) + } + + var avatarColorIndex: Int { + RosettaColors.avatarColorIndex(for: publicKey) + } + + var connectionStatus: String { + switch ProtocolManager.shared.connectionState { + case .disconnected: return "Disconnected" + case .connecting: return "Connecting..." + case .connected: return "Connected" + case .handshaking: return "Authenticating..." + case .authenticated: return "Online" + } + } + + var isConnected: Bool { + ProtocolManager.shared.connectionState == .authenticated + } + + func copyPublicKey() { + #if canImport(UIKit) + UIPasteboard.general.string = publicKey + #endif + } +} diff --git a/Rosetta/Features/Splash/SplashView.swift b/Rosetta/Features/Splash/SplashView.swift new file mode 100644 index 0000000..64163be --- /dev/null +++ b/Rosetta/Features/Splash/SplashView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct SplashView: View { + let onSplashComplete: () -> Void + + @State private var logoScale: CGFloat = 0.3 + @State private var logoOpacity: Double = 0 + @State private var glowScale: CGFloat = 0.3 + @State private var glowPulsing = false + + var body: some View { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + ZStack { + // Glow behind logo + Circle() + .fill(Color(hex: 0x54A9EB).opacity(0.2)) + .frame(width: 180, height: 180) + .scaleEffect(glowScale * (glowPulsing ? 1.1 : 1.0)) + .opacity(logoOpacity) + + // Logo + Image("RosettaIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 150, height: 150) + .clipShape(Circle()) + .scaleEffect(logoScale) + .opacity(logoOpacity) + } + } + .task { + try? await Task.sleep(for: .milliseconds(100)) + + withAnimation(.easeOut(duration: 0.6)) { + logoOpacity = 1 + } + withAnimation(.spring(response: 0.6, dampingFraction: 0.6)) { + logoScale = 1 + glowScale = 1 + } + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + glowPulsing = true + } + + try? await Task.sleep(for: .seconds(2)) + onSplashComplete() + } + .accessibilityLabel("Rosetta") + } +} + +#Preview { + SplashView(onSplashComplete: {}) +} diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index d3f9b54..d96bc2d 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -1,45 +1,98 @@ -// -// RosettaApp.swift -// Rosetta -// -// Created by Gaidar Timirbaev on 22.02.2026. -// - import SwiftUI +// MARK: - App State + +private enum AppState { + case splash + case onboarding + case auth + case unlock + case main +} + +// MARK: - RosettaApp + @main struct RosettaApp: App { - @State private var hasCompletedOnboarding = false + + init() { + UIWindow.appearance().backgroundColor = .systemBackground + // Preload Lottie animations early + Task.detached(priority: .userInitiated) { + LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName)) + } + } + + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("isLoggedIn") private var isLoggedIn = false + @State private var appState: AppState = .splash var body: some Scene { WindowGroup { - rootView - .preferredColorScheme(.dark) + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + rootView + } } } @ViewBuilder private var rootView: some View { - if !hasCompletedOnboarding { + switch appState { + case .splash: + SplashView { + withAnimation(.easeInOut(duration: 0.4)) { + determineNextState() + } + } + + case .onboarding: OnboardingView { withAnimation(.easeInOut(duration: 0.4)) { hasCompletedOnboarding = true + appState = .auth } } - } else if !isLoggedIn { + + case .auth: AuthCoordinator { withAnimation(.easeInOut(duration: 0.4)) { isLoggedIn = true + // Start session automatically with the password from auth flow + appState = .main } } + + case .unlock: + UnlockView { + withAnimation(.easeInOut(duration: 0.4)) { + isLoggedIn = true + appState = .main + } + } + + case .main: + MainTabView(onLogout: { + withAnimation(.easeInOut(duration: 0.4)) { + isLoggedIn = false + appState = .unlock + } + }) + } + } + + private func determineNextState() { + if AccountManager.shared.hasAccount { + // Existing user — unlock with password + appState = .unlock + } else if !hasCompletedOnboarding { + // New user — show onboarding first + appState = .onboarding } else { - // TODO: Replace with main ChatListView - Text("Welcome to Rosetta") - .font(RosettaFont.headlineLarge) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(RosettaColors.Dark.background) + // Onboarding done but no account — go to auth + appState = .auth } } }