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:
11
.claude/settings.json
Normal file
11
.claude/settings.json
Normal 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
5
Info.plist
Normal 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>
|
||||||
@@ -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 */;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
BIN
Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Rosetta/Assets.xcassets/RosettaIcon.imageset/Contents.json
vendored
Normal file
21
Rosetta/Assets.xcassets/RosettaIcon.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Rosetta/Assets.xcassets/RosettaIcon.imageset/rosetta_icon.png
vendored
Normal file
BIN
Rosetta/Assets.xcassets/RosettaIcon.imageset/rosetta_icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
269
Rosetta/Core/Crypto/BIP39WordList.swift
Normal file
269
Rosetta/Core/Crypto/BIP39WordList.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
379
Rosetta/Core/Crypto/CryptoManager.swift
Normal file
379
Rosetta/Core/Crypto/CryptoManager.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
119
Rosetta/Core/Crypto/KeychainManager.swift
Normal file
119
Rosetta/Core/Crypto/KeychainManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
725
Rosetta/Core/Crypto/MessageCrypto.swift
Normal file
725
Rosetta/Core/Crypto/MessageCrypto.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Rosetta/Core/Data/Models/Account.swift
Normal file
40
Rosetta/Core/Data/Models/Account.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Rosetta/Core/Data/Models/Dialog.swift
Normal file
52
Rosetta/Core/Data/Models/Dialog.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
109
Rosetta/Core/Data/Repositories/DialogRepository.swift
Normal file
109
Rosetta/Core/Data/Repositories/DialogRepository.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Rosetta/Core/Network/Protocol/Packets/Packet.swift
Normal file
74
Rosetta/Core/Network/Protocol/Packets/Packet.swift
Normal 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
|
||||||
|
}
|
||||||
20
Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift
Normal file
20
Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift
Normal file
57
Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift
Normal file
59
Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Rosetta/Core/Network/Protocol/Packets/PacketRead.swift
Normal file
23
Rosetta/Core/Network/Protocol/Packets/PacketRead.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Rosetta/Core/Network/Protocol/Packets/PacketResult.swift
Normal file
24
Rosetta/Core/Network/Protocol/Packets/PacketResult.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Rosetta/Core/Network/Protocol/Packets/PacketSearch.swift
Normal file
41
Rosetta/Core/Network/Protocol/Packets/PacketSearch.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Rosetta/Core/Network/Protocol/Packets/PacketSync.swift
Normal file
33
Rosetta/Core/Network/Protocol/Packets/PacketSync.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Rosetta/Core/Network/Protocol/Packets/PacketTyping.swift
Normal file
23
Rosetta/Core/Network/Protocol/Packets/PacketTyping.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Rosetta/Core/Network/Protocol/Packets/PacketUserInfo.swift
Normal file
28
Rosetta/Core/Network/Protocol/Packets/PacketUserInfo.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
302
Rosetta/Core/Network/Protocol/ProtocolManager.swift
Normal file
302
Rosetta/Core/Network/Protocol/ProtocolManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
Rosetta/Core/Network/Protocol/Stream.swift
Normal file
176
Rosetta/Core/Network/Protocol/Stream.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
Rosetta/Core/Network/Protocol/WebSocketClient.swift
Normal file
130
Rosetta/Core/Network/Protocol/WebSocketClient.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Rosetta/Core/Services/AccountManager.swift
Normal file
129
Rosetta/Core/Services/AccountManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
221
Rosetta/Core/Services/SessionManager.swift
Normal file
221
Rosetta/Core/Services/SessionManager.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
69
Rosetta/DesignSystem/Components/AvatarView.swift
Normal file
69
Rosetta/DesignSystem/Components/AvatarView.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
234
Rosetta/DesignSystem/Components/RosettaTabBar.swift
Normal file
234
Rosetta/DesignSystem/Components/RosettaTabBar.swift
Normal 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"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
256
Rosetta/Features/Auth/UnlockView.swift
Normal file
256
Rosetta/Features/Auth/UnlockView.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
383
Rosetta/Features/Chats/ChatList/ChatListView.swift
Normal file
383
Rosetta/Features/Chats/ChatList/ChatListView.swift
Normal 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()
|
||||||
|
}
|
||||||
153
Rosetta/Features/Chats/ChatList/ChatListViewModel.swift
Normal file
153
Rosetta/Features/Chats/ChatList/ChatListViewModel.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
Rosetta/Features/Chats/ChatList/ChatRowView.swift
Normal file
220
Rosetta/Features/Chats/ChatList/ChatRowView.swift
Normal 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)
|
||||||
|
}
|
||||||
340
Rosetta/Features/Chats/Search/SearchView.swift
Normal file
340
Rosetta/Features/Chats/Search/SearchView.swift
Normal 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()
|
||||||
|
}
|
||||||
180
Rosetta/Features/Chats/Search/SearchViewModel.swift
Normal file
180
Rosetta/Features/Chats/Search/SearchViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Rosetta/Features/MainTabView.swift
Normal file
87
Rosetta/Features/MainTabView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Rosetta/Features/Onboarding/OnboardingPager.swift
Normal file
79
Rosetta/Features/Onboarding/OnboardingPager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
219
Rosetta/Features/Settings/SettingsView.swift
Normal file
219
Rosetta/Features/Settings/SettingsView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Rosetta/Features/Settings/SettingsViewModel.swift
Normal file
52
Rosetta/Features/Settings/SettingsViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Rosetta/Features/Splash/SplashView.swift
Normal file
57
Rosetta/Features/Splash/SplashView.swift
Normal 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: {})
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user