feat: Implement chat list and search functionality

- Added ChatListViewModel to manage chat list state and server search.
- Created ChatRowView for displaying individual chat rows.
- Developed SearchView and SearchViewModel for user search functionality.
- Introduced MainTabView for tab-based navigation between chats and settings.
- Implemented OnboardingPager for onboarding experience.
- Created SettingsView and SettingsViewModel for user settings management.
- Added SplashView for initial app launch experience.
This commit is contained in:
2026-02-25 21:27:41 +05:00
parent 7fb57fffba
commit 99a35302fa
54 changed files with 5818 additions and 213 deletions

11
.claude/settings.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"figma": {
"type": "url",
"url": "https://mcp.figma.com/mcp",
"headers": {
"Authorization": "Bearer figd_WFn1CnWSubbZSaeSMsRW_q2WZLPam49AI1DYicwE"
}
}
}
}

5
Info.plist Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -28,6 +29,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
853F29992F4B63D20092AD05 /* Lottie in Frameworks */, 853F29992F4B63D20092AD05 /* Lottie in Frameworks */,
853F29A02F4B63D20092AD05 /* P256K in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -71,6 +73,7 @@
name = Rosetta; name = Rosetta;
packageProductDependencies = ( packageProductDependencies = (
853F29982F4B63D20092AD05 /* Lottie */, 853F29982F4B63D20092AD05 /* Lottie */,
853F29A12F4B63D20092AD05 /* P256K */,
); );
productName = Rosetta; productName = Rosetta;
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
@@ -102,6 +105,7 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */, 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */,
853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 853F29632F4B50410092AD05 /* Products */; productRefGroup = 853F29632F4B50410092AD05 /* Products */;
@@ -263,8 +267,10 @@
DEVELOPMENT_TEAM = U6DMAKWNV3; DEVELOPMENT_TEAM = U6DMAKWNV3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -295,8 +301,10 @@
DEVELOPMENT_TEAM = U6DMAKWNV3; DEVELOPMENT_TEAM = U6DMAKWNV3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -349,6 +357,14 @@
minimumVersion = 4.0.0; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -357,6 +373,11 @@
package = 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */; package = 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */;
productName = Lottie; productName = Lottie;
}; };
853F29A12F4B63D20092AD05 /* P256K */ = {
isa = XCSwiftPackageProductDependency;
package = 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */;
productName = P256K;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 853F295A2F4B50410092AD05 /* Project object */; rootObject = 853F295A2F4B50410092AD05 /* Project object */;

View File

@@ -1,6 +1,15 @@
{ {
"colors" : [ "colors" : [
{ {
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.902",
"green" : "0.541",
"red" : "0.141"
}
},
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -12,6 +13,7 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -23,6 +25,7 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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<String> = Set(wordList)
static func index(of word: String) -> Int? {
wordList.firstIndex(of: word)
}
}

View File

@@ -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()
}
}

View File

@@ -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.<key>`.
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<T: Encodable>(_ value: T, forKey key: String) throws {
let data = try JSONEncoder().encode(value)
try save(data, forKey: key)
}
func loadCodable<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T {
let data = try load(forKey: key)
return try JSONDecoder().decode(type, from: data)
}
}

View File

@@ -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..<blockSize {
result[offset + i] = data[data.startIndex + offset + i] ^ block[i]
}
counter &+= 1
}
return result
}
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
static func hchacha20(key: Data, nonce: Data) -> 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..<blockEnd])
while block.count < 16 { block.append(0) }
// Add block to accumulator (with hibit for message blocks, not for length blocks)
let hibit: UInt64 = offset < (macInput.count - 16) ? (1 << 24) : (offset < data.count + padding ? (1 << 24) : 0)
// For AEAD: hibit is 1 for actual data blocks, 0 for length fields
let isDataBlock = offset < data.count + padding
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
let n = toLimbs26(block)
accumulator[0] = accumulator[0] &+ n[0]
accumulator[1] = accumulator[1] &+ n[1]
accumulator[2] = accumulator[2] &+ n[2]
accumulator[3] = accumulator[3] &+ n[3]
accumulator[4] = accumulator[4] &+ n[4] &+ bit
// Multiply accumulator by r
accumulator = poly1305Multiply(accumulator, rLimbs)
}
// Freeze: reduce mod 2^130 - 5, then add s
let result = poly1305Freeze(accumulator, s: s)
return Data(result)
}
/// Convert 16 bytes to 5 limbs of 26 bits each.
static func toLimbs26(_ bytes: [UInt8]) -> [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..<min(16, b.count) { full[i] = b[i] }
let lo = UInt64(full[0]) | UInt64(full[1]) << 8 | UInt64(full[2]) << 16 | UInt64(full[3]) << 24 |
UInt64(full[4]) << 32 | UInt64(full[5]) << 40 | UInt64(full[6]) << 48 | UInt64(full[7]) << 56
let hi = UInt64(full[8]) | UInt64(full[9]) << 8 | UInt64(full[10]) << 16 | UInt64(full[11]) << 24 |
UInt64(full[12]) << 32 | UInt64(full[13]) << 40 | UInt64(full[14]) << 48 | UInt64(full[15]) << 56
limbs[0] = lo & 0x3FFFFFF
limbs[1] = (lo >> 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..<a.count {
result |= a[a.startIndex + i] ^ b[b.startIndex + i]
}
return result == 0
}
}
// MARK: - AES-CBC Helpers
private extension MessageCrypto {
static 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)
}
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..<nextIndex], radix: 16) ?? 0
data.append(byte)
}
index = nextIndex
}
self = data
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
// MARK: - Account
/// Represents a Rosetta user account.
/// Sensitive fields (private key, seed phrase) are stored encrypted.
/// The public key is the user's unique identity on the network.
struct Account: Codable, Equatable {
/// Compressed secp256k1 public key (33 bytes, hex-encoded, 66 characters).
/// This is the user's unique identifier on the Rosetta network.
let publicKey: String
/// AES-256-CBC encrypted private key (Base64(IV):Base64(ciphertext)).
let privateKeyEncrypted: String
/// AES-256-CBC encrypted BIP39 seed phrase joined by spaces.
let seedPhraseEncrypted: String
var displayName: String?
var username: String?
}
// MARK: - System Accounts
extension Account {
/// The "Safe" system account sends security alerts.
static let safePublicKey = "0x0000000000000000000000000000000000000000000000000000000000000002"
/// The "Updates" system account sends release notes.
static let updatesPublicKey = "0x0000000000000000000000000000000000000000000000000000000000000001"
}
// MARK: - Keychain Keys
extension Account {
enum KeychainKey {
static let account = "currentAccount"
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
// MARK: - DeliveryStatus
enum DeliveryStatus: Int, Codable {
case waiting = 0
case delivered = 1
case error = 2
case read = 3
}
// MARK: - Dialog
/// Represents a chat dialog between the current user and an opponent.
/// Mirrors the Android `DialogEntity` Room database model.
struct Dialog: Identifiable, Codable, Equatable {
let id: String
let account: String // My public key (secp256k1 compressed hex)
let opponentKey: String // Opponent's public key
var opponentTitle: String // Display name
var opponentUsername: String // @username
var lastMessage: String // Last message preview (decrypted)
var lastMessageTimestamp: Int64
var unreadCount: Int
var isOnline: Bool
var lastSeen: Int64
var isVerified: Bool
var iHaveSent: Bool // I have sent at least one message (chat vs request)
var isPinned: Bool
var isMuted: Bool
var lastMessageFromMe: Bool
var lastMessageDelivered: DeliveryStatus
// MARK: - Computed
var isSavedMessages: Bool { opponentKey == account }
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: opponentKey)
}
var initials: String {
if isSavedMessages { return "S" }
return RosettaColors.initials(name: opponentTitle, publicKey: opponentKey)
}
}

View File

@@ -0,0 +1,109 @@
import Foundation
import Observation
/// In-memory dialog store, updated from incoming protocol packets.
@Observable
@MainActor
final class DialogRepository {
static let shared = DialogRepository()
private(set) var dialogs: [String: Dialog] = [:]
var sortedDialogs: [Dialog] {
Array(dialogs.values).sorted {
if $0.isPinned != $1.isPinned { return $0.isPinned }
return $0.lastMessageTimestamp > $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
}
}

View File

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

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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..<attachmentCount {
list.append(MessageAttachment(
id: stream.readString(),
preview: stream.readString(),
blob: stream.readString(),
type: AttachmentType(rawValue: stream.readInt8()) ?? .image
))
}
attachments = list
// No aesChachaKey Android doesn't read it
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
/// OnlineState packet (0x05) batch presence updates.
/// Matches TypeScript server: count (Int8) + array of (publicKey, boolean).
struct OnlineStateEntry {
var publicKey: String
var isOnline: Bool
}
struct PacketOnlineState: Packet {
static let packetId = 0x05
var entries: [OnlineStateEntry] = []
func write(to stream: Stream) {
// Client doesn't send this packet
}
mutating func read(from stream: Stream) {
let count = stream.readInt8()
var list: [OnlineStateEntry] = []
for _ in 0..<count {
let publicKey = stream.readString()
let isOnline = stream.readBoolean()
list.append(OnlineStateEntry(publicKey: publicKey, isOnline: isOnline))
}
entries = list
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
/// Read packet (0x07) read receipt notification.
/// Field order matches TypeScript server: privateKey, fromPublicKey, toPublicKey.
struct PacketRead: Packet {
static let packetId = 0x07
var privateKey: String = ""
var fromPublicKey: String = ""
var toPublicKey: String = ""
func write(to stream: Stream) {
stream.writeString(privateKey)
stream.writeString(fromPublicKey)
stream.writeString(toPublicKey)
}
mutating func read(from stream: Stream) {
privateKey = stream.readString()
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
/// Result codes sent by the server.
enum ResultCode: Int {
case success = 0
case error = 1
case invalid = 2
case usernameTaken = 3
}
/// Result packet (0x02) server response to user actions.
struct PacketResult: Packet {
static let packetId = 0x02
var resultCode: Int = 0
func write(to stream: Stream) {
stream.writeInt16(resultCode)
}
mutating func read(from stream: Stream) {
resultCode = stream.readInt16()
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
/// Search packet (0x03) search for users by username or public key.
struct PacketSearch: Packet {
static let packetId = 0x03
var privateKey: String = ""
var search: String = ""
var users: [SearchUser] = []
func write(to stream: Stream) {
stream.writeString(privateKey)
stream.writeString(search)
// Must write users count (0) server's receive() always reads it
stream.writeInt16(users.count)
for user in users {
stream.writeString(user.username)
stream.writeString(user.title)
stream.writeString(user.publicKey)
stream.writeInt8(user.verified)
stream.writeInt8(user.online)
}
}
mutating func read(from stream: Stream) {
privateKey = stream.readString()
search = stream.readString()
let userCount = stream.readInt16()
var list: [SearchUser] = []
for _ in 0..<userCount {
list.append(SearchUser(
username: stream.readString(),
title: stream.readString(),
publicKey: stream.readString(),
verified: stream.readInt8(),
online: stream.readInt8()
))
}
users = list
}
}

View File

@@ -0,0 +1,33 @@
import Foundation
// MARK: - SyncStatus
enum SyncStatus: Int {
case notNeeded = 0
case batchStart = 1
case batchEnd = 2
init(value: Int) {
self = SyncStatus(rawValue: value) ?? .notNeeded
}
}
// MARK: - PacketSync (0x19)
/// Sync packet batch message synchronization.
struct PacketSync: Packet {
static let packetId = 0x19
var status: SyncStatus = .notNeeded
var timestamp: Int64 = 0
func write(to stream: Stream) {
stream.writeInt8(status.rawValue)
stream.writeInt64(timestamp)
}
mutating func read(from stream: Stream) {
status = SyncStatus(value: stream.readInt8())
timestamp = stream.readInt64()
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
/// Typing packet (0x0B) typing indicator.
/// Field order matches TypeScript: privateKey, fromPublicKey, toPublicKey.
struct PacketTyping: Packet {
static let packetId = 0x0B
var privateKey: String = ""
var fromPublicKey: String = ""
var toPublicKey: String = ""
func write(to stream: Stream) {
stream.writeString(privateKey)
stream.writeString(fromPublicKey)
stream.writeString(toPublicKey)
}
mutating func read(from stream: Stream) {
privateKey = stream.readString()
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
/// UserInfo packet (0x01) get/set user profile information.
/// Field order matches TypeScript server: username, avatar, title, privateKey.
struct PacketUserInfo: Packet {
static let packetId = 0x01
var username: String = ""
var avatar: String = ""
var title: String = ""
var privateKey: String = ""
func write(to stream: Stream) {
// Send: username, avatar, title, privateKey (match TypeScript server)
stream.writeString(username)
stream.writeString(avatar)
stream.writeString(title)
stream.writeString(privateKey)
}
mutating func read(from stream: Stream) {
// Receive: username, avatar, title, privateKey (match TypeScript server)
username = stream.readString()
avatar = stream.readString()
title = stream.readString()
privateKey = stream.readString()
}
}

View File

@@ -0,0 +1,302 @@
import Foundation
import os
import Observation
import UIKit
// MARK: - Connection State
enum ConnectionState: String {
case disconnected
case connecting
case connected
case handshaking
case authenticated
}
// MARK: - ProtocolManager
/// Central networking coordinator. Owns WebSocket, routes packets, manages handshake.
@Observable
final class ProtocolManager: @unchecked Sendable {
static let shared = ProtocolManager()
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Protocol")
// MARK: - Public State
private(set) var connectionState: ConnectionState = .disconnected
// MARK: - Callbacks
var onMessageReceived: ((PacketMessage) -> 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<Void, Never>?
private var handshakeTimeoutTask: Task<Void, Never>?
// 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)
}
}
}

View File

@@ -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..<length {
let code = readInt16()
if let scalar = Unicode.Scalar(code) {
result.append(Character(scalar))
}
}
return result
}
// MARK: - Bytes (Int32 length + raw Int8s)
func writeBytes(_ value: Data) {
writeInt32(value.count)
for byte in value {
writeInt8(Int(byte))
}
}
func readBytes() -> Data {
let length = readInt32()
var result = Data(capacity: length)
for _ in 0..<length {
result.append(UInt8(readInt8() & 0xFF))
}
return result
}
// MARK: - Private
private func ensureCapacity(_ index: Int) {
while bytes.count <= index {
bytes.append(0)
}
}
}

View File

@@ -0,0 +1,130 @@
import Foundation
import os
/// Native URLSession-based WebSocket client for Rosetta protocol.
final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketDelegate {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "WebSocket")
private let url = URL(string: "wss://wss.rosetta.im")!
private var session: URLSession!
private var webSocketTask: URLSessionWebSocketTask?
private var isManuallyClosed = false
private var reconnectTask: Task<Void, Never>?
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()
}
}
}

View File

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

View File

@@ -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")
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UIKit
// MARK: - Rosetta Color Tokens // MARK: - Rosetta Color Tokens
@@ -33,15 +34,22 @@ enum RosettaColors {
static let subtleBorder = Color.white.opacity(0.15) static let subtleBorder = Color.white.opacity(0.15)
static let cardFill = Color.white.opacity(0.06) 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 // MARK: Light Theme
enum Light { enum Light {
static let background = Color.white 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 surface = Color(hex: 0xF5F5F5)
static let text = Color.black static let text = Color.black
static let textSecondary = Color(hex: 0x666666) static let textSecondary = Color(hex: 0x3C3C43).opacity(0.6) // Figma subtitle gray
static let textTertiary = Color(hex: 0x999999) static let textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray
static let border = Color(hex: 0xE0E0E0) static let border = Color(hex: 0xE0E0E0)
static let divider = Color(hex: 0xEEEEEE) static let divider = Color(hex: 0xEEEEEE)
static let messageBubble = Color(hex: 0xF5F5F5) static let messageBubble = Color(hex: 0xF5F5F5)
@@ -65,6 +73,30 @@ enum RosettaColors {
static let inputBackground = Color(hex: 0x2A2A2A) 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) // MARK: Seed Word Colors (12 unique, matching Android)
static let seedWordColors: [Color] = [ static let seedWordColors: [Color] = [
@@ -82,18 +114,52 @@ enum RosettaColors {
Color(hex: 0xF7DC6F), Color(hex: 0xF7DC6F),
] ]
// MARK: Avatar Palette // MARK: Avatar Palette (11 colors, matching rosetta-android dark theme)
static let avatarColors: [(background: Color, text: Color)] = [ static let avatarColors: [(background: Color, text: Color)] = [
(Color(hex: 0xFF6B6B), .white), (Color(hex: 0x2D3548), Color(hex: 0x7DD3FC)), // blue
(Color(hex: 0x4ECDC4), .white), (Color(hex: 0x2D4248), Color(hex: 0x67E8F9)), // cyan
(Color(hex: 0x45B7D1), .white), (Color(hex: 0x39334C), Color(hex: 0xD8B4FE)), // grape
(Color(hex: 0xF7B731), .white), (Color(hex: 0x2D3F32), Color(hex: 0x86EFAC)), // green
(Color(hex: 0x5F27CD), .white), (Color(hex: 0x333448), Color(hex: 0xA5B4FC)), // indigo
(Color(hex: 0x00D2D3), .white), (Color(hex: 0x383F2D), Color(hex: 0xBEF264)), // lime
(Color(hex: 0xFF9FF3), .white), (Color(hex: 0x483529), Color(hex: 0xFDBA74)), // orange
(Color(hex: 0x54A0FF), .white), (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 // MARK: - Color Hex Initializer

View File

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

View File

@@ -23,10 +23,16 @@ struct GlassCard<Content: View>: View {
content() content()
.background { .background {
RoundedRectangle(cornerRadius: cornerRadius) RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.white.opacity(fillOpacity)) .fill(RosettaColors.adaptive(
light: Color.black.opacity(fillOpacity),
dark: Color.white.opacity(fillOpacity)
))
.overlay { .overlay {
RoundedRectangle(cornerRadius: cornerRadius) 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)
} }
} }
} }

View File

@@ -1,14 +1,57 @@
import SwiftUI import SwiftUI
import Lottie 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 let animationName: String
var loopMode: LottieLoopMode = .playOnce var loopMode: LottieLoopMode = .playOnce
var animationSpeed: CGFloat = 1.5 var animationSpeed: CGFloat = 1.5
var isPlaying: Bool = true 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 { 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.contentMode = .scaleAspectFit
animationView.loopMode = loopMode animationView.loopMode = loopMode
animationView.animationSpeed = animationSpeed animationView.animationSpeed = animationSpeed
@@ -21,13 +64,14 @@ struct LottieView: UIViewRepresentable {
} }
func updateUIView(_ uiView: LottieAnimationView, context: Context) { func updateUIView(_ uiView: LottieAnimationView, context: Context) {
uiView.loopMode = loopMode if isPlaying {
uiView.animationSpeed = animationSpeed if !uiView.isAnimationPlaying {
uiView.play()
if isPlaying && !uiView.isAnimationPlaying { }
uiView.play() } else {
} else if !isPlaying { if uiView.isAnimationPlaying {
uiView.stop() uiView.stop()
}
} }
} }
} }

View File

@@ -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"),
]
)
}
}

View File

@@ -280,7 +280,8 @@ private extension ConfirmSeedPhraseView {
#Preview { #Preview {
ConfirmSeedPhraseView( ConfirmSeedPhraseView(
seedPhrase: SeedPhraseGenerator.generate(), seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident"],
onConfirmed: {}, onConfirmed: {},
onBack: {} onBack: {}
) )

View File

@@ -194,8 +194,12 @@ private extension ImportSeedPhraseView {
showError("Please fill in all words") showError("Please fill in all words")
return return
} }
// TODO: Validate seed phrase with CryptoManager.validateSeedPhrase() let normalized = importedWords.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
seedPhrase = importedWords guard CryptoManager.shared.validateMnemonic(normalized) else {
showError("Invalid recovery phrase. Check each word for typos.")
return
}
seedPhrase = normalized
onContinue() onContinue()
} label: { } label: {
Text("Continue") Text("Continue")

View File

@@ -174,31 +174,16 @@ private extension SeedPhraseView {
isContentVisible = true isContentVisible = true
return return
} }
// TODO: Replace with real BIP39 generation from CryptoManager do {
seedPhrase = SeedPhraseGenerator.generate() seedPhrase = try CryptoManager.shared.generateMnemonic()
} catch {
// Entropy failure is extremely rare; show empty state rather than crash
seedPhrase = []
}
withAnimation { isContentVisible = true } 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 { #Preview {
SeedPhraseView(seedPhrase: .constant([]), onContinue: {}, onBack: {}) SeedPhraseView(seedPhrase: .constant([]), onContinue: {}, onBack: {})
.preferredColorScheme(.dark) .preferredColorScheme(.dark)

View File

@@ -11,6 +11,7 @@ struct SetPasswordView: View {
@State private var showPassword = false @State private var showPassword = false
@State private var showConfirmPassword = false @State private var showConfirmPassword = false
@State private var isCreating = false @State private var isCreating = false
@State private var errorMessage: String?
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
fileprivate enum Field { fileprivate enum Field {
@@ -46,6 +47,14 @@ struct SetPasswordView: View {
WeakPasswordWarning(password: password) WeakPasswordWarning(password: password)
infoCard 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(.horizontal, 24)
.padding(.top, 8) .padding(.top, 8)
@@ -219,23 +228,28 @@ private extension SetPasswordView {
guard canCreate else { return } guard canCreate else { return }
isCreating = true isCreating = true
// TODO: Implement real account creation: Task {
// 1. CryptoManager.generateKeyPairFromSeed(seedPhrase) do {
// 2. CryptoManager.encryptWithPassword(privateKey, password) _ = try await AccountManager.shared.createAccount(
// 3. CryptoManager.encryptWithPassword(seedPhrase.joined(separator: " "), password) seedPhrase: seedPhrase,
// 4. Save EncryptedAccount to persistence password: password
// 5. Authenticate with server via Protocol handshake )
// Start session (WebSocket + handshake) immediately after account creation
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { try await SessionManager.shared.startSession(password: password)
isCreating = false isCreating = false
onAccountCreated() onAccountCreated()
} catch {
isCreating = false
errorMessage = error.localizedDescription
}
} }
} }
} }
#Preview { #Preview {
SetPasswordView( SetPasswordView(
seedPhrase: SeedPhraseGenerator.generate(), seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident"],
isImportMode: false, isImportMode: false,
onAccountCreated: {}, onAccountCreated: {},
onBack: {} onBack: {}

View File

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

View File

@@ -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()
}

View File

@@ -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<Void, Never>?
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
)
}
}
}
}
}

View File

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

View File

@@ -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()
}

View File

@@ -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<Void, Never>?
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)
}
}

View File

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

View File

@@ -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<Page: View>: 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<Page>]
init(_ parent: OnboardingPager) {
self.parent = parent
// Create hosting controllers without triggering view loading
self.controllers = (0..<parent.count).map { i in
UIHostingController(rootView: parent.buildPage(i))
}
}
func pageViewController(
_ pvc: UIPageViewController,
viewControllerBefore vc: UIViewController
) -> 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
}
}
}

View File

@@ -5,33 +5,29 @@ struct OnboardingView: View {
let onStartMessaging: () -> Void let onStartMessaging: () -> Void
@State private var currentPage = 0 @State private var currentPage = 0
@State private var dragOffset: CGFloat = 0
@State private var activeIndex: CGFloat = 0
private let pages = OnboardingPages.all private let pages = OnboardingPages.all
private let springResponse: Double = 0.4
private let springDamping: Double = 0.85
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let screenWidth = geometry.size.width let pagerHeight = geometry.size.height * 0.32 + 152
ZStack { ZStack {
RosettaColors.Dark.background.ignoresSafeArea() RosettaColors.Dark.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() OnboardingPager(
.frame(height: geometry.size.height * 0.1) 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) pageIndicator()
.frame(height: geometry.size.height * 0.22)
Spacer().frame(height: 32)
textSection(screenWidth: screenWidth)
.frame(height: 120)
pageIndicator(screenWidth: screenWidth)
.padding(.top, 24) .padding(.top, 24)
Spacer() Spacer()
@@ -41,71 +37,72 @@ struct OnboardingView: View {
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
} }
} }
.gesture(swipeGesture(screenWidth: screenWidth))
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.statusBarHidden(false) .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 { private struct OnboardingPageSlide: View {
func lottieSection(screenWidth: CGFloat) -> some View { let page: OnboardingPage
ZStack { let screenWidth: CGFloat
ForEach(pages) { page in let screenHeight: CGFloat
LottieView(
animationName: page.animationName, @State private var isPlaying = false
loopMode: .playOnce,
animationSpeed: 1.5, var body: some View {
isPlaying: currentPage == page.id VStack(spacing: 0) {
) Spacer().frame(height: screenHeight * 0.1)
.frame(width: screenWidth * 0.42, height: screenWidth * 0.42)
.offset(x: textOffset(for: page.id, screenWidth: screenWidth)) 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) .frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped() .background(RosettaColors.Dark.background)
.accessibilityLabel("Illustration for \(pages[currentPage].title)") .onAppear { isPlaying = true }
} .onDisappear { isPlaying = false }
} .accessibilityElement(children: .contain)
.accessibilityLabel("\(page.title). \(page.description)")
// 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)")
} }
func highlightedDescription(page: OnboardingPage) -> some View { private func textBlock() -> some View {
let lines = page.description.components(separatedBy: "\n") VStack(spacing: 14) {
return VStack(spacing: 4) { Text(page.title)
ForEach(lines, id: \.self) { line in .font(.system(size: 32, weight: .bold))
buildHighlightedLine(line, highlights: page.highlightedWords) .foregroundStyle(.white)
}
highlightedText()
} }
.frame(width: screenWidth)
.multilineTextAlignment(.center) .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) var attributed = AttributedString(line)
for word in highlights { for word in page.highlightedWords {
if let range = attributed.range(of: word, options: .caseInsensitive) { if let range = attributed.range(of: word, options: .caseInsensitive) {
attributed[range].foregroundColor = RosettaColors.primaryBlue attributed[range].foregroundColor = RosettaColors.primaryBlue
attributed[range].font = .system(size: 17, weight: .semibold) attributed[range].font = .system(size: 17, weight: .semibold)
@@ -115,56 +112,27 @@ private extension OnboardingView {
.font(.system(size: 17)) .font(.system(size: 17))
.foregroundStyle(RosettaColors.secondaryText) .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 { private extension OnboardingView {
func pageIndicator(screenWidth: CGFloat) -> some View { func pageIndicator() -> some View {
let dotSize: CGFloat = 7 let dotSize: CGFloat = 7
let spacing: CGFloat = 15 let spacing: CGFloat = 15
let count = pages.count
let center = CGFloat(count - 1) / 2.0
let frac = activeIndex - floor(activeIndex) return HStack(spacing: spacing - dotSize) {
let distFromWhole = min(frac, 1.0 - frac) ForEach(0..<pages.count, id: \.self) { i in
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..<count, id: \.self) { i in
let dist = abs(CGFloat(i) - activeIndex)
Circle() Circle()
.fill(Color.white.opacity(dist < 0.6 ? 0.0 : 0.3)) .fill(currentPage == i ? RosettaColors.primaryBlue : Color.white.opacity(0.3))
.frame(width: dotSize, height: dotSize) .frame(width: dotSize, height: dotSize)
.offset(x: (CGFloat(i) - center) * spacing) .animation(.easeInOut(duration: 0.25), value: currentPage)
} }
Capsule()
.fill(RosettaColors.primaryBlue)
.frame(width: capsuleWidth, height: capsuleHeight)
.offset(x: capsuleX)
} }
.frame(height: dotSize + 4) .frame(height: dotSize + 4)
.accessibilityLabel("Page \(currentPage + 1) of \(pages.count)") .accessibilityLabel("Page \(currentPage + 1) of \(pages.count)")
} }
}
// MARK: - Start Button
private extension OnboardingView {
func startButton() -> some View { func startButton() -> some View {
Button(action: onStartMessaging) { Button(action: onStartMessaging) {
Text("Start Messaging") 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 // MARK: - Preview
#Preview { #Preview {

View File

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

View File

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

View File

@@ -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: {})
}

View File

@@ -1,45 +1,98 @@
//
// RosettaApp.swift
// Rosetta
//
// Created by Gaidar Timirbaev on 22.02.2026.
//
import SwiftUI import SwiftUI
// MARK: - App State
private enum AppState {
case splash
case onboarding
case auth
case unlock
case main
}
// MARK: - RosettaApp
@main @main
struct RosettaApp: App { 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 @AppStorage("isLoggedIn") private var isLoggedIn = false
@State private var appState: AppState = .splash
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
rootView ZStack {
.preferredColorScheme(.dark) RosettaColors.Adaptive.background
.ignoresSafeArea()
rootView
}
} }
} }
@ViewBuilder @ViewBuilder
private var rootView: some View { private var rootView: some View {
if !hasCompletedOnboarding { switch appState {
case .splash:
SplashView {
withAnimation(.easeInOut(duration: 0.4)) {
determineNextState()
}
}
case .onboarding:
OnboardingView { OnboardingView {
withAnimation(.easeInOut(duration: 0.4)) { withAnimation(.easeInOut(duration: 0.4)) {
hasCompletedOnboarding = true hasCompletedOnboarding = true
appState = .auth
} }
} }
} else if !isLoggedIn {
case .auth:
AuthCoordinator { AuthCoordinator {
withAnimation(.easeInOut(duration: 0.4)) { withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true 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 { } else {
// TODO: Replace with main ChatListView // Onboarding done but no account go to auth
Text("Welcome to Rosetta") appState = .auth
.font(RosettaFont.headlineLarge)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RosettaColors.Dark.background)
} }
} }
} }