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
}
}
}