mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:25:19 +00:00
Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53826d5145 | ||
|
|
f58955f194 | ||
|
|
537737f521 | ||
|
|
b58c71f777 | ||
|
|
b4e2746be6 | ||
|
|
91040f7ed4 | ||
|
|
588d8fef0d | ||
|
|
8cd5a09a86 | ||
|
|
121e1d246a | ||
|
|
e5028e5e86 | ||
|
|
6681ca11c3 | ||
|
|
81b5dd15c1 | ||
|
|
098f223906 | ||
|
|
d31dd8300f | ||
|
|
85b627945d | ||
|
|
035ad175a7 | ||
|
|
b995a3fe4f | ||
|
|
fb26db3bf0 | ||
|
|
de4bf0a471 | ||
|
|
f493b50163 | ||
|
|
4aa12c08e7 | ||
|
|
c2a0c86542 | ||
|
|
073e22e126 | ||
|
|
6533293f75 | ||
|
|
757acef8d1 | ||
|
|
db52c9463b | ||
|
|
b179d99cf8 | ||
|
|
73d0867c18 | ||
|
|
97f822b88d | ||
|
|
3f91d6510b | ||
|
|
2bb55cbe1a | ||
|
|
7fb93eb522 | ||
|
|
cb8b34f8ea | ||
|
|
b872113a4b | ||
|
|
de3795289d | ||
|
|
bd37cc69a0 | ||
|
|
788e21c4ea | ||
|
|
c179e34c43 | ||
|
|
aa8b257e68 | ||
|
|
7b4aeb506e | ||
|
|
5d5ed94952 | ||
|
|
4945688eca | ||
|
|
22bd975059 | ||
|
|
d28b58ecb2 | ||
|
|
e17163b3da | ||
|
|
9346e62971 | ||
|
|
a89fd153ee | ||
|
|
25bc737919 | ||
|
|
8218c12f69 | ||
|
|
60c2263a46 | ||
|
|
e2fcb44982 | ||
|
|
ebbb7b356f | ||
|
|
2d0f55ae84 | ||
|
|
51e8e4e51a | ||
|
|
fb251a3fa8 | ||
|
|
4052ba581a | ||
|
|
235fefe4ab | ||
|
|
8389961269 | ||
|
|
a244c4084f | ||
|
|
ed320fb0ad | ||
|
|
2bdc1535c7 | ||
|
|
60a375469a | ||
|
|
640567b7e4 | ||
|
|
f58bcaf615 | ||
|
|
6e19995de2 | ||
|
|
78d72f5814 | ||
|
|
f145d13992 | ||
|
|
f607413caf | ||
|
|
47db836a22 | ||
|
|
aa7a5efe6a | ||
|
|
c8d196f106 | ||
|
|
2cd90ff813 | ||
|
|
5267489fa2 | ||
|
|
790dcda8e5 | ||
|
|
3d914dcf46 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 |
@@ -24,4 +24,4 @@ jobs:
|
|||||||
run: swift build
|
run: swift build
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
run: swift test --parallel
|
||||||
|
|||||||
+5
-4
@@ -9,9 +9,6 @@ plans/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
## User settings
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
*.xccheckout
|
*.xccheckout
|
||||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
|||||||
|
|
||||||
## Xcode project
|
## Xcode project
|
||||||
*.xcodeproj/project.xcworkspace/
|
*.xcodeproj/project.xcworkspace/
|
||||||
*.xcodeproj/xcshareddata/
|
## Xcode User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -68,6 +66,9 @@ __pycache__/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
## Cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.3
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
+9
-14
@@ -4,6 +4,7 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v16),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
@@ -15,6 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -24,8 +26,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.target(name: "TorC"),
|
.product(name: "Tor", package: "Tor")
|
||||||
.target(name: "tor-nolzma")
|
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -33,21 +34,12 @@ let package = Package(
|
|||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"Services/Tor/C/"
|
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
resources: [
|
||||||
.linkedLibrary("z")
|
.process("Localizable.xcstrings")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "TorC",
|
|
||||||
path: "bitchat/Services/Tor/C"
|
|
||||||
),
|
|
||||||
.binaryTarget(
|
|
||||||
name: "tor-nolzma",
|
|
||||||
path: "Frameworks/tor-nolzma.xcframework"
|
|
||||||
),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: ["bitchat"],
|
||||||
@@ -55,6 +47,9 @@ let package = Package(
|
|||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localization")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+32
-21
@@ -7,15 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
|
||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||||
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -58,11 +57,10 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
|
||||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -87,7 +85,6 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
LaunchScreen.storyboard,
|
LaunchScreen.storyboard,
|
||||||
Services/Tor/C/include/.gitkeep,
|
|
||||||
);
|
);
|
||||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||||
};
|
};
|
||||||
@@ -136,32 +133,22 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
|
||||||
);
|
|
||||||
path = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
18198ED912AAF495D8AF7763 = {
|
18198ED912AAF495D8AF7763 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -170,8 +157,8 @@
|
|||||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||||
A6E367C92E76469E0032EA8A /* Configs */,
|
A6E367C92E76469E0032EA8A /* Configs */,
|
||||||
0481A3542E6D877600FC845E /* Frameworks */,
|
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -187,6 +174,14 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -207,6 +202,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -284,6 +280,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -315,6 +312,7 @@
|
|||||||
ne,
|
ne,
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
ru,
|
ru,
|
||||||
|
tr,
|
||||||
uk,
|
uk,
|
||||||
"zh-Hans",
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
@@ -323,6 +321,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -880,6 +879,10 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Tor;
|
||||||
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
@@ -907,6 +910,14 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitLogger;
|
productName = BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||||
|
BuildableName = "bitchatShareExtension.appex"
|
||||||
|
BlueprintName = "bitchatShareExtension"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
codeCoverageEnabled = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<CodeCoverageTargets>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</CodeCoverageTargets>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||||
|
BuildableName = "bitchatTests_iOS.xctest"
|
||||||
|
BlueprintName = "bitchatTests_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||||
|
BuildableName = "bitchatTests_macOS.xctest"
|
||||||
|
BlueprintName = "bitchatTests_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
|
value = "debug"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 1.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 2.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
wrappedValue: ChatViewModel(
|
wrappedValue: ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -49,7 +54,7 @@ struct BitchatApp: App {
|
|||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -216,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum ImageUtilsError: Error {
|
||||||
|
case invalidImage
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImageUtils {
|
||||||
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
|
private static let targetImageBytes: Int = 60_000
|
||||||
|
|
||||||
|
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
// Allow up to 10MB source images (will be scaled down)
|
||||||
|
guard fileSize <= 10 * 1024 * 1024 else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
#if os(iOS)
|
||||||
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
|
#else
|
||||||
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return rendered ?? image
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
let width = inputCG.width
|
||||||
|
let height = inputCG.height
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
let scaledImage = NSImage(size: newSize)
|
||||||
|
scaledImage.lockFocus()
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||||
|
from: NSRect(origin: .zero, size: size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
scaledImage.unlockFocus()
|
||||||
|
return scaledImage
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private static func makeOutputURL() throws -> URL {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||||
|
|
||||||
|
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func applicationFilesDirectory() throws -> URL {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
return base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||||
|
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||||
|
@Published private(set) var isPlaying: Bool = false
|
||||||
|
@Published private(set) var currentTime: TimeInterval = 0
|
||||||
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
|
private var player: AVAudioPlayer?
|
||||||
|
private var timer: Timer?
|
||||||
|
private var url: URL
|
||||||
|
|
||||||
|
init(url: URL) {
|
||||||
|
self.url = url
|
||||||
|
super.init()
|
||||||
|
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDuration() {
|
||||||
|
guard duration == 0 else { return }
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||||
|
let loadedDuration = player.duration
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self, self.duration == 0 else { return }
|
||||||
|
self.duration = loadedDuration
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceURL(_ url: URL) {
|
||||||
|
guard url != self.url else { return }
|
||||||
|
stop()
|
||||||
|
self.url = url
|
||||||
|
player = nil
|
||||||
|
duration = 0
|
||||||
|
// Duration will be loaded on demand when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayback() {
|
||||||
|
isPlaying ? pause() : play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
guard ensurePlayerReady() else { return }
|
||||||
|
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||||
|
player?.play()
|
||||||
|
startTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player?.pause()
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
player?.stop()
|
||||||
|
player?.currentTime = 0
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to fraction: Double) {
|
||||||
|
guard ensurePlayerReady() else { return }
|
||||||
|
let clamped = max(0, min(1, fraction))
|
||||||
|
if let player = player {
|
||||||
|
player.currentTime = clamped * player.duration
|
||||||
|
if isPlaying {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVAudioPlayerDelegate
|
||||||
|
|
||||||
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||||
|
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopTimer()
|
||||||
|
self.updateProgress()
|
||||||
|
self.isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func preparePlayer(for url: URL) {
|
||||||
|
// Prepare player synchronously (only called when playback is requested)
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
|
player.delegate = self
|
||||||
|
player.prepareToPlay()
|
||||||
|
self.player = player
|
||||||
|
duration = player.duration
|
||||||
|
currentTime = player.currentTime
|
||||||
|
progress = duration > 0 ? currentTime / duration : 0
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
|
player = nil
|
||||||
|
duration = 0
|
||||||
|
currentTime = 0
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensurePlayerReady() -> Bool {
|
||||||
|
if player == nil {
|
||||||
|
preparePlayer(for: url)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return player != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
if timer != nil { return }
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
|
self?.updateProgress()
|
||||||
|
}
|
||||||
|
if let timer = timer {
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress() {
|
||||||
|
guard let player = player else {
|
||||||
|
currentTime = 0
|
||||||
|
duration = 0
|
||||||
|
progress = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentTime = player.currentTime
|
||||||
|
duration = player.duration
|
||||||
|
progress = duration > 0 ? currentTime / duration : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures only one voice note plays at a time.
|
||||||
|
final class VoiceNotePlaybackCoordinator {
|
||||||
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
|
private weak var activeController: VoiceNotePlaybackController?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func activate(_ controller: VoiceNotePlaybackController) {
|
||||||
|
if activeController === controller {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeController?.pause()
|
||||||
|
activeController = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||||
|
if activeController === controller {
|
||||||
|
activeController = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
|
enum RecorderError: Error {
|
||||||
|
case microphoneAccessDenied
|
||||||
|
case recorderInitializationFailed
|
||||||
|
case recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var stopWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permissions
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
func startRecording() throws -> URL {
|
||||||
|
try queue.sync {
|
||||||
|
if recorder?.isRecording == true {
|
||||||
|
throw RecorderError.recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
|
)
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 20_000
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.delegate = self
|
||||||
|
audioRecorder.isMeteringEnabled = true
|
||||||
|
audioRecorder.prepareToRecord()
|
||||||
|
audioRecorder.record()
|
||||||
|
|
||||||
|
recorder = audioRecorder
|
||||||
|
currentURL = outputURL
|
||||||
|
stopWorkItem?.cancel()
|
||||||
|
stopWorkItem = nil
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||||
|
completion(self?.currentURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
recorder.stop()
|
||||||
|
self.cleanupSession()
|
||||||
|
let url = self.currentURL
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = url
|
||||||
|
completion(url)
|
||||||
|
}
|
||||||
|
self.stopWorkItem = item
|
||||||
|
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRecording() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopWorkItem?.cancel()
|
||||||
|
self.stopWorkItem = nil
|
||||||
|
if let recorder = self.recorder, recorder.isRecording {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
self.cleanupSession()
|
||||||
|
if let url = self.currentURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metering
|
||||||
|
|
||||||
|
func currentAveragePower() -> Float {
|
||||||
|
queue.sync {
|
||||||
|
recorder?.updateMeters()
|
||||||
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeOutputURL() throws -> URL {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||||
|
|
||||||
|
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return baseDirectory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applicationFilesDirectory() throws -> URL {
|
||||||
|
#if os(iOS)
|
||||||
|
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
#else
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
return base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupSession() {
|
||||||
|
#if os(iOS)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||||
|
final class WaveformCache {
|
||||||
|
static let shared = WaveformCache()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||||
|
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||||
|
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func cachedWaveform(for url: URL) -> [Float]? {
|
||||||
|
queue.sync {
|
||||||
|
guard let entry = cache[url] else { return nil }
|
||||||
|
return entry.waveform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Check cache (read-only, no update needed on cache hit for performance)
|
||||||
|
if let entry = self.cache[url] {
|
||||||
|
DispatchQueue.main.async { completion(entry.waveform) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||||
|
DispatchQueue.main.async { completion([]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Evict oldest entry if cache is full
|
||||||
|
if self.cache.count >= self.maxCacheSize {
|
||||||
|
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||||
|
self.cache.removeValue(forKey: oldest.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache[url] = (computed, Date())
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { completion(computed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purge(url: URL) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeAll() {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
|
guard bins > 0 else { return nil }
|
||||||
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
return autoreleasepool {
|
||||||
|
do {
|
||||||
|
let audioFile = try AVAudioFile(forReading: url)
|
||||||
|
let length = Int(audioFile.length)
|
||||||
|
guard length > 0 else { return nil }
|
||||||
|
|
||||||
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||||
|
guard let channelData = buffer.floatChannelData else { return nil }
|
||||||
|
|
||||||
|
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
let samplesPerBin = max(1, frameLength / bins)
|
||||||
|
|
||||||
|
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||||
|
for bin in 0..<bins {
|
||||||
|
let start = bin * samplesPerBin
|
||||||
|
let end = min(frameLength, start + samplesPerBin)
|
||||||
|
if start >= end { break }
|
||||||
|
|
||||||
|
var sum: Float = 0
|
||||||
|
var sampleCount = 0
|
||||||
|
for frame in start..<end {
|
||||||
|
var sampleValue: Float = 0
|
||||||
|
for channel in 0..<channelCount {
|
||||||
|
sampleValue += fabsf(channelData[channel][frame])
|
||||||
|
}
|
||||||
|
sum += sampleValue / Float(channelCount)
|
||||||
|
sampleCount += 1
|
||||||
|
}
|
||||||
|
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||||
|
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||||
|
}
|
||||||
|
return magnitudes
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ import Foundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: String // 8 random bytes
|
let peerID: PeerID // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Cryptographic Identities
|
// MARK: Cryptographic Identities
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func getBlockedNostrPubkeys() -> Set<String>
|
func getBlockedNostrPubkeys() -> Set<String>
|
||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: String)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Verification
|
// MARK: Verification
|
||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
private var cache: IdentityCache = IdentityCache()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
guard peerID.isShort else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: String) {
|
func removeEphemeralSession(peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
Bitchat
|
|
||||||
|
|
||||||
Base English localization entries. Keep keys sorted alphabetically.
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "close";
|
|
||||||
"app_info.done" = "DONE";
|
|
||||||
"app_info.features.encryption.description" = "private messages encrypted with noise protocol";
|
|
||||||
"app_info.features.encryption.title" = "end-to-end encryption";
|
|
||||||
"app_info.features.extended_range.description" = "messages relay through peers, going the distance";
|
|
||||||
"app_info.features.extended_range.title" = "extended range";
|
|
||||||
"app_info.features.favorites.description" = "get notified when your favorite people join";
|
|
||||||
"app_info.features.favorites.title" = "favorites";
|
|
||||||
"app_info.features.geohash.description" = "geohash channels to chat with people in nearby regions over decentralized anonymous relays";
|
|
||||||
"app_info.features.geohash.title" = "local channels";
|
|
||||||
"app_info.features.mentions.description" = "use @nickname to notify specific people";
|
|
||||||
"app_info.features.mentions.title" = "mentions";
|
|
||||||
"app_info.features.offline.description" = "works without internet using Bluetooth low energy";
|
|
||||||
"app_info.features.offline.title" = "offline communication";
|
|
||||||
"app_info.features.title" = "FEATURES";
|
|
||||||
"app_info.how_to_use.change_channels" = "• tap #mesh to change channels";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• triple-tap chat to clear";
|
|
||||||
"app_info.how_to_use.commands" = "• type / for commands";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• tap people icon for sidebar";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• set your nickname by tapping it";
|
|
||||||
"app_info.how_to_use.start_dm" = "• tap a peer's name to start a DM";
|
|
||||||
"app_info.how_to_use.title" = "HOW TO USE";
|
|
||||||
"app_info.privacy.ephemeral.description" = "new peer ID generated regularly";
|
|
||||||
"app_info.privacy.ephemeral.title" = "ephemeral identity";
|
|
||||||
"app_info.privacy.no_tracking.description" = "no servers, accounts, or data collection";
|
|
||||||
"app_info.privacy.no_tracking.title" = "no tracking";
|
|
||||||
"app_info.privacy.panic.description" = "triple-tap logo to instantly clear all data";
|
|
||||||
"app_info.privacy.panic.title" = "panic mode";
|
|
||||||
"app_info.privacy.title" = "PRIVACY";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears.";
|
|
||||||
"app_info.warning.title" = "WARNING";
|
|
||||||
"common.cancel" = "cancel";
|
|
||||||
"common.close" = "close";
|
|
||||||
"common.copy" = "copy";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "off";
|
|
||||||
"common.toggle.on" = "on";
|
|
||||||
"common.unknown" = "unknown";
|
|
||||||
"content.accessibility.add_favorite" = "add to favorites";
|
|
||||||
"content.accessibility.available_nostr" = "available via Nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "back to main chat";
|
|
||||||
"content.accessibility.connected_mesh" = "connected via mesh";
|
|
||||||
"content.accessibility.encryption_status" = "encryption status: %@";
|
|
||||||
"content.accessibility.location_channels" = "location channels";
|
|
||||||
"content.accessibility.location_notes" = "location notes for this place";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "open unread private chat";
|
|
||||||
"content.accessibility.private_chat_header" = "private chat with %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "reachable via mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "remove from favorites";
|
|
||||||
"content.accessibility.send_hint_empty" = "enter a message to send";
|
|
||||||
"content.accessibility.send_hint_ready" = "double tap to send";
|
|
||||||
"content.accessibility.send_message" = "send message";
|
|
||||||
"content.accessibility.toggle_bookmark" = "toggle bookmark for #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "double tap to toggle favorite status";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "tap to view encryption fingerprint";
|
|
||||||
"content.actions.block" = "block";
|
|
||||||
"content.actions.direct_message" = "direct message";
|
|
||||||
"content.actions.hug" = "hug";
|
|
||||||
"content.actions.mention" = "mention";
|
|
||||||
"content.actions.slap" = "slap";
|
|
||||||
"content.actions.title" = "actions";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth is turned off. please turn on bluetooth in settings to use bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "settings";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetooth required";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "this device does not support bluetooth. bitchat requires bluetooth to function.";
|
|
||||||
"content.alert.screenshot.message" = "screenshots of location channels will reveal your location. think before sharing publicly.";
|
|
||||||
"content.alert.screenshot.title" = "heads up";
|
|
||||||
"content.commands.block" = "block or list blocked peers";
|
|
||||||
"content.commands.clear" = "clear chat messages";
|
|
||||||
"content.commands.favorite" = "add to favorites";
|
|
||||||
"content.commands.hug" = "send someone a warm hug";
|
|
||||||
"content.commands.message" = "send private message";
|
|
||||||
"content.commands.slap" = "slap someone with a trout";
|
|
||||||
"content.commands.unblock" = "unblock a peer";
|
|
||||||
"content.commands.unfavorite" = "remove from favorites";
|
|
||||||
"content.commands.who" = "see who's online";
|
|
||||||
"content.delivery.delivered_members" = "delivered to %1$d of %2$d members";
|
|
||||||
"content.delivery.delivered_to" = "delivered to %@";
|
|
||||||
"content.delivery.failed" = "failed: %@";
|
|
||||||
"content.delivery.read_by" = "read by %@";
|
|
||||||
"content.delivery.reason.blocked" = "user is blocked";
|
|
||||||
"content.delivery.reason.self" = "cannot message yourself";
|
|
||||||
"content.delivery.reason.send_error" = "send error";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "unknown recipient";
|
|
||||||
"content.delivery.reason.unreachable" = "peer not reachable";
|
|
||||||
"content.header.people" = "PEOPLE";
|
|
||||||
"content.help.verification" = "verification: show my QR or scan a friend";
|
|
||||||
"content.input.message_placeholder" = "type a message...";
|
|
||||||
"content.input.nickname_placeholder" = "nickname";
|
|
||||||
"content.location.enable" = "enable location";
|
|
||||||
"content.message.copy" = "copy message";
|
|
||||||
"content.message.show_less" = "show less";
|
|
||||||
"content.message.show_more" = "show more";
|
|
||||||
"content.notes.location_unavailable" = "location unavailable";
|
|
||||||
"content.notes.title" = "notes";
|
|
||||||
"content.payment.cashu" = "pay via cashu";
|
|
||||||
"content.payment.lightning" = "pay via lightning";
|
|
||||||
"encryption.accessibility.establishing" = "establishing encryption";
|
|
||||||
"encryption.accessibility.failed" = "encryption failed";
|
|
||||||
"encryption.accessibility.not_encrypted" = "not encrypted";
|
|
||||||
"encryption.accessibility.secured" = "encrypted";
|
|
||||||
"encryption.accessibility.verified" = "encrypted and verified";
|
|
||||||
"encryption.status.establishing" = "sstablishing encryption...";
|
|
||||||
"encryption.status.failed" = "encryption failed";
|
|
||||||
"encryption.status.not_encrypted" = "not encrypted";
|
|
||||||
"encryption.status.secured" = "encrypted";
|
|
||||||
"encryption.status.verified" = "encrypted & verified";
|
|
||||||
"fingerprint.action.mark_verified" = "mark as verified";
|
|
||||||
"fingerprint.action.remove_verification" = "remove verification";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ NOT VERIFIED";
|
|
||||||
"fingerprint.badge.verified" = "✓ VERIFIED";
|
|
||||||
"fingerprint.handshake_pending" = "not available - handshake in progress";
|
|
||||||
"fingerprint.message.verified" = "uou have verified this person's identity.";
|
|
||||||
"fingerprint.message.verify_hint" = "compare these fingerprints with %@ using a secure channel.";
|
|
||||||
"fingerprint.their_label" = "their fingerprint:";
|
|
||||||
"fingerprint.title" = "security verification";
|
|
||||||
"fingerprint.your_label" = "your fingerprint:";
|
|
||||||
"geohash_people.action.block" = "block";
|
|
||||||
"geohash_people.action.unblock" = "unblock";
|
|
||||||
"geohash_people.none_nearby" = "nobody around...";
|
|
||||||
"geohash_people.tooltip.blocked" = "blocked in geohash";
|
|
||||||
"geohash_people.you_suffix" = " (you)";
|
|
||||||
"location_channels.action.open_settings" = "open settings";
|
|
||||||
"location_channels.action.remove_access" = "remove location access";
|
|
||||||
"location_channels.action.request_permissions" = "get location and my geohashes";
|
|
||||||
"location_channels.action.teleport" = "teleport";
|
|
||||||
"location_channels.bookmarked_section_title" = "bookmarked";
|
|
||||||
"location_channels.description" = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "invalid geohash";
|
|
||||||
"location_channels.loading_nearby" = "finding nearby channels…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "location permission denied. enable in settings to use location channels.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#location channels";
|
|
||||||
"location_channels.tor.subtitle" = "hides your IP for location channels. recommended: on.";
|
|
||||||
"location_channels.tor.title" = "tor routing";
|
|
||||||
"location_levels.block" = "block";
|
|
||||||
"location_levels.building" = "building";
|
|
||||||
"location_levels.city" = "city";
|
|
||||||
"location_levels.neighborhood" = "neighborhood";
|
|
||||||
"location_levels.province" = "province";
|
|
||||||
"location_levels.region" = "region";
|
|
||||||
"location_notes.action.dismiss" = "dismiss";
|
|
||||||
"location_notes.action.retry" = "retry";
|
|
||||||
"location_notes.description" = "add short permanent notes to this location for other visitors to find.";
|
|
||||||
"location_notes.empty_subtitle" = "be the first to add one for this spot.";
|
|
||||||
"location_notes.empty_title" = "no notes yet";
|
|
||||||
"location_notes.error.failed_to_send" = "failed to send note. %@";
|
|
||||||
"location_notes.error.no_relays" = "no geo relays available near this location. try again soon.";
|
|
||||||
"location_notes.loading_notes" = "loading notes…";
|
|
||||||
"location_notes.loading_recent" = "loading recent notes…";
|
|
||||||
"location_notes.no_relays_nearby" = "no geo relays nearby";
|
|
||||||
"location_notes.placeholder" = "add a note for this place";
|
|
||||||
"location_notes.relays_paused" = "geo relays unavailable; notes paused";
|
|
||||||
"location_notes.relays_retry_hint" = "notes rely on geo relays. check connection and try again.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "new messages";
|
|
||||||
"system.chat.blocked" = "cannot start chat with %@: person is blocked.";
|
|
||||||
"system.chat.requires_favorite" = "cannot start chat with %@: mutual favorite required for offline messaging.";
|
|
||||||
"system.common.user" = "user";
|
|
||||||
"system.dm.blocked_generic" = "cannot send message: person is blocked.";
|
|
||||||
"system.dm.blocked_recipient" = "cannot send message to %@: person is blocked.";
|
|
||||||
"system.dm.unreachable" = "cannot send message to %@ - peer is not reachable via mesh or nostr.";
|
|
||||||
"system.geohash.blocked" = "blocked %@ in geohash chats";
|
|
||||||
"system.geohash.unblocked" = "unblocked %@ in geohash chats";
|
|
||||||
"system.location.not_in_channel" = "cannot send: not in a location channel";
|
|
||||||
"system.location.send_failed" = "failed to send to location channel";
|
|
||||||
"system.tor.dev_bypass" = "development build: Tor bypass enabled.";
|
|
||||||
"system.tor.restarted" = "tor restarted. network routing restored.";
|
|
||||||
"system.tor.restarting" = "tor restarting to recover connectivity...";
|
|
||||||
"system.tor.started" = "tor started. routing all chats via tor for IP privacy.";
|
|
||||||
"system.tor.starting" = "starting tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "verification QR code";
|
|
||||||
"verification.my_qr.title" = "scan to verify me";
|
|
||||||
"verification.my_qr.unavailable" = "QR unavailable";
|
|
||||||
"verification.scan.paste_prompt" = "paste QR content to validate:";
|
|
||||||
"verification.scan.prompt_friend" = "scan a friend's QR";
|
|
||||||
"verification.scan.status.invalid" = "invalid or expired QR payload";
|
|
||||||
"verification.scan.status.no_peer" = "could not find matching peer";
|
|
||||||
"verification.scan.status.requested" = "verification requested for %@";
|
|
||||||
"verification.scan.validate" = "validate";
|
|
||||||
"verification.sheet.title" = "VERIFY";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d note</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d notes</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d person</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d people</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d person</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d people</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Arabic)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "إغلاق";
|
|
||||||
"app_info.done" = "تم";
|
|
||||||
"app_info.features.encryption.description" = "الرسائل الخاصة مشفرة ببروتوكول noise";
|
|
||||||
"app_info.features.encryption.title" = "تشفير طرف لطرف";
|
|
||||||
"app_info.features.extended_range.description" = "يُعاد تمرير الرسائل بين الأقران لتصل لمسافات أبعد";
|
|
||||||
"app_info.features.extended_range.title" = "نطاق ممتد";
|
|
||||||
"app_info.features.favorites.description" = "تلقَّ تنبيهات عندما ينضم أحباؤك";
|
|
||||||
"app_info.features.favorites.title" = "المفضلة";
|
|
||||||
"app_info.features.geohash.description" = "قنوات geohash للدردشة مع أشخاص قريبين عبر مرحلات لامركزية مجهولة";
|
|
||||||
"app_info.features.geohash.title" = "قنوات محلية";
|
|
||||||
"app_info.features.mentions.description" = "استخدم @nickname لتنبيه أشخاص محددين";
|
|
||||||
"app_info.features.mentions.title" = "إشارات";
|
|
||||||
"app_info.features.offline.description" = "يعمل بدون إنترنت باستخدام bluetooth منخفض الطاقة";
|
|
||||||
"app_info.features.offline.title" = "تواصل بدون اتصال";
|
|
||||||
"app_info.features.title" = "مزايا";
|
|
||||||
"app_info.how_to_use.change_channels" = "• اضغط #mesh لتغيير القناة";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• اضغط الدردشة ثلاث مرات للمسح";
|
|
||||||
"app_info.how_to_use.commands" = "• اكتب / لعرض الأوامر";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• اضبط لقبك بلمسه";
|
|
||||||
"app_info.how_to_use.start_dm" = "• اضغط اسم القرين لبدء رسائل خاصة";
|
|
||||||
"app_info.how_to_use.title" = "طريقة الاستخدام";
|
|
||||||
"app_info.privacy.ephemeral.description" = "يُولد معرف قرين جديد بانتظام";
|
|
||||||
"app_info.privacy.ephemeral.title" = "هوية مؤقتة";
|
|
||||||
"app_info.privacy.no_tracking.description" = "لا خوادم أو حسابات أو جمع بيانات";
|
|
||||||
"app_info.privacy.no_tracking.title" = "لا تتبع";
|
|
||||||
"app_info.privacy.panic.description" = "اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً";
|
|
||||||
"app_info.privacy.panic.title" = "وضع الذعر";
|
|
||||||
"app_info.privacy.title" = "خصوصية";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "أمان الرسائل الخاصة لم يتم تدقيقه بالكامل بعد. لا تستخدمها في الحالات الحرجة حتى يختفي هذا التحذير.";
|
|
||||||
"app_info.warning.title" = "تحذير";
|
|
||||||
"common.cancel" = "إلغاء";
|
|
||||||
"common.close" = "إغلاق";
|
|
||||||
"common.copy" = "نسخ";
|
|
||||||
"common.ok" = "موافق";
|
|
||||||
"common.toggle.off" = "إيقاف";
|
|
||||||
"common.toggle.on" = "تشغيل";
|
|
||||||
"common.unknown" = "غير معروف";
|
|
||||||
"content.accessibility.add_favorite" = "إضافة إلى المفضلة";
|
|
||||||
"content.accessibility.available_nostr" = "متاح عبر nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "عودة إلى الدردشة الرئيسية";
|
|
||||||
"content.accessibility.connected_mesh" = "متصل عبر mesh";
|
|
||||||
"content.accessibility.encryption_status" = "حالة التشفير: %@";
|
|
||||||
"content.accessibility.location_channels" = "قنوات الموقع";
|
|
||||||
"content.accessibility.location_notes" = "ملاحظات الموقع لهذا المكان";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "فتح دردشة خاصة غير مقروءة";
|
|
||||||
"content.accessibility.private_chat_header" = "دردشة خاصة مع %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "قابل للوصول عبر mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "إزالة من المفضلة";
|
|
||||||
"content.accessibility.send_hint_empty" = "أدخل رسالة للإرسال";
|
|
||||||
"content.accessibility.send_hint_ready" = "اضغط مرتين للإرسال";
|
|
||||||
"content.accessibility.send_message" = "إرسال رسالة";
|
|
||||||
"content.accessibility.toggle_bookmark" = "تبديل الإشارة لـ #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "اضغط مرتين لتبديل حالة المفضلة";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "اضغط لمشاهدة بصمة التشفير";
|
|
||||||
"content.actions.block" = "حظر";
|
|
||||||
"content.actions.direct_message" = "رسالة مباشرة";
|
|
||||||
"content.actions.hug" = "عناق";
|
|
||||||
"content.actions.mention" = "ذكر";
|
|
||||||
"content.actions.slap" = "صفعة";
|
|
||||||
"content.actions.title" = "إجراءات";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth متوقف. فعّل bluetooth في الإعدادات لاستخدام bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "الإعدادات";
|
|
||||||
"content.alert.bluetooth_required.title" = "مطلوب bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "هذا الجهاز لا يدعم bluetooth. يحتاج bitchat إلى bluetooth للعمل.";
|
|
||||||
"content.alert.screenshot.message" = "لقطات قنوات الموقع تكشف موقعك. فكر قبل المشاركة علناً.";
|
|
||||||
"content.alert.screenshot.title" = "تنبيه";
|
|
||||||
"content.commands.block" = "حظر أو عرض المحظورين";
|
|
||||||
"content.commands.clear" = "مسح رسائل الدردشة";
|
|
||||||
"content.commands.favorite" = "إضافة للمفضلة";
|
|
||||||
"content.commands.hug" = "إرسال عناق دافئ";
|
|
||||||
"content.commands.message" = "إرسال رسالة خاصة";
|
|
||||||
"content.commands.slap" = "صفع شخص بسمكة تراوت";
|
|
||||||
"content.commands.unblock" = "إلغاء حظر قرين";
|
|
||||||
"content.commands.unfavorite" = "إزالة من المفضلة";
|
|
||||||
"content.commands.who" = "عرض من هو متصل";
|
|
||||||
"content.delivery.delivered_members" = "تم التسليم إلى %1$d من %2$d عضو";
|
|
||||||
"content.delivery.delivered_to" = "سُلّم إلى %@";
|
|
||||||
"content.delivery.failed" = "فشل: %@";
|
|
||||||
"content.delivery.read_by" = "قُرِئ بواسطة %@";
|
|
||||||
"content.delivery.reason.blocked" = "المستخدم محظور";
|
|
||||||
"content.delivery.reason.self" = "لا يمكن الإرسال لنفسك";
|
|
||||||
"content.delivery.reason.send_error" = "خطأ في الإرسال";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "مستلم غير معروف";
|
|
||||||
"content.delivery.reason.unreachable" = "القرين غير متاح";
|
|
||||||
"content.header.people" = "أشخاص";
|
|
||||||
"content.help.verification" = "التحقق: عرض رمز qr الخاص بي أو مسح صديق";
|
|
||||||
"content.input.message_placeholder" = "اكتب رسالة...";
|
|
||||||
"content.input.nickname_placeholder" = "لقب";
|
|
||||||
"content.location.enable" = "تفعيل الموقع";
|
|
||||||
"content.message.copy" = "نسخ الرسالة";
|
|
||||||
"content.message.show_less" = "عرض أقل";
|
|
||||||
"content.message.show_more" = "عرض المزيد";
|
|
||||||
"content.notes.location_unavailable" = "الموقع غير متاح";
|
|
||||||
"content.notes.title" = "ملاحظات";
|
|
||||||
"content.payment.cashu" = "الدفع عبر cashu";
|
|
||||||
"content.payment.lightning" = "الدفع عبر lightning";
|
|
||||||
"encryption.accessibility.establishing" = "جار إعداد التشفير";
|
|
||||||
"encryption.accessibility.failed" = "فشل التشفير";
|
|
||||||
"encryption.accessibility.not_encrypted" = "غير مشفر";
|
|
||||||
"encryption.accessibility.secured" = "مشفر";
|
|
||||||
"encryption.accessibility.verified" = "مشفر ومُتحقق";
|
|
||||||
"encryption.status.establishing" = "جار إعداد التشفير...";
|
|
||||||
"encryption.status.failed" = "فشل التشفير";
|
|
||||||
"encryption.status.not_encrypted" = "غير مشفر";
|
|
||||||
"encryption.status.secured" = "مشفر";
|
|
||||||
"encryption.status.verified" = "مشفر ومُتحقق";
|
|
||||||
"fingerprint.action.mark_verified" = "وضع علامة تم التحقق";
|
|
||||||
"fingerprint.action.remove_verification" = "إزالة التحقق";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ غير مُتحقق";
|
|
||||||
"fingerprint.badge.verified" = "✓ مُتحقق";
|
|
||||||
"fingerprint.handshake_pending" = "غير متاح - جار تنفيذ handshake";
|
|
||||||
"fingerprint.message.verified" = "لقد تحققت من هوية هذا الشخص.";
|
|
||||||
"fingerprint.message.verify_hint" = "قارن هذه البصمات مع %@ عبر قناة آمنة.";
|
|
||||||
"fingerprint.their_label" = "بصمتهم:";
|
|
||||||
"fingerprint.title" = "تحقق الأمان";
|
|
||||||
"fingerprint.your_label" = "بصمتك:";
|
|
||||||
"geohash_people.action.block" = "حظر";
|
|
||||||
"geohash_people.action.unblock" = "إلغاء الحظر";
|
|
||||||
"geohash_people.none_nearby" = "لا أحد قريب...";
|
|
||||||
"geohash_people.tooltip.blocked" = "محظور في geohash";
|
|
||||||
"geohash_people.you_suffix" = " (أنت)";
|
|
||||||
"location_channels.action.open_settings" = "فتح الإعدادات";
|
|
||||||
"location_channels.action.remove_access" = "إزالة صلاحية الموقع";
|
|
||||||
"location_channels.action.request_permissions" = "جلب موقعي و geohash";
|
|
||||||
"location_channels.action.teleport" = "انتقال فوري";
|
|
||||||
"location_channels.bookmarked_section_title" = "محفوظ";
|
|
||||||
"location_channels.description" = "تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash غير صالح";
|
|
||||||
"location_channels.loading_nearby" = "جار البحث عن قنوات قريبة…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "تم رفض إذن الموقع. فعّله في الإعدادات لاستخدام قنوات الموقع.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#قنوات الموقع";
|
|
||||||
"location_channels.tor.subtitle" = "يخفي ip لقنوات الموقع. الموصى به: تشغيل.";
|
|
||||||
"location_channels.tor.title" = "توجيه tor";
|
|
||||||
"location_levels.block" = "مربع";
|
|
||||||
"location_levels.building" = "مبنى";
|
|
||||||
"location_levels.city" = "مدينة";
|
|
||||||
"location_levels.neighborhood" = "حي";
|
|
||||||
"location_levels.province" = "مقاطعة";
|
|
||||||
"location_levels.region" = "منطقة";
|
|
||||||
"location_notes.action.dismiss" = "إغلاق";
|
|
||||||
"location_notes.action.retry" = "إعادة المحاولة";
|
|
||||||
"location_notes.description" = "أضف ملاحظات قصيرة دائمة لهذا المكان ليجدها الآخرون.";
|
|
||||||
"location_notes.empty_subtitle" = "كن أول من يضيف هنا.";
|
|
||||||
"location_notes.empty_title" = "لا توجد ملاحظات بعد";
|
|
||||||
"location_notes.error.failed_to_send" = "تعذر إرسال الملاحظة. %@";
|
|
||||||
"location_notes.error.no_relays" = "لا توجد مرحلات جغرافية قريبة من هذا المكان. حاول لاحقاً.";
|
|
||||||
"location_notes.loading_notes" = "جار تحميل الملاحظات…";
|
|
||||||
"location_notes.loading_recent" = "جار تحميل الملاحظات الحديثة…";
|
|
||||||
"location_notes.no_relays_nearby" = "لا مرحلات جغرافية قريبة";
|
|
||||||
"location_notes.placeholder" = "أضف ملاحظة لهذا المكان";
|
|
||||||
"location_notes.relays_paused" = "المرحلات الجغرافية غير متاحة؛ الملاحظات متوقفة";
|
|
||||||
"location_notes.relays_retry_hint" = "الملاحظات تعتمد على المرحلات الجغرافية. تحقق من الاتصال ثم أعد المحاولة.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "رسائل جديدة";
|
|
||||||
"system.chat.blocked" = "لا يمكن بدء دردشة مع %@: المستخدم محظور.";
|
|
||||||
"system.chat.requires_favorite" = "لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال.";
|
|
||||||
"system.common.user" = "مستخدم";
|
|
||||||
"system.dm.blocked_generic" = "تعذر الإرسال: المستخدم محظور.";
|
|
||||||
"system.dm.blocked_recipient" = "لا يمكن الإرسال إلى %@: المستخدم محظور.";
|
|
||||||
"system.dm.unreachable" = "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr.";
|
|
||||||
"system.geohash.blocked" = "تم حظر %@ في محادثات geohash";
|
|
||||||
"system.geohash.unblocked" = "تم إلغاء حظر %@ في محادثات geohash";
|
|
||||||
"system.location.not_in_channel" = "تعذر الإرسال: لست داخل قناة موقع";
|
|
||||||
"system.location.send_failed" = "تعذر الإرسال إلى قناة الموقع";
|
|
||||||
"system.tor.dev_bypass" = "بناء تطوير: تجاوز tor مفعل.";
|
|
||||||
"system.tor.restarted" = "tor أُعيد تشغيله. تمت استعادة التوجيه.";
|
|
||||||
"system.tor.restarting" = "tor يعاد تشغيله لاستعادة الاتصال...";
|
|
||||||
"system.tor.started" = "tor يعمل. كل الدردشة تمر عبر tor للخصوصية.";
|
|
||||||
"system.tor.starting" = "يتم تشغيل tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "رمز qr للتحقق";
|
|
||||||
"verification.my_qr.title" = "امسح للتحقق مني";
|
|
||||||
"verification.my_qr.unavailable" = "qr غير متاح";
|
|
||||||
"verification.scan.paste_prompt" = "الصق محتوى qr للتحقق:";
|
|
||||||
"verification.scan.prompt_friend" = "امسح qr لصديق";
|
|
||||||
"verification.scan.status.invalid" = "qr غير صالح أو منتهٍ";
|
|
||||||
"verification.scan.status.no_peer" = "لم يتم العثور على قرين مطابق";
|
|
||||||
"verification.scan.status.requested" = "تم طلب التحقق لـ %@";
|
|
||||||
"verification.scan.validate" = "تحقق";
|
|
||||||
"verification.sheet.title" = "تحقق";
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>zero</key>
|
|
||||||
<string>%d ملاحظات</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d ملاحظة</string>
|
|
||||||
<key>two</key>
|
|
||||||
<string>%d ملاحظتان</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d ملاحظات</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d ملاحظة</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d ملاحظة</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>zero</key>
|
|
||||||
<string>%d أشخاص</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d شخص</string>
|
|
||||||
<key>two</key>
|
|
||||||
<string>%d شخصان</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d أشخاص</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d شخص</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d شخص</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>zero</key>
|
|
||||||
<string>%d أشخاص</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d شخص</string>
|
|
||||||
<key>two</key>
|
|
||||||
<string>%d شخصان</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d أشخاص</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d شخص</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d شخص</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (German)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "schließen";
|
|
||||||
"app_info.done" = "FERTIG";
|
|
||||||
"app_info.features.encryption.description" = "private nachrichten werden mit dem noise-protokoll verschlüsselt";
|
|
||||||
"app_info.features.encryption.title" = "end-to-end-verschlüsselung";
|
|
||||||
"app_info.features.extended_range.description" = "nachrichten werden zwischen peers weitergeleitet und reichen weiter";
|
|
||||||
"app_info.features.extended_range.title" = "erweiterte reichweite";
|
|
||||||
"app_info.features.favorites.description" = "erhalte hinweise, wenn deine lieblingsmenschen online kommen";
|
|
||||||
"app_info.features.favorites.title" = "favoriten";
|
|
||||||
"app_info.features.geohash.description" = "geohash-kanäle zum chatten mit menschen in der nähe über dezentrale anonyme relays";
|
|
||||||
"app_info.features.geohash.title" = "lokale kanäle";
|
|
||||||
"app_info.features.mentions.description" = "nutze @nickname, um bestimmte personen zu benachrichtigen";
|
|
||||||
"app_info.features.mentions.title" = "erwähnungen";
|
|
||||||
"app_info.features.offline.description" = "funktioniert ohne internet per bluetooth low energy";
|
|
||||||
"app_info.features.offline.title" = "offline-kommunikation";
|
|
||||||
"app_info.features.title" = "FUNKTIONEN";
|
|
||||||
"app_info.how_to_use.change_channels" = "• tippe auf #mesh, um den kanal zu wechseln";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• tippe den chat dreimal, um ihn zu leeren";
|
|
||||||
"app_info.how_to_use.commands" = "• tippe /, um befehle zu sehen";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• tippe auf das personen-icon, um die seitenleiste zu öffnen";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• tippe auf deinen nickname, um ihn zu ändern";
|
|
||||||
"app_info.how_to_use.start_dm" = "• tippe auf den namen eines peers, um eine pn zu starten";
|
|
||||||
"app_info.how_to_use.title" = "SO FUNKTIONIERT'S";
|
|
||||||
"app_info.privacy.ephemeral.description" = "neue peer-id wird regelmäßig erzeugt";
|
|
||||||
"app_info.privacy.ephemeral.title" = "flüchtige identität";
|
|
||||||
"app_info.privacy.no_tracking.description" = "keine server, konten oder datensammlung";
|
|
||||||
"app_info.privacy.no_tracking.title" = "kein tracking";
|
|
||||||
"app_info.privacy.panic.description" = "tippe dreimal auf das logo, um alle daten sofort zu löschen";
|
|
||||||
"app_info.privacy.panic.title" = "panikmodus";
|
|
||||||
"app_info.privacy.title" = "PRIVATSPHÄRE";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "die sicherheit privater nachrichten wurde noch nicht vollständig geprüft. nutze sie nicht für kritische situationen, solange dieser hinweis erscheint.";
|
|
||||||
"app_info.warning.title" = "WARNUNG";
|
|
||||||
"common.cancel" = "abbrechen";
|
|
||||||
"common.close" = "schließen";
|
|
||||||
"common.copy" = "kopieren";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "aus";
|
|
||||||
"common.toggle.on" = "an";
|
|
||||||
"common.unknown" = "unbekannt";
|
|
||||||
"content.accessibility.add_favorite" = "zu favoriten hinzufügen";
|
|
||||||
"content.accessibility.available_nostr" = "verfügbar über nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "zurück zum hauptchat";
|
|
||||||
"content.accessibility.connected_mesh" = "verbunden über mesh";
|
|
||||||
"content.accessibility.encryption_status" = "verschlüsselungsstatus: %@";
|
|
||||||
"content.accessibility.location_channels" = "kanäle für standorte";
|
|
||||||
"content.accessibility.location_notes" = "standortnotizen für diesen ort";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "ungelesene privatnachricht öffnen";
|
|
||||||
"content.accessibility.private_chat_header" = "privatchat mit %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "erreichbar über mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "aus favoriten entfernen";
|
|
||||||
"content.accessibility.send_hint_empty" = "gib eine nachricht zum senden ein";
|
|
||||||
"content.accessibility.send_hint_ready" = "doppelt tippen zum senden";
|
|
||||||
"content.accessibility.send_message" = "nachricht senden";
|
|
||||||
"content.accessibility.toggle_bookmark" = "bookmark für #%@ umschalten";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "doppelt tippen, um favoritenstatus zu wechseln";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "tippe, um den verschlüsselungs-fingerprint zu sehen";
|
|
||||||
"content.actions.block" = "blockieren";
|
|
||||||
"content.actions.direct_message" = "direktnachricht";
|
|
||||||
"content.actions.hug" = "umarmen";
|
|
||||||
"content.actions.mention" = "erwähnen";
|
|
||||||
"content.actions.slap" = "ohrfeige";
|
|
||||||
"content.actions.title" = "aktionen";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth ist ausgeschaltet. aktiviere bluetooth in den einstellungen, um bitchat zu verwenden.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat benötigt bluetooth-berechtigung, um sich mit geräten in der nähe zu verbinden. erlaube den zugriff in den einstellungen.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "einstellungen";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetooth erforderlich";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "dieses gerät unterstützt kein bluetooth. bitchat benötigt bluetooth zum funktionieren.";
|
|
||||||
"content.alert.screenshot.message" = "screenshots von standortkanälen verraten deinen standort. überleg dir das teilen vorher gut.";
|
|
||||||
"content.alert.screenshot.title" = "achtung";
|
|
||||||
"content.commands.block" = "blocked peers anzeigen oder blockieren";
|
|
||||||
"content.commands.clear" = "chatnachrichten löschen";
|
|
||||||
"content.commands.favorite" = "zu favoriten hinzufügen";
|
|
||||||
"content.commands.hug" = "eine warme umarmung senden";
|
|
||||||
"content.commands.message" = "privatnachricht senden";
|
|
||||||
"content.commands.slap" = "jemandem eine forelle um die ohren schlagen";
|
|
||||||
"content.commands.unblock" = "peer entsperren";
|
|
||||||
"content.commands.unfavorite" = "aus favoriten entfernen";
|
|
||||||
"content.commands.who" = "sehen, wer online ist";
|
|
||||||
"content.delivery.delivered_members" = "zugestellt an %1$d von %2$d mitgliedern";
|
|
||||||
"content.delivery.delivered_to" = "zugestellt an %@";
|
|
||||||
"content.delivery.failed" = "fehlgeschlagen: %@";
|
|
||||||
"content.delivery.read_by" = "gelesen von %@";
|
|
||||||
"content.delivery.reason.blocked" = "nutzer blockiert";
|
|
||||||
"content.delivery.reason.self" = "kann nicht an dich selbst senden";
|
|
||||||
"content.delivery.reason.send_error" = "sende-fehler";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "unbekannter empfänger";
|
|
||||||
"content.delivery.reason.unreachable" = "peer nicht erreichbar";
|
|
||||||
"content.header.people" = "PERSONEN";
|
|
||||||
"content.help.verification" = "verifizierung: meinen qr zeigen oder freund scannen";
|
|
||||||
"content.input.message_placeholder" = "nachricht eingeben...";
|
|
||||||
"content.input.nickname_placeholder" = "nickname";
|
|
||||||
"content.location.enable" = "standort aktivieren";
|
|
||||||
"content.message.copy" = "nachricht kopieren";
|
|
||||||
"content.message.show_less" = "weniger anzeigen";
|
|
||||||
"content.message.show_more" = "mehr anzeigen";
|
|
||||||
"content.notes.location_unavailable" = "standort nicht verfügbar";
|
|
||||||
"content.notes.title" = "notizen";
|
|
||||||
"content.payment.cashu" = "per cashu bezahlen";
|
|
||||||
"content.payment.lightning" = "per lightning bezahlen";
|
|
||||||
"encryption.accessibility.establishing" = "verschlüsselung wird aufgebaut";
|
|
||||||
"encryption.accessibility.failed" = "verschlüsselung fehlgeschlagen";
|
|
||||||
"encryption.accessibility.not_encrypted" = "nicht verschlüsselt";
|
|
||||||
"encryption.accessibility.secured" = "verschlüsselt";
|
|
||||||
"encryption.accessibility.verified" = "verschlüsselt und verifiziert";
|
|
||||||
"encryption.status.establishing" = "verschlüsselung wird aufgebaut...";
|
|
||||||
"encryption.status.failed" = "verschlüsselung fehlgeschlagen";
|
|
||||||
"encryption.status.not_encrypted" = "nicht verschlüsselt";
|
|
||||||
"encryption.status.secured" = "verschlüsselt";
|
|
||||||
"encryption.status.verified" = "verschlüsselt und verifiziert";
|
|
||||||
"fingerprint.action.mark_verified" = "als verifiziert markieren";
|
|
||||||
"fingerprint.action.remove_verification" = "verifizierung entfernen";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ NICHT VERIFIZIERT";
|
|
||||||
"fingerprint.badge.verified" = "✓ VERIFIZIERT";
|
|
||||||
"fingerprint.handshake_pending" = "nicht verfügbar – handshake läuft";
|
|
||||||
"fingerprint.message.verified" = "du hast die identität dieser person verifiziert.";
|
|
||||||
"fingerprint.message.verify_hint" = "vergleiche diese fingerabdrücke mit %@ über einen sicheren kanal.";
|
|
||||||
"fingerprint.their_label" = "deren fingerabdruck:";
|
|
||||||
"fingerprint.title" = "sicherheitsverifizierung";
|
|
||||||
"fingerprint.your_label" = "dein fingerabdruck:";
|
|
||||||
"geohash_people.action.block" = "blockieren";
|
|
||||||
"geohash_people.action.unblock" = "entsperren";
|
|
||||||
"geohash_people.none_nearby" = "niemand in der nähe...";
|
|
||||||
"geohash_people.tooltip.blocked" = "in geohash blockiert";
|
|
||||||
"geohash_people.you_suffix" = " (du)";
|
|
||||||
"location_channels.action.open_settings" = "einstellungen öffnen";
|
|
||||||
"location_channels.action.remove_access" = "standortzugriff entfernen";
|
|
||||||
"location_channels.action.request_permissions" = "standort und geohash abrufen";
|
|
||||||
"location_channels.action.teleport" = "teleportieren";
|
|
||||||
"location_channels.bookmarked_section_title" = "gespeichert";
|
|
||||||
"location_channels.description" = "chatte mit menschen in deiner nähe über geohash-kanäle. geteilt wird nur ein grober geohash, niemals exakte gps-daten. deine ip bleibt verborgen, weil der gesamte verkehr über tor läuft.";
|
|
||||||
"location_channels.error.invalid_geohash" = "ungültiger geohash";
|
|
||||||
"location_channels.loading_nearby" = "suche nach kanälen in der nähe…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "standortberechtigung verweigert. aktiviere sie in den einstellungen für standortkanäle.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#standort-kanäle";
|
|
||||||
"location_channels.tor.subtitle" = "verbirgt deine ip für standortkanäle. empfohlen: an.";
|
|
||||||
"location_channels.tor.title" = "tor-routing";
|
|
||||||
"location_levels.block" = "block";
|
|
||||||
"location_levels.building" = "gebäude";
|
|
||||||
"location_levels.city" = "stadt";
|
|
||||||
"location_levels.neighborhood" = "viertel";
|
|
||||||
"location_levels.province" = "bundesland";
|
|
||||||
"location_levels.region" = "region";
|
|
||||||
"location_notes.action.dismiss" = "schließen";
|
|
||||||
"location_notes.action.retry" = "erneut versuchen";
|
|
||||||
"location_notes.description" = "füge diesem ort kurze dauerhafte notizen hinzu, damit andere sie finden.";
|
|
||||||
"location_notes.empty_subtitle" = "sei die erste person, die hier eine notiz hinterlässt.";
|
|
||||||
"location_notes.empty_title" = "noch keine notizen";
|
|
||||||
"location_notes.error.failed_to_send" = "notiz konnte nicht gesendet werden. %@";
|
|
||||||
"location_notes.error.no_relays" = "keine geo-relays in der nähe verfügbar. versuch es später erneut.";
|
|
||||||
"location_notes.loading_notes" = "notizen werden geladen…";
|
|
||||||
"location_notes.loading_recent" = "aktuelle notizen werden geladen…";
|
|
||||||
"location_notes.no_relays_nearby" = "keine geo-relays in der nähe";
|
|
||||||
"location_notes.placeholder" = "notiz für diesen ort hinzufügen";
|
|
||||||
"location_notes.relays_paused" = "geo-relays nicht verfügbar; notizen pausiert";
|
|
||||||
"location_notes.relays_retry_hint" = "notizen hängen von geo-relays ab. prüfe die verbindung und versuch es erneut.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "neue nachrichten";
|
|
||||||
"system.chat.blocked" = "chat mit %@ kann nicht gestartet werden: nutzer blockiert.";
|
|
||||||
"system.chat.requires_favorite" = "chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig.";
|
|
||||||
"system.common.user" = "nutzer";
|
|
||||||
"system.dm.blocked_generic" = "senden nicht möglich: nutzer blockiert.";
|
|
||||||
"system.dm.blocked_recipient" = "senden an %@ nicht möglich: nutzer blockiert.";
|
|
||||||
"system.dm.unreachable" = "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar.";
|
|
||||||
"system.geohash.blocked" = "%@ wurde in geohash-chats blockiert";
|
|
||||||
"system.geohash.unblocked" = "%@ wurde in geohash-chats entsperrt";
|
|
||||||
"system.location.not_in_channel" = "senden fehlgeschlagen: du bist nicht in einem standortkanal";
|
|
||||||
"system.location.send_failed" = "konnte nicht an den standortkanal senden";
|
|
||||||
"system.tor.dev_bypass" = "dev-build: tor-bypass aktiv.";
|
|
||||||
"system.tor.restarted" = "tor wurde neu gestartet. routing wiederhergestellt.";
|
|
||||||
"system.tor.restarting" = "tor startet neu, um die verbindung herzustellen...";
|
|
||||||
"system.tor.started" = "tor läuft. der gesamte chat wird über tor geleitet.";
|
|
||||||
"system.tor.starting" = "tor wird gestartet...";
|
|
||||||
"verification.my_qr.accessibility_label" = "verifizierungs-qr-code";
|
|
||||||
"verification.my_qr.title" = "scanne, um mich zu verifizieren";
|
|
||||||
"verification.my_qr.unavailable" = "qr nicht verfügbar";
|
|
||||||
"verification.scan.paste_prompt" = "füge den qr-inhalt zum prüfen ein:";
|
|
||||||
"verification.scan.prompt_friend" = "scanne den qr eines freundes";
|
|
||||||
"verification.scan.status.invalid" = "qr ungültig oder abgelaufen";
|
|
||||||
"verification.scan.status.no_peer" = "kein passender peer gefunden";
|
|
||||||
"verification.scan.status.requested" = "verifizierung für %@ angefordert";
|
|
||||||
"verification.scan.validate" = "prüfen";
|
|
||||||
"verification.sheet.title" = "VERIFIZIEREN";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d notiz</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d notizen</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d person</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d personen</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d person</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d personen</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Spanish)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "cerrar";
|
|
||||||
"app_info.done" = "LISTO";
|
|
||||||
"app_info.features.encryption.description" = "mensajes privados cifrados con el protocolo Noise";
|
|
||||||
"app_info.features.encryption.title" = "cifrado de extremo a extremo";
|
|
||||||
"app_info.features.extended_range.description" = "los mensajes se retransmiten entre pares y llegan lejos";
|
|
||||||
"app_info.features.extended_range.title" = "alcance ampliado";
|
|
||||||
"app_info.features.favorites.description" = "recibe avisos cuando tus personas favoritas se conecten";
|
|
||||||
"app_info.features.favorites.title" = "favoritos";
|
|
||||||
"app_info.features.geohash.description" = "canales geohash para chatear con personas en regiones cercanas a través de relays descentralizados anónimos";
|
|
||||||
"app_info.features.geohash.title" = "canales locales";
|
|
||||||
"app_info.features.mentions.description" = "usa @nickname para avisar a personas concretas";
|
|
||||||
"app_info.features.mentions.title" = "menciones";
|
|
||||||
"app_info.features.offline.description" = "funciona sin internet utilizando Bluetooth de bajo consumo";
|
|
||||||
"app_info.features.offline.title" = "comunicación sin conexión";
|
|
||||||
"app_info.features.title" = "FUNCIONES";
|
|
||||||
"app_info.how_to_use.change_channels" = "• toca #mesh para cambiar de canal";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• toca tres veces el chat para limpiarlo";
|
|
||||||
"app_info.how_to_use.commands" = "• escribe / para ver los comandos";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• toca el ícono de personas para abrir la barra lateral";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• define tu apodo tocándolo";
|
|
||||||
"app_info.how_to_use.start_dm" = "• toca el nombre de un participante para iniciar un MD";
|
|
||||||
"app_info.how_to_use.title" = "CÓMO USARLO";
|
|
||||||
"app_info.privacy.ephemeral.description" = "nuevo ID de peer generado periódicamente";
|
|
||||||
"app_info.privacy.ephemeral.title" = "identidad efímera";
|
|
||||||
"app_info.privacy.no_tracking.description" = "sin servidores, cuentas ni recopilación de datos";
|
|
||||||
"app_info.privacy.no_tracking.title" = "sin seguimiento";
|
|
||||||
"app_info.privacy.panic.description" = "toca el logotipo tres veces para borrar todos los datos al instante";
|
|
||||||
"app_info.privacy.panic.title" = "modo pánico";
|
|
||||||
"app_info.privacy.title" = "PRIVACIDAD";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "la seguridad de los mensajes privados aún no ha sido auditada por completo. no lo uses en situaciones críticas hasta que este aviso desaparezca.";
|
|
||||||
"app_info.warning.title" = "ADVERTENCIA";
|
|
||||||
"common.cancel" = "cancelar";
|
|
||||||
"common.close" = "cerrar";
|
|
||||||
"common.copy" = "copiar";
|
|
||||||
"common.ok" = "aceptar";
|
|
||||||
"common.toggle.off" = "desactivado";
|
|
||||||
"common.toggle.on" = "activado";
|
|
||||||
"common.unknown" = "desconocido";
|
|
||||||
"content.accessibility.add_favorite" = "agregar a favoritos";
|
|
||||||
"content.accessibility.available_nostr" = "disponible vía Nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "volver al chat principal";
|
|
||||||
"content.accessibility.connected_mesh" = "conectado por mesh";
|
|
||||||
"content.accessibility.encryption_status" = "estado de cifrado: %@";
|
|
||||||
"content.accessibility.location_channels" = "canales de ubicación";
|
|
||||||
"content.accessibility.location_notes" = "notas de ubicación de este lugar";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "abrir chat privado sin leer";
|
|
||||||
"content.accessibility.private_chat_header" = "chat privado con %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "disponible por mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "quitar de favoritos";
|
|
||||||
"content.accessibility.send_hint_empty" = "introduce un mensaje para enviarlo";
|
|
||||||
"content.accessibility.send_hint_ready" = "toca dos veces para enviar";
|
|
||||||
"content.accessibility.send_message" = "enviar mensaje";
|
|
||||||
"content.accessibility.toggle_bookmark" = "alternar marcador para #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "toca dos veces para alternar el estado de favorito";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "toca para ver la huella de cifrado";
|
|
||||||
"content.actions.block" = "bloquear";
|
|
||||||
"content.actions.direct_message" = "mensaje directo";
|
|
||||||
"content.actions.hug" = "abrazo";
|
|
||||||
"content.actions.mention" = "mencionar";
|
|
||||||
"content.actions.slap" = "bofetada";
|
|
||||||
"content.actions.title" = "acciones";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth está desactivado. Actívalo en Ajustes para usar BitChat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "ajustes";
|
|
||||||
"content.alert.bluetooth_required.title" = "se requiere Bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "este dispositivo no admite Bluetooth. BitChat necesita Bluetooth para funcionar.";
|
|
||||||
"content.alert.screenshot.message" = "las capturas de pantalla de los canales de ubicación revelarán tu ubicación. Piensa antes de compartirlas públicamente.";
|
|
||||||
"content.alert.screenshot.title" = "atención";
|
|
||||||
"content.commands.block" = "bloquear o listar usuarios bloqueados";
|
|
||||||
"content.commands.clear" = "borrar los mensajes del chat";
|
|
||||||
"content.commands.favorite" = "agregar a favoritos";
|
|
||||||
"content.commands.hug" = "enviar un abrazo caluroso";
|
|
||||||
"content.commands.message" = "enviar mensaje privado";
|
|
||||||
"content.commands.slap" = "abofetear a alguien con una trucha";
|
|
||||||
"content.commands.unblock" = "desbloquear a un usuario";
|
|
||||||
"content.commands.unfavorite" = "quitar de favoritos";
|
|
||||||
"content.commands.who" = "ver quién está en línea";
|
|
||||||
"content.delivery.delivered_members" = "entregado a %1$d de %2$d miembros";
|
|
||||||
"content.delivery.delivered_to" = "entregado a %@";
|
|
||||||
"content.delivery.failed" = "falló: %@";
|
|
||||||
"content.delivery.read_by" = "leído por %@";
|
|
||||||
"content.delivery.reason.blocked" = "el usuario está bloqueado";
|
|
||||||
"content.delivery.reason.self" = "no puedes enviarte mensajes a ti mismo";
|
|
||||||
"content.delivery.reason.send_error" = "error al enviar";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "destinatario desconocido";
|
|
||||||
"content.delivery.reason.unreachable" = "el destinatario no es alcanzable";
|
|
||||||
"content.header.people" = "PERSONAS";
|
|
||||||
"content.help.verification" = "verificación: mostrar mi QR o escanear a un amigo";
|
|
||||||
"content.input.message_placeholder" = "escribe un mensaje...";
|
|
||||||
"content.input.nickname_placeholder" = "apodo";
|
|
||||||
"content.location.enable" = "activar ubicación";
|
|
||||||
"content.message.copy" = "copiar mensaje";
|
|
||||||
"content.message.show_less" = "mostrar menos";
|
|
||||||
"content.message.show_more" = "mostrar más";
|
|
||||||
"content.notes.location_unavailable" = "ubicación no disponible";
|
|
||||||
"content.notes.title" = "notas";
|
|
||||||
"content.payment.cashu" = "pagar con Cashu";
|
|
||||||
"content.payment.lightning" = "pagar con Lightning";
|
|
||||||
"encryption.accessibility.establishing" = "estableciendo cifrado";
|
|
||||||
"encryption.accessibility.failed" = "cifrado fallido";
|
|
||||||
"encryption.accessibility.not_encrypted" = "sin cifrar";
|
|
||||||
"encryption.accessibility.secured" = "cifrado";
|
|
||||||
"encryption.accessibility.verified" = "cifrado y verificado";
|
|
||||||
"encryption.status.establishing" = "estableciendo cifrado...";
|
|
||||||
"encryption.status.failed" = "cifrado fallido";
|
|
||||||
"encryption.status.not_encrypted" = "sin cifrar";
|
|
||||||
"encryption.status.secured" = "cifrado";
|
|
||||||
"encryption.status.verified" = "cifrado y verificado";
|
|
||||||
"fingerprint.action.mark_verified" = "marcar como verificado";
|
|
||||||
"fingerprint.action.remove_verification" = "eliminar verificación";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ NO VERIFICADO";
|
|
||||||
"fingerprint.badge.verified" = "✓ VERIFICADO";
|
|
||||||
"fingerprint.handshake_pending" = "no disponible: el handshake está en curso";
|
|
||||||
"fingerprint.message.verified" = "has verificado la identidad de esta persona.";
|
|
||||||
"fingerprint.message.verify_hint" = "compara estas huellas con %@ mediante un canal seguro.";
|
|
||||||
"fingerprint.their_label" = "huella de la otra persona:";
|
|
||||||
"fingerprint.title" = "verificación de seguridad";
|
|
||||||
"fingerprint.your_label" = "tu huella:";
|
|
||||||
"geohash_people.action.block" = "bloquear";
|
|
||||||
"geohash_people.action.unblock" = "desbloquear";
|
|
||||||
"geohash_people.none_nearby" = "nadie cerca...";
|
|
||||||
"geohash_people.tooltip.blocked" = "bloqueado en geohash";
|
|
||||||
"geohash_people.you_suffix" = " (tú)";
|
|
||||||
"location_channels.action.open_settings" = "abrir ajustes";
|
|
||||||
"location_channels.action.remove_access" = "eliminar acceso a la ubicación";
|
|
||||||
"location_channels.action.request_permissions" = "obtener mi ubicación y mis geohashes";
|
|
||||||
"location_channels.action.teleport" = "teletransportar";
|
|
||||||
"location_channels.bookmarked_section_title" = "marcados";
|
|
||||||
"location_channels.description" = "chatea con personas cercanas usando canales geohash. Solo se comparte un geohash aproximado, nunca GPS exacto. Tu IP se oculta al enrutar todo el tráfico por Tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash no válido";
|
|
||||||
"location_channels.loading_nearby" = "buscando canales cercanos…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "permiso de ubicación denegado. Actívalo en Ajustes para usar los canales de ubicación.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#canales de ubicación";
|
|
||||||
"location_channels.tor.subtitle" = "oculta tu IP para los canales de ubicación. Recomendado: activado.";
|
|
||||||
"location_channels.tor.title" = "enrutamiento Tor";
|
|
||||||
"location_levels.block" = "bloque";
|
|
||||||
"location_levels.building" = "edificio";
|
|
||||||
"location_levels.city" = "ciudad";
|
|
||||||
"location_levels.neighborhood" = "barrio";
|
|
||||||
"location_levels.province" = "provincia";
|
|
||||||
"location_levels.region" = "región";
|
|
||||||
"location_notes.action.dismiss" = "descartar";
|
|
||||||
"location_notes.action.retry" = "reintentar";
|
|
||||||
"location_notes.description" = "añade notas permanentes cortas sobre este lugar para que otras personas las encuentren.";
|
|
||||||
"location_notes.empty_subtitle" = "sé la primera persona en añadir una en este lugar.";
|
|
||||||
"location_notes.empty_title" = "aún no hay notas";
|
|
||||||
"location_notes.error.failed_to_send" = "no se pudo enviar la nota. %@";
|
|
||||||
"location_notes.error.no_relays" = "no hay relays geográficos disponibles cerca de este lugar. Inténtalo de nuevo pronto.";
|
|
||||||
"location_notes.loading_notes" = "cargando notas…";
|
|
||||||
"location_notes.loading_recent" = "cargando notas recientes…";
|
|
||||||
"location_notes.no_relays_nearby" = "no hay relays geográficos cercanos";
|
|
||||||
"location_notes.placeholder" = "añade una nota para este lugar";
|
|
||||||
"location_notes.relays_paused" = "relays geográficos no disponibles; notas en pausa";
|
|
||||||
"location_notes.relays_retry_hint" = "las notas dependen de los relays geográficos. Comprueba la conexión e inténtalo de nuevo.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "nuevos mensajes";
|
|
||||||
"system.chat.blocked" = "no se puede iniciar un chat con %@: el usuario está bloqueado.";
|
|
||||||
"system.chat.requires_favorite" = "no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión.";
|
|
||||||
"system.common.user" = "usuario";
|
|
||||||
"system.dm.blocked_generic" = "no se puede enviar el mensaje: el usuario está bloqueado.";
|
|
||||||
"system.dm.blocked_recipient" = "no se puede enviar un mensaje a %@: el usuario está bloqueado.";
|
|
||||||
"system.dm.unreachable" = "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr.";
|
|
||||||
"system.geohash.blocked" = "se bloqueó a %@ en los chats geohash";
|
|
||||||
"system.geohash.unblocked" = "se desbloqueó a %@ en los chats geohash";
|
|
||||||
"system.location.not_in_channel" = "no se puede enviar: no estás en un canal de ubicación";
|
|
||||||
"system.location.send_failed" = "no se pudo enviar al canal de ubicación";
|
|
||||||
"system.tor.dev_bypass" = "compilación de desarrollo: bypass de Tor activado.";
|
|
||||||
"system.tor.restarted" = "tor se reinició. Se restauró el enrutamiento de la red.";
|
|
||||||
"system.tor.restarting" = "tor se está reiniciando para recuperar la conectividad...";
|
|
||||||
"system.tor.started" = "tor se inició. Todo el chat se enruta por Tor para privacidad.";
|
|
||||||
"system.tor.starting" = "iniciando Tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "código QR de verificación";
|
|
||||||
"verification.my_qr.title" = "escanea para verificarme";
|
|
||||||
"verification.my_qr.unavailable" = "QR no disponible";
|
|
||||||
"verification.scan.paste_prompt" = "pega el contenido del QR para validarlo:";
|
|
||||||
"verification.scan.prompt_friend" = "escanea el QR de un amigo";
|
|
||||||
"verification.scan.status.invalid" = "QR inválido o caducado";
|
|
||||||
"verification.scan.status.no_peer" = "no se encontró un peer coincidente";
|
|
||||||
"verification.scan.status.requested" = "se solicitó la verificación de %@";
|
|
||||||
"verification.scan.validate" = "validar";
|
|
||||||
"verification.sheet.title" = "VERIFICAR";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d nota</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d notas</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d persona</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d personas</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d persona</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d personas</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (French)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "fermer";
|
|
||||||
"app_info.done" = "TERMINÉ";
|
|
||||||
"app_info.features.encryption.description" = "messages privés chiffrés avec le protocole noise";
|
|
||||||
"app_info.features.encryption.title" = "chiffrement de bout en bout";
|
|
||||||
"app_info.features.extended_range.description" = "messages relayés entre pairs pour aller plus loin";
|
|
||||||
"app_info.features.extended_range.title" = "portée étendue";
|
|
||||||
"app_info.features.favorites.description" = "reçois une alerte quand tes personnes favorites arrivent";
|
|
||||||
"app_info.features.favorites.title" = "favoris";
|
|
||||||
"app_info.features.geohash.description" = "canaux geohash pour discuter avec des personnes proches via des relais décentralisés anonymes";
|
|
||||||
"app_info.features.geohash.title" = "canaux locaux";
|
|
||||||
"app_info.features.mentions.description" = "utilise @nickname pour avertir des personnes précises";
|
|
||||||
"app_info.features.mentions.title" = "mentions";
|
|
||||||
"app_info.features.offline.description" = "fonctionne sans internet avec le bluetooth basse énergie";
|
|
||||||
"app_info.features.offline.title" = "communication hors ligne";
|
|
||||||
"app_info.features.title" = "FONCTIONNALITÉS";
|
|
||||||
"app_info.how_to_use.change_channels" = "• tape sur #mesh pour changer de canal";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• tape trois fois sur le chat pour le vider";
|
|
||||||
"app_info.how_to_use.commands" = "• tape / pour voir les commandes";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• tape sur l'icône personnes pour ouvrir la barre latérale";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• règle ton pseudo en le touchant";
|
|
||||||
"app_info.how_to_use.start_dm" = "• tape sur le nom d'un pair pour démarrer un mp";
|
|
||||||
"app_info.how_to_use.title" = "MODE D'EMPLOI";
|
|
||||||
"app_info.privacy.ephemeral.description" = "nouvel id de pair généré régulièrement";
|
|
||||||
"app_info.privacy.ephemeral.title" = "identité éphémère";
|
|
||||||
"app_info.privacy.no_tracking.description" = "sans serveurs, comptes ni collecte de données";
|
|
||||||
"app_info.privacy.no_tracking.title" = "sans suivi";
|
|
||||||
"app_info.privacy.panic.description" = "tape trois fois sur le logo pour tout effacer instantanément";
|
|
||||||
"app_info.privacy.panic.title" = "mode panique";
|
|
||||||
"app_info.privacy.title" = "CONFIDENTIALITÉ";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "la sécurité des messages privés n'a pas encore été entièrement auditée. n'utilise pas pour des situations critiques tant que cet avertissement reste.";
|
|
||||||
"app_info.warning.title" = "AVERTISSEMENT";
|
|
||||||
"common.cancel" = "annuler";
|
|
||||||
"common.close" = "fermer";
|
|
||||||
"common.copy" = "copier";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "désactivé";
|
|
||||||
"common.toggle.on" = "activé";
|
|
||||||
"common.unknown" = "inconnu";
|
|
||||||
"content.accessibility.add_favorite" = "ajouter aux favoris";
|
|
||||||
"content.accessibility.available_nostr" = "disponible via nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "retour au chat principal";
|
|
||||||
"content.accessibility.connected_mesh" = "connecté via mesh";
|
|
||||||
"content.accessibility.encryption_status" = "état du chiffrement : %@";
|
|
||||||
"content.accessibility.location_channels" = "canaux de localisation";
|
|
||||||
"content.accessibility.location_notes" = "notes de localisation pour cet endroit";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "ouvrir le chat privé non lu";
|
|
||||||
"content.accessibility.private_chat_header" = "chat privé avec %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "joignable via mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "retirer des favoris";
|
|
||||||
"content.accessibility.send_hint_empty" = "saisis un message à envoyer";
|
|
||||||
"content.accessibility.send_hint_ready" = "tape deux fois pour envoyer";
|
|
||||||
"content.accessibility.send_message" = "envoyer le message";
|
|
||||||
"content.accessibility.toggle_bookmark" = "basculer le favori pour #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "tape deux fois pour basculer le statut favori";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "tape pour voir l'empreinte de chiffrement";
|
|
||||||
"content.actions.block" = "bloquer";
|
|
||||||
"content.actions.direct_message" = "message direct";
|
|
||||||
"content.actions.hug" = "câlin";
|
|
||||||
"content.actions.mention" = "mentionner";
|
|
||||||
"content.actions.slap" = "gifle";
|
|
||||||
"content.actions.title" = "actions";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth est désactivé. active le bluetooth dans réglages pour utiliser bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat a besoin de l'autorisation bluetooth pour se connecter aux appareils proches. active l'accès dans réglages.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "réglages";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetooth requis";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "cet appareil ne prend pas en charge le bluetooth. bitchat en a besoin pour fonctionner.";
|
|
||||||
"content.alert.screenshot.message" = "les captures des canaux de localisation révéleront ta position. réfléchis avant de partager publiquement.";
|
|
||||||
"content.alert.screenshot.title" = "attention";
|
|
||||||
"content.commands.block" = "bloquer ou lister les pairs bloqués";
|
|
||||||
"content.commands.clear" = "effacer les messages du chat";
|
|
||||||
"content.commands.favorite" = "ajouter aux favoris";
|
|
||||||
"content.commands.hug" = "envoyer un câlin chaleureux";
|
|
||||||
"content.commands.message" = "envoyer un message privé";
|
|
||||||
"content.commands.slap" = "gifler quelqu'un avec une truite";
|
|
||||||
"content.commands.unblock" = "débloquer un pair";
|
|
||||||
"content.commands.unfavorite" = "retirer des favoris";
|
|
||||||
"content.commands.who" = "voir qui est en ligne";
|
|
||||||
"content.delivery.delivered_members" = "livré à %1$d sur %2$d membres";
|
|
||||||
"content.delivery.delivered_to" = "livré à %@";
|
|
||||||
"content.delivery.failed" = "échec : %@";
|
|
||||||
"content.delivery.read_by" = "lu par %@";
|
|
||||||
"content.delivery.reason.blocked" = "utilisateur bloqué";
|
|
||||||
"content.delivery.reason.self" = "impossible d'envoyer à toi-même";
|
|
||||||
"content.delivery.reason.send_error" = "erreur d'envoi";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "destinataire inconnu";
|
|
||||||
"content.delivery.reason.unreachable" = "pair injoignable";
|
|
||||||
"content.header.people" = "PERSONNES";
|
|
||||||
"content.help.verification" = "vérification : afficher mon qr ou scanner un ami";
|
|
||||||
"content.input.message_placeholder" = "écris un message...";
|
|
||||||
"content.input.nickname_placeholder" = "pseudo";
|
|
||||||
"content.location.enable" = "activer la localisation";
|
|
||||||
"content.message.copy" = "copier le message";
|
|
||||||
"content.message.show_less" = "afficher moins";
|
|
||||||
"content.message.show_more" = "afficher plus";
|
|
||||||
"content.notes.location_unavailable" = "localisation indisponible";
|
|
||||||
"content.notes.title" = "notes";
|
|
||||||
"content.payment.cashu" = "payer via cashu";
|
|
||||||
"content.payment.lightning" = "payer via lightning";
|
|
||||||
"encryption.accessibility.establishing" = "établissement du chiffrement";
|
|
||||||
"encryption.accessibility.failed" = "chiffrement échoué";
|
|
||||||
"encryption.accessibility.not_encrypted" = "non chiffré";
|
|
||||||
"encryption.accessibility.secured" = "chiffré";
|
|
||||||
"encryption.accessibility.verified" = "chiffré et vérifié";
|
|
||||||
"encryption.status.establishing" = "mise en place du chiffrement...";
|
|
||||||
"encryption.status.failed" = "chiffrement échoué";
|
|
||||||
"encryption.status.not_encrypted" = "non chiffré";
|
|
||||||
"encryption.status.secured" = "chiffré";
|
|
||||||
"encryption.status.verified" = "chiffré et vérifié";
|
|
||||||
"fingerprint.action.mark_verified" = "marquer comme vérifié";
|
|
||||||
"fingerprint.action.remove_verification" = "retirer la vérification";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ NON VÉRIFIÉ";
|
|
||||||
"fingerprint.badge.verified" = "✓ VÉRIFIÉ";
|
|
||||||
"fingerprint.handshake_pending" = "indisponible - handshake en cours";
|
|
||||||
"fingerprint.message.verified" = "tu as vérifié l'identité de cette personne.";
|
|
||||||
"fingerprint.message.verify_hint" = "compare ces empreintes avec %@ via un canal sécurisé.";
|
|
||||||
"fingerprint.their_label" = "leur empreinte :";
|
|
||||||
"fingerprint.title" = "vérification de sécurité";
|
|
||||||
"fingerprint.your_label" = "ton empreinte :";
|
|
||||||
"geohash_people.action.block" = "bloquer";
|
|
||||||
"geohash_people.action.unblock" = "débloquer";
|
|
||||||
"geohash_people.none_nearby" = "personne à proximité...";
|
|
||||||
"geohash_people.tooltip.blocked" = "bloqué dans geohash";
|
|
||||||
"geohash_people.you_suffix" = " (toi)";
|
|
||||||
"location_channels.action.open_settings" = "ouvrir réglages";
|
|
||||||
"location_channels.action.remove_access" = "retirer l'accès localisation";
|
|
||||||
"location_channels.action.request_permissions" = "obtenir ma localisation et mes geohash";
|
|
||||||
"location_channels.action.teleport" = "téléporter";
|
|
||||||
"location_channels.bookmarked_section_title" = "enregistrés";
|
|
||||||
"location_channels.description" = "discute avec les personnes proches grâce aux canaux geohash. seul un geohash grossier est partagé, jamais de gps exact. ton ip reste cachée car tout le trafic passe par tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash invalide";
|
|
||||||
"location_channels.loading_nearby" = "recherche de canaux proches…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "autorisation de localisation refusée. active-la dans réglages pour utiliser les canaux.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#canaux localisation";
|
|
||||||
"location_channels.tor.subtitle" = "cache ton ip pour les canaux localisation. recommandé : activé.";
|
|
||||||
"location_channels.tor.title" = "routage tor";
|
|
||||||
"location_levels.block" = "bloc";
|
|
||||||
"location_levels.building" = "bâtiment";
|
|
||||||
"location_levels.city" = "ville";
|
|
||||||
"location_levels.neighborhood" = "quartier";
|
|
||||||
"location_levels.province" = "province";
|
|
||||||
"location_levels.region" = "région";
|
|
||||||
"location_notes.action.dismiss" = "ignorer";
|
|
||||||
"location_notes.action.retry" = "réessayer";
|
|
||||||
"location_notes.description" = "ajoute de courtes notes permanentes ici pour aider les autres.";
|
|
||||||
"location_notes.empty_subtitle" = "sois la première personne à en ajouter ici.";
|
|
||||||
"location_notes.empty_title" = "pas encore de notes";
|
|
||||||
"location_notes.error.failed_to_send" = "impossible d'envoyer la note. %@";
|
|
||||||
"location_notes.error.no_relays" = "aucun relais géo disponible près d'ici. réessaie bientôt.";
|
|
||||||
"location_notes.loading_notes" = "chargement des notes…";
|
|
||||||
"location_notes.loading_recent" = "chargement des notes récentes…";
|
|
||||||
"location_notes.no_relays_nearby" = "aucun relais géo à proximité";
|
|
||||||
"location_notes.placeholder" = "ajoute une note pour cet endroit";
|
|
||||||
"location_notes.relays_paused" = "relais géo indisponibles ; notes en pause";
|
|
||||||
"location_notes.relays_retry_hint" = "les notes dépendent des relais géo. vérifie la connexion et réessaie.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "nouveaux messages";
|
|
||||||
"system.chat.blocked" = "impossible de démarrer un chat avec %@ : utilisateur bloqué.";
|
|
||||||
"system.chat.requires_favorite" = "impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne.";
|
|
||||||
"system.common.user" = "utilisateur";
|
|
||||||
"system.dm.blocked_generic" = "envoi impossible : utilisateur bloqué.";
|
|
||||||
"system.dm.blocked_recipient" = "impossible d'envoyer à %@ : utilisateur bloqué.";
|
|
||||||
"system.dm.unreachable" = "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ a été bloqué dans les chats geohash";
|
|
||||||
"system.geohash.unblocked" = "%@ a été débloqué dans les chats geohash";
|
|
||||||
"system.location.not_in_channel" = "envoi impossible : tu n'es pas dans un canal localisation";
|
|
||||||
"system.location.send_failed" = "envoi au canal localisation impossible";
|
|
||||||
"system.tor.dev_bypass" = "build de développement : bypass tor actif.";
|
|
||||||
"system.tor.restarted" = "tor a redémarré. routage restauré.";
|
|
||||||
"system.tor.restarting" = "tor redémarre pour rétablir la connectivité...";
|
|
||||||
"system.tor.started" = "tor a démarré. tout le chat passe par tor pour la confidentialité.";
|
|
||||||
"system.tor.starting" = "lancement de tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "code qr de vérification";
|
|
||||||
"verification.my_qr.title" = "scanne pour me vérifier";
|
|
||||||
"verification.my_qr.unavailable" = "qr indisponible";
|
|
||||||
"verification.scan.paste_prompt" = "colle le contenu du qr pour valider :";
|
|
||||||
"verification.scan.prompt_friend" = "scanne le qr d'un ami";
|
|
||||||
"verification.scan.status.invalid" = "qr invalide ou expiré";
|
|
||||||
"verification.scan.status.no_peer" = "aucun pair correspondant trouvé";
|
|
||||||
"verification.scan.status.requested" = "vérification demandée pour %@";
|
|
||||||
"verification.scan.validate" = "valider";
|
|
||||||
"verification.sheet.title" = "VÉRIFIER";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d note</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d notes</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d personne</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d personnes</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d personne</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d personnes</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Hebrew)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "סגור";
|
|
||||||
"app_info.done" = "בוצע";
|
|
||||||
"app_info.features.encryption.description" = "הודעות פרטיות מוצפנות בפרוטוקול noise";
|
|
||||||
"app_info.features.encryption.title" = "הצפנה מקצה לקצה";
|
|
||||||
"app_info.features.extended_range.description" = "הודעות משודרות בין עמיתים ומגיעות רחוק יותר";
|
|
||||||
"app_info.features.extended_range.title" = "טווח מורחב";
|
|
||||||
"app_info.features.favorites.description" = "קבל התראות כשהאנשים המועדפים שלך מצטרפים";
|
|
||||||
"app_info.features.favorites.title" = "מועדפים";
|
|
||||||
"app_info.features.geohash.description" = "ערוצי geohash לשיחה עם אנשים קרובים דרך ממסרים אנונימיים מבוזרים";
|
|
||||||
"app_info.features.geohash.title" = "ערוצים מקומיים";
|
|
||||||
"app_info.features.mentions.description" = "השתמש ב-@nickname כדי להתריע לאנשים ספציפיים";
|
|
||||||
"app_info.features.mentions.title" = "אזכורים";
|
|
||||||
"app_info.features.offline.description" = "עובד בלי אינטרנט באמצעות bluetooth בתצריכת אנרגיה נמוכה";
|
|
||||||
"app_info.features.offline.title" = "תקשורת לא מקוונת";
|
|
||||||
"app_info.features.title" = "יכולות";
|
|
||||||
"app_info.how_to_use.change_channels" = "• הקש על #mesh כדי להחליף ערוץ";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• הקש שלוש פעמים על הצ'אט כדי לנקות";
|
|
||||||
"app_info.how_to_use.commands" = "• הקלד / כדי לראות פקודות";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• הקש על אייקון האנשים כדי לפתוח סרגל צד";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• הקש על הכינוי שלך כדי לעדכן";
|
|
||||||
"app_info.how_to_use.start_dm" = "• הקש על שם עמית כדי להתחיל הודעה פרטית";
|
|
||||||
"app_info.how_to_use.title" = "איך להשתמש";
|
|
||||||
"app_info.privacy.ephemeral.description" = "מזהה עמית חדש נוצר באופן קבוע";
|
|
||||||
"app_info.privacy.ephemeral.title" = "זהות זמנית";
|
|
||||||
"app_info.privacy.no_tracking.description" = "אין שרתים, חשבונות או איסוף נתונים";
|
|
||||||
"app_info.privacy.no_tracking.title" = "ללא מעקב";
|
|
||||||
"app_info.privacy.panic.description" = "הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד";
|
|
||||||
"app_info.privacy.panic.title" = "מצב בהלה";
|
|
||||||
"app_info.privacy.title" = "פרטיות";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "אבטחת ההודעות הפרטיות עדיין לא נבדקה במלואה. אל תשתמש למצבים קריטיים עד שהאזהרה תיעלם.";
|
|
||||||
"app_info.warning.title" = "אזהרה";
|
|
||||||
"common.cancel" = "ביטול";
|
|
||||||
"common.close" = "סגור";
|
|
||||||
"common.copy" = "העתק";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "כבוי";
|
|
||||||
"common.toggle.on" = "פעיל";
|
|
||||||
"common.unknown" = "לא ידוע";
|
|
||||||
"content.accessibility.add_favorite" = "הוסף למועדפים";
|
|
||||||
"content.accessibility.available_nostr" = "זמין דרך nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "חזרה לצ'אט הראשי";
|
|
||||||
"content.accessibility.connected_mesh" = "מחובר דרך mesh";
|
|
||||||
"content.accessibility.encryption_status" = "מצב הצפנה: %@";
|
|
||||||
"content.accessibility.location_channels" = "ערוצי מיקום";
|
|
||||||
"content.accessibility.location_notes" = "הערות מיקום למקום הזה";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "פתח צ'אט פרטי שלא נקרא";
|
|
||||||
"content.accessibility.private_chat_header" = "צ'אט פרטי עם %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "זמין דרך mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "הסר מהמועדפים";
|
|
||||||
"content.accessibility.send_hint_empty" = "הזן הודעה לשליחה";
|
|
||||||
"content.accessibility.send_hint_ready" = "הקש פעמיים לשליחה";
|
|
||||||
"content.accessibility.send_message" = "שלח הודעה";
|
|
||||||
"content.accessibility.toggle_bookmark" = "החלף סימנייה עבור #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "הקש פעמיים כדי להחליף מצב מועדפים";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "הקש להצגת טביעת ההצפנה";
|
|
||||||
"content.actions.block" = "חסום";
|
|
||||||
"content.actions.direct_message" = "הודעה ישירה";
|
|
||||||
"content.actions.hug" = "חיבוק";
|
|
||||||
"content.actions.mention" = "אזכור";
|
|
||||||
"content.actions.slap" = "סטירה";
|
|
||||||
"content.actions.title" = "פעולות";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth כבוי. הפעל bluetooth בהגדרות כדי להשתמש ב-bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "הגדרות";
|
|
||||||
"content.alert.bluetooth_required.title" = "נדרש bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "המכשיר הזה לא תומך ב-bluetooth. bitchat זקוקה ל-bluetooth כדי לעבוד.";
|
|
||||||
"content.alert.screenshot.message" = "צילומי מסך של ערוצי מיקום יחשפו את מיקומך. חשב לפני שיתוף פומבי.";
|
|
||||||
"content.alert.screenshot.title" = "שים לב";
|
|
||||||
"content.commands.block" = "חסום או הצג עמיתים חסומים";
|
|
||||||
"content.commands.clear" = "נקה הודעות צ'אט";
|
|
||||||
"content.commands.favorite" = "הוסף למועדפים";
|
|
||||||
"content.commands.hug" = "שלח חיבוק חם";
|
|
||||||
"content.commands.message" = "שלח הודעה פרטית";
|
|
||||||
"content.commands.slap" = "תן למישהו סטירת פורל";
|
|
||||||
"content.commands.unblock" = "בטל חסימה לעמית";
|
|
||||||
"content.commands.unfavorite" = "הסר מהמועדפים";
|
|
||||||
"content.commands.who" = "ראה מי מחובר";
|
|
||||||
"content.delivery.delivered_members" = "נמסר ל-%1$d מתוך %2$d חברים";
|
|
||||||
"content.delivery.delivered_to" = "נמסר ל-%@";
|
|
||||||
"content.delivery.failed" = "נכשל: %@";
|
|
||||||
"content.delivery.read_by" = "נקרא על ידי %@";
|
|
||||||
"content.delivery.reason.blocked" = "המשתמש חסום";
|
|
||||||
"content.delivery.reason.self" = "אי אפשר לשלוח לעצמך";
|
|
||||||
"content.delivery.reason.send_error" = "שגיאת שליחה";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "נמען לא ידוע";
|
|
||||||
"content.delivery.reason.unreachable" = "עמית לא זמין";
|
|
||||||
"content.header.people" = "אנשים";
|
|
||||||
"content.help.verification" = "אימות: הצג את ה-qr שלי או סרוק חבר";
|
|
||||||
"content.input.message_placeholder" = "כתוב הודעה...";
|
|
||||||
"content.input.nickname_placeholder" = "כינוי";
|
|
||||||
"content.location.enable" = "הפעל מיקום";
|
|
||||||
"content.message.copy" = "העתק הודעה";
|
|
||||||
"content.message.show_less" = "הצג פחות";
|
|
||||||
"content.message.show_more" = "הצג עוד";
|
|
||||||
"content.notes.location_unavailable" = "המיקום לא זמין";
|
|
||||||
"content.notes.title" = "הערות";
|
|
||||||
"content.payment.cashu" = "תשלום דרך cashu";
|
|
||||||
"content.payment.lightning" = "תשלום דרך lightning";
|
|
||||||
"encryption.accessibility.establishing" = "הצפנה בהקמה";
|
|
||||||
"encryption.accessibility.failed" = "הצפנה נכשלה";
|
|
||||||
"encryption.accessibility.not_encrypted" = "לא מוצפן";
|
|
||||||
"encryption.accessibility.secured" = "מוצפן";
|
|
||||||
"encryption.accessibility.verified" = "מוצפן ומאומת";
|
|
||||||
"encryption.status.establishing" = "מקימים הצפנה...";
|
|
||||||
"encryption.status.failed" = "הצפנה נכשלה";
|
|
||||||
"encryption.status.not_encrypted" = "לא מוצפן";
|
|
||||||
"encryption.status.secured" = "מוצפן";
|
|
||||||
"encryption.status.verified" = "מוצפן ומאומת";
|
|
||||||
"fingerprint.action.mark_verified" = "סמן כמאומת";
|
|
||||||
"fingerprint.action.remove_verification" = "הסר אימות";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ לא מאומת";
|
|
||||||
"fingerprint.badge.verified" = "✓ מאומת";
|
|
||||||
"fingerprint.handshake_pending" = "לא זמין - handshake מתבצע";
|
|
||||||
"fingerprint.message.verified" = "אישרת את זהותו של האדם הזה.";
|
|
||||||
"fingerprint.message.verify_hint" = "השווה את הטביעות עם %@ בערוץ מאובטח.";
|
|
||||||
"fingerprint.their_label" = "הטבעת שלהם:";
|
|
||||||
"fingerprint.title" = "אימות אבטחה";
|
|
||||||
"fingerprint.your_label" = "הטבעת שלך:";
|
|
||||||
"geohash_people.action.block" = "חסום";
|
|
||||||
"geohash_people.action.unblock" = "בטל חסימה";
|
|
||||||
"geohash_people.none_nearby" = "אין אף אחד בסביבה...";
|
|
||||||
"geohash_people.tooltip.blocked" = "חסום ב-geohash";
|
|
||||||
"geohash_people.you_suffix" = " (אתה)";
|
|
||||||
"location_channels.action.open_settings" = "פתח הגדרות";
|
|
||||||
"location_channels.action.remove_access" = "הסר גישת מיקום";
|
|
||||||
"location_channels.action.request_permissions" = "קבל את המיקום וה-geohash שלי";
|
|
||||||
"location_channels.action.teleport" = "טלפורט";
|
|
||||||
"location_channels.bookmarked_section_title" = "שמורים";
|
|
||||||
"location_channels.description" = "שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash לא תקף";
|
|
||||||
"location_channels.loading_nearby" = "מחפש ערוצים קרובים…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "הרשאת מיקום נדחתה. אפשר בהגדרות כדי להשתמש בערוצי מיקום.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#ערוצי מיקום";
|
|
||||||
"location_channels.tor.subtitle" = "מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל.";
|
|
||||||
"location_channels.tor.title" = "ניתוב tor";
|
|
||||||
"location_levels.block" = "בלוק";
|
|
||||||
"location_levels.building" = "מבנה";
|
|
||||||
"location_levels.city" = "עיר";
|
|
||||||
"location_levels.neighborhood" = "שכונה";
|
|
||||||
"location_levels.province" = "מחוז";
|
|
||||||
"location_levels.region" = "אזור";
|
|
||||||
"location_notes.action.dismiss" = "סגור";
|
|
||||||
"location_notes.action.retry" = "ניסיון שוב";
|
|
||||||
"location_notes.description" = "הוסף הערות קצרות וקבועות למקום הזה כדי שאחרים ימצאו.";
|
|
||||||
"location_notes.empty_subtitle" = "היה הראשון להוסיף כאן.";
|
|
||||||
"location_notes.empty_title" = "אין הערות עדיין";
|
|
||||||
"location_notes.error.failed_to_send" = "לא ניתן לשלוח את ההערה. %@";
|
|
||||||
"location_notes.error.no_relays" = "אין ממסרי geo זמינים בקרבת מקום. נסה שוב מאוחר יותר.";
|
|
||||||
"location_notes.loading_notes" = "טוען הערות…";
|
|
||||||
"location_notes.loading_recent" = "טוען הערות אחרונות…";
|
|
||||||
"location_notes.no_relays_nearby" = "אין ממסרי geo קרובים";
|
|
||||||
"location_notes.placeholder" = "הוסף הערה למקום הזה";
|
|
||||||
"location_notes.relays_paused" = "ממסרי geo אינם זמינים; הערות הושהו";
|
|
||||||
"location_notes.relays_retry_hint" = "הערות תלויות בממסרי geo. בדוק את החיבור ונסה שוב.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "הודעות חדשות";
|
|
||||||
"system.chat.blocked" = "לא ניתן להתחיל צ'אט עם %@: המשתמש חסום.";
|
|
||||||
"system.chat.requires_favorite" = "לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין.";
|
|
||||||
"system.common.user" = "משתמש";
|
|
||||||
"system.dm.blocked_generic" = "אי אפשר לשלוח: המשתמש חסום.";
|
|
||||||
"system.dm.blocked_recipient" = "אי אפשר לשלוח ל-%@: המשתמש חסום.";
|
|
||||||
"system.dm.unreachable" = "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ נחסם בצ'אטי geohash";
|
|
||||||
"system.geohash.unblocked" = "%@ הוסר מהחסימה בצ'אטי geohash";
|
|
||||||
"system.location.not_in_channel" = "אי אפשר לשלוח: אינך בערוץ מיקום";
|
|
||||||
"system.location.send_failed" = "השליחה לערוץ המיקום נכשלה";
|
|
||||||
"system.tor.dev_bypass" = "בנייה לפיתוח: עקיפת tor פעילה.";
|
|
||||||
"system.tor.restarted" = "tor הופעל מחדש. הניתוב שוחזר.";
|
|
||||||
"system.tor.restarting" = "tor מופעל מחדש להשבת החיבור...";
|
|
||||||
"system.tor.started" = "tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.";
|
|
||||||
"system.tor.starting" = "tor מופעל...";
|
|
||||||
"verification.my_qr.accessibility_label" = "קוד qr לאימות";
|
|
||||||
"verification.my_qr.title" = "סרוק כדי לאמת";
|
|
||||||
"verification.my_qr.unavailable" = "qr לא זמין";
|
|
||||||
"verification.scan.paste_prompt" = "הדבק תוכן qr לאימות:";
|
|
||||||
"verification.scan.prompt_friend" = "סרוק qr של חבר";
|
|
||||||
"verification.scan.status.invalid" = "qr לא תקף או שפג תוקפו";
|
|
||||||
"verification.scan.status.no_peer" = "לא נמצא עמית תואם";
|
|
||||||
"verification.scan.status.requested" = "התבקש אימות עבור %@";
|
|
||||||
"verification.scan.validate" = "אשר";
|
|
||||||
"verification.sheet.title" = "אימות";
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d הערה</string>
|
|
||||||
<key>two</key>
|
|
||||||
<string>%d הערות</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d הערות</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d הערות</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d אדם</string>
|
|
||||||
<key>two</key>
|
|
||||||
<string>%d אנשים</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d אנשים</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d אנשים</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d אדם</string>
|
|
||||||
<key>two</key>
|
|
||||||
<string>%d אנשים</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d אנשים</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d אנשים</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Indonesian)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "tutup";
|
|
||||||
"app_info.done" = "SELESAI";
|
|
||||||
"app_info.features.encryption.description" = "pesan pribadi dienkripsi dengan protokol noise";
|
|
||||||
"app_info.features.encryption.title" = "enkripsi ujung ke ujung";
|
|
||||||
"app_info.features.extended_range.description" = "pesan diteruskan antar peer sehingga jangkauannya lebih jauh";
|
|
||||||
"app_info.features.extended_range.title" = "jangkauan diperluas";
|
|
||||||
"app_info.features.favorites.description" = "dapatkan notifikasi saat orang favoritmu bergabung";
|
|
||||||
"app_info.features.favorites.title" = "favorit";
|
|
||||||
"app_info.features.geohash.description" = "kanal geohash untuk ngobrol dengan orang di wilayah sekitar lewat relay anonim terdesentralisasi";
|
|
||||||
"app_info.features.geohash.title" = "kanal lokal";
|
|
||||||
"app_info.features.mentions.description" = "pakai @nickname untuk memberi tahu orang tertentu";
|
|
||||||
"app_info.features.mentions.title" = "mention";
|
|
||||||
"app_info.features.offline.description" = "bekerja tanpa internet memakai bluetooth low energy";
|
|
||||||
"app_info.features.offline.title" = "komunikasi offline";
|
|
||||||
"app_info.features.title" = "FITUR";
|
|
||||||
"app_info.how_to_use.change_channels" = "• ketuk #mesh untuk ganti kanal";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• ketuk chat tiga kali untuk menghapus";
|
|
||||||
"app_info.how_to_use.commands" = "• ketik / untuk melihat perintah";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• ketuk ikon orang untuk membuka sidebar";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• atur nama panggilanmu dengan mengetuknya";
|
|
||||||
"app_info.how_to_use.start_dm" = "• ketuk nama peer untuk mulai dm";
|
|
||||||
"app_info.how_to_use.title" = "CARA PAKAI";
|
|
||||||
"app_info.privacy.ephemeral.description" = "id peer baru dibuat secara berkala";
|
|
||||||
"app_info.privacy.ephemeral.title" = "identitas sementara";
|
|
||||||
"app_info.privacy.no_tracking.description" = "tanpa server, akun, atau pengumpulan data";
|
|
||||||
"app_info.privacy.no_tracking.title" = "tanpa pelacakan";
|
|
||||||
"app_info.privacy.panic.description" = "ketuk logo tiga kali untuk langsung menghapus semua data";
|
|
||||||
"app_info.privacy.panic.title" = "mode panik";
|
|
||||||
"app_info.privacy.title" = "PRIVASI";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "keamanan pesan pribadi belum diaudit sepenuhnya. jangan dipakai untuk situasi kritis sampai peringatan ini hilang.";
|
|
||||||
"app_info.warning.title" = "PERINGATAN";
|
|
||||||
"common.cancel" = "batal";
|
|
||||||
"common.close" = "tutup";
|
|
||||||
"common.copy" = "salin";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "mati";
|
|
||||||
"common.toggle.on" = "nyala";
|
|
||||||
"common.unknown" = "tidak diketahui";
|
|
||||||
"content.accessibility.add_favorite" = "tambah ke favorit";
|
|
||||||
"content.accessibility.available_nostr" = "tersedia melalui nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "kembali ke chat utama";
|
|
||||||
"content.accessibility.connected_mesh" = "terhubung lewat mesh";
|
|
||||||
"content.accessibility.encryption_status" = "status enkripsi: %@";
|
|
||||||
"content.accessibility.location_channels" = "kanal lokasi";
|
|
||||||
"content.accessibility.location_notes" = "catatan lokasi untuk tempat ini";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "buka chat pribadi belum dibaca";
|
|
||||||
"content.accessibility.private_chat_header" = "chat pribadi dengan %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "dapat dijangkau lewat mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "hapus dari favorit";
|
|
||||||
"content.accessibility.send_hint_empty" = "masukkan pesan untuk dikirim";
|
|
||||||
"content.accessibility.send_hint_ready" = "ketuk dua kali untuk mengirim";
|
|
||||||
"content.accessibility.send_message" = "kirim pesan";
|
|
||||||
"content.accessibility.toggle_bookmark" = "ubah penanda untuk #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "ketuk dua kali untuk mengubah status favorit";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "ketuk untuk melihat sidik enkripsi";
|
|
||||||
"content.actions.block" = "blokir";
|
|
||||||
"content.actions.direct_message" = "pesan langsung";
|
|
||||||
"content.actions.hug" = "peluk";
|
|
||||||
"content.actions.mention" = "sebut";
|
|
||||||
"content.actions.slap" = "tampar";
|
|
||||||
"content.actions.title" = "aksi";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth dimatikan. aktifkan bluetooth di pengaturan untuk memakai bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "pengaturan";
|
|
||||||
"content.alert.bluetooth_required.title" = "butuh bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "perangkat ini tidak mendukung bluetooth. bitchat memerlukan bluetooth untuk berjalan.";
|
|
||||||
"content.alert.screenshot.message" = "tangkapan layar kanal lokasi akan mengungkap lokasimu. pikirkan dulu sebelum membagikannya.";
|
|
||||||
"content.alert.screenshot.title" = "perhatian";
|
|
||||||
"content.commands.block" = "blokir atau lihat peer yang diblokir";
|
|
||||||
"content.commands.clear" = "hapus pesan chat";
|
|
||||||
"content.commands.favorite" = "tambah ke favorit";
|
|
||||||
"content.commands.hug" = "kirim pelukan hangat";
|
|
||||||
"content.commands.message" = "kirim pesan pribadi";
|
|
||||||
"content.commands.slap" = "tampar seseorang dengan ikan trout";
|
|
||||||
"content.commands.unblock" = "buka blokir peer";
|
|
||||||
"content.commands.unfavorite" = "hapus dari favorit";
|
|
||||||
"content.commands.who" = "lihat siapa yang online";
|
|
||||||
"content.delivery.delivered_members" = "terkirim ke %1$d dari %2$d anggota";
|
|
||||||
"content.delivery.delivered_to" = "terkirim ke %@";
|
|
||||||
"content.delivery.failed" = "gagal: %@";
|
|
||||||
"content.delivery.read_by" = "dibaca oleh %@";
|
|
||||||
"content.delivery.reason.blocked" = "pengguna diblokir";
|
|
||||||
"content.delivery.reason.self" = "tidak bisa kirim ke diri sendiri";
|
|
||||||
"content.delivery.reason.send_error" = "kesalahan pengiriman";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "penerima tidak dikenal";
|
|
||||||
"content.delivery.reason.unreachable" = "peer tidak dapat dijangkau";
|
|
||||||
"content.header.people" = "ORANG";
|
|
||||||
"content.help.verification" = "verifikasi: tampilkan qr-ku atau pindai teman";
|
|
||||||
"content.input.message_placeholder" = "ketik pesan...";
|
|
||||||
"content.input.nickname_placeholder" = "nama panggilan";
|
|
||||||
"content.location.enable" = "aktifkan lokasi";
|
|
||||||
"content.message.copy" = "salin pesan";
|
|
||||||
"content.message.show_less" = "tampilkan lebih sedikit";
|
|
||||||
"content.message.show_more" = "tampilkan lebih banyak";
|
|
||||||
"content.notes.location_unavailable" = "lokasi tidak tersedia";
|
|
||||||
"content.notes.title" = "catatan";
|
|
||||||
"content.payment.cashu" = "bayar via cashu";
|
|
||||||
"content.payment.lightning" = "bayar via lightning";
|
|
||||||
"encryption.accessibility.establishing" = "menyiapkan enkripsi";
|
|
||||||
"encryption.accessibility.failed" = "enkripsi gagal";
|
|
||||||
"encryption.accessibility.not_encrypted" = "tidak terenkripsi";
|
|
||||||
"encryption.accessibility.secured" = "terenkripsi";
|
|
||||||
"encryption.accessibility.verified" = "terenkripsi dan terverifikasi";
|
|
||||||
"encryption.status.establishing" = "menyiapkan enkripsi...";
|
|
||||||
"encryption.status.failed" = "enkripsi gagal";
|
|
||||||
"encryption.status.not_encrypted" = "tidak terenkripsi";
|
|
||||||
"encryption.status.secured" = "terenkripsi";
|
|
||||||
"encryption.status.verified" = "terenkripsi dan terverifikasi";
|
|
||||||
"fingerprint.action.mark_verified" = "tandai sebagai terverifikasi";
|
|
||||||
"fingerprint.action.remove_verification" = "hapus verifikasi";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ BELUM TERVERIFIKASI";
|
|
||||||
"fingerprint.badge.verified" = "✓ TERVERIFIKASI";
|
|
||||||
"fingerprint.handshake_pending" = "tidak tersedia - handshake sedang berlangsung";
|
|
||||||
"fingerprint.message.verified" = "kamu sudah memverifikasi identitas orang ini.";
|
|
||||||
"fingerprint.message.verify_hint" = "bandingkan sidik ini dengan %@ lewat kanal aman.";
|
|
||||||
"fingerprint.their_label" = "sidik mereka:";
|
|
||||||
"fingerprint.title" = "verifikasi keamanan";
|
|
||||||
"fingerprint.your_label" = "sidikmu:";
|
|
||||||
"geohash_people.action.block" = "blokir";
|
|
||||||
"geohash_people.action.unblock" = "buka blokir";
|
|
||||||
"geohash_people.none_nearby" = "tidak ada siapa pun...";
|
|
||||||
"geohash_people.tooltip.blocked" = "diblokir di geohash";
|
|
||||||
"geohash_people.you_suffix" = " (kamu)";
|
|
||||||
"location_channels.action.open_settings" = "buka pengaturan";
|
|
||||||
"location_channels.action.remove_access" = "cabut akses lokasi";
|
|
||||||
"location_channels.action.request_permissions" = "ambil lokasiku dan geohash";
|
|
||||||
"location_channels.action.teleport" = "teleport";
|
|
||||||
"location_channels.bookmarked_section_title" = "disimpan";
|
|
||||||
"location_channels.description" = "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash tidak valid";
|
|
||||||
"location_channels.loading_nearby" = "mencari kanal sekitar…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "izin lokasi ditolak. aktifkan di pengaturan untuk memakai kanal lokasi.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#kanal lokasi";
|
|
||||||
"location_channels.tor.subtitle" = "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif.";
|
|
||||||
"location_channels.tor.title" = "perutean tor";
|
|
||||||
"location_levels.block" = "blok";
|
|
||||||
"location_levels.building" = "gedung";
|
|
||||||
"location_levels.city" = "kota";
|
|
||||||
"location_levels.neighborhood" = "lingkungan";
|
|
||||||
"location_levels.province" = "provinsi";
|
|
||||||
"location_levels.region" = "wilayah";
|
|
||||||
"location_notes.action.dismiss" = "tutup";
|
|
||||||
"location_notes.action.retry" = "coba lagi";
|
|
||||||
"location_notes.description" = "tambahkan catatan permanen singkat di tempat ini agar orang lain menemukannya.";
|
|
||||||
"location_notes.empty_subtitle" = "jadilah orang pertama yang menambahkannya di sini.";
|
|
||||||
"location_notes.empty_title" = "belum ada catatan";
|
|
||||||
"location_notes.error.failed_to_send" = "tidak bisa mengirim catatan. %@";
|
|
||||||
"location_notes.error.no_relays" = "tidak ada relay geo tersedia dekat sini. coba lagi nanti.";
|
|
||||||
"location_notes.loading_notes" = "memuat catatan…";
|
|
||||||
"location_notes.loading_recent" = "memuat catatan terbaru…";
|
|
||||||
"location_notes.no_relays_nearby" = "tidak ada relay geo di dekatmu";
|
|
||||||
"location_notes.placeholder" = "tambahkan catatan untuk tempat ini";
|
|
||||||
"location_notes.relays_paused" = "relay geo tidak tersedia; catatan dijeda";
|
|
||||||
"location_notes.relays_retry_hint" = "catatan bergantung pada relay geo. cek koneksi lalu coba lagi.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "pesan baru";
|
|
||||||
"system.chat.blocked" = "tidak bisa mulai chat dengan %@: pengguna diblokir.";
|
|
||||||
"system.chat.requires_favorite" = "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline.";
|
|
||||||
"system.common.user" = "pengguna";
|
|
||||||
"system.dm.blocked_generic" = "tidak bisa mengirim: pengguna diblokir.";
|
|
||||||
"system.dm.blocked_recipient" = "tidak bisa mengirim ke %@: pengguna diblokir.";
|
|
||||||
"system.dm.unreachable" = "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ diblokir di chat geohash";
|
|
||||||
"system.geohash.unblocked" = "%@ dibuka blokirnya di chat geohash";
|
|
||||||
"system.location.not_in_channel" = "gagal mengirim: kamu tidak berada di kanal lokasi";
|
|
||||||
"system.location.send_failed" = "gagal mengirim ke kanal lokasi";
|
|
||||||
"system.tor.dev_bypass" = "build pengembangan: bypass tor aktif.";
|
|
||||||
"system.tor.restarted" = "tor dimulai ulang. perutean dipulihkan.";
|
|
||||||
"system.tor.restarting" = "tor sedang dimulai ulang untuk memulihkan konektivitas...";
|
|
||||||
"system.tor.started" = "tor berjalan. seluruh chat dirutekan lewat tor demi privasi.";
|
|
||||||
"system.tor.starting" = "menjalankan tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "kode qr verifikasi";
|
|
||||||
"verification.my_qr.title" = "pindai untuk verifikasi";
|
|
||||||
"verification.my_qr.unavailable" = "qr tidak tersedia";
|
|
||||||
"verification.scan.paste_prompt" = "tempel konten qr untuk validasi:";
|
|
||||||
"verification.scan.prompt_friend" = "pindai qr teman";
|
|
||||||
"verification.scan.status.invalid" = "qr tidak valid atau kedaluwarsa";
|
|
||||||
"verification.scan.status.no_peer" = "tidak ada peer yang cocok";
|
|
||||||
"verification.scan.status.requested" = "verifikasi diminta untuk %@";
|
|
||||||
"verification.scan.validate" = "validasi";
|
|
||||||
"verification.sheet.title" = "VERIFIKASI";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d catatan</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d catatan</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d orang</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d orang</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d orang</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d orang</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Italian)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "chiudi";
|
|
||||||
"app_info.done" = "FATTO";
|
|
||||||
"app_info.features.encryption.description" = "messaggi privati cifrati con il protocollo noise";
|
|
||||||
"app_info.features.encryption.title" = "crittografia end-to-end";
|
|
||||||
"app_info.features.extended_range.description" = "i messaggi vengono inoltrati tra peer per arrivare più lontano";
|
|
||||||
"app_info.features.extended_range.title" = "portata estesa";
|
|
||||||
"app_info.features.favorites.description" = "ricevi avvisi quando entrano le tue persone preferite";
|
|
||||||
"app_info.features.favorites.title" = "preferiti";
|
|
||||||
"app_info.features.geohash.description" = "canali geohash per chattare con persone vicine tramite relay anonimi decentralizzati";
|
|
||||||
"app_info.features.geohash.title" = "canali locali";
|
|
||||||
"app_info.features.mentions.description" = "usa @nickname per avvisare persone specifiche";
|
|
||||||
"app_info.features.mentions.title" = "menzioni";
|
|
||||||
"app_info.features.offline.description" = "funziona senza internet usando bluetooth a basso consumo";
|
|
||||||
"app_info.features.offline.title" = "comunicazione offline";
|
|
||||||
"app_info.features.title" = "FUNZIONI";
|
|
||||||
"app_info.how_to_use.change_channels" = "• tocca #mesh per cambiare canale";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• tocca tre volte la chat per svuotarla";
|
|
||||||
"app_info.how_to_use.commands" = "• digita / per vedere i comandi";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• tocca l'icona persone per aprire la barra laterale";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• imposta il tuo nickname toccandolo";
|
|
||||||
"app_info.how_to_use.start_dm" = "• tocca il nome di un peer per avviare un dm";
|
|
||||||
"app_info.how_to_use.title" = "COME SI USA";
|
|
||||||
"app_info.privacy.ephemeral.description" = "nuovo id peer generato regolarmente";
|
|
||||||
"app_info.privacy.ephemeral.title" = "identità effimera";
|
|
||||||
"app_info.privacy.no_tracking.description" = "niente server, account o raccolta dati";
|
|
||||||
"app_info.privacy.no_tracking.title" = "senza tracciamento";
|
|
||||||
"app_info.privacy.panic.description" = "tocca il logo tre volte per cancellare subito tutti i dati";
|
|
||||||
"app_info.privacy.panic.title" = "modalità panico";
|
|
||||||
"app_info.privacy.title" = "PRIVACY";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "la sicurezza dei messaggi privati non è stata ancora auditata completamente. non usarli in situazioni critiche finché questo avviso resta.";
|
|
||||||
"app_info.warning.title" = "AVVISO";
|
|
||||||
"common.cancel" = "annulla";
|
|
||||||
"common.close" = "chiudi";
|
|
||||||
"common.copy" = "copia";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "spento";
|
|
||||||
"common.toggle.on" = "acceso";
|
|
||||||
"common.unknown" = "sconosciuto";
|
|
||||||
"content.accessibility.add_favorite" = "aggiungi ai preferiti";
|
|
||||||
"content.accessibility.available_nostr" = "disponibile via nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "torna alla chat principale";
|
|
||||||
"content.accessibility.connected_mesh" = "connesso tramite mesh";
|
|
||||||
"content.accessibility.encryption_status" = "stato crittografia: %@";
|
|
||||||
"content.accessibility.location_channels" = "canali posizione";
|
|
||||||
"content.accessibility.location_notes" = "note di posizione per questo posto";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "apri chat privata non letta";
|
|
||||||
"content.accessibility.private_chat_header" = "chat privata con %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "raggiungibile via mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "rimuovi dai preferiti";
|
|
||||||
"content.accessibility.send_hint_empty" = "inserisci un messaggio da inviare";
|
|
||||||
"content.accessibility.send_hint_ready" = "tocca due volte per inviare";
|
|
||||||
"content.accessibility.send_message" = "invia messaggio";
|
|
||||||
"content.accessibility.toggle_bookmark" = "cambia segnalibro per #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "tocca due volte per cambiare stato preferito";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "tocca per vedere l'impronta di cifratura";
|
|
||||||
"content.actions.block" = "blocca";
|
|
||||||
"content.actions.direct_message" = "messaggio diretto";
|
|
||||||
"content.actions.hug" = "abbraccia";
|
|
||||||
"content.actions.mention" = "menziona";
|
|
||||||
"content.actions.slap" = "schiaffo";
|
|
||||||
"content.actions.title" = "azioni";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth è disattivato. attiva bluetooth nelle impostazioni per usare bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "impostazioni";
|
|
||||||
"content.alert.bluetooth_required.title" = "serve bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "questo dispositivo non supporta bluetooth. bitchat richiede bluetooth per funzionare.";
|
|
||||||
"content.alert.screenshot.message" = "gli screenshot dei canali posizione rivelano la tua posizione. pensaci prima di condividerli.";
|
|
||||||
"content.alert.screenshot.title" = "attenzione";
|
|
||||||
"content.commands.block" = "blocca o mostra i peer bloccati";
|
|
||||||
"content.commands.clear" = "svuota la chat";
|
|
||||||
"content.commands.favorite" = "aggiungi ai preferiti";
|
|
||||||
"content.commands.hug" = "invia un caldo abbraccio";
|
|
||||||
"content.commands.message" = "invia messaggio privato";
|
|
||||||
"content.commands.slap" = "schiaffeggia qualcuno con una trota";
|
|
||||||
"content.commands.unblock" = "sblocca un peer";
|
|
||||||
"content.commands.unfavorite" = "rimuovi dai preferiti";
|
|
||||||
"content.commands.who" = "vedi chi è online";
|
|
||||||
"content.delivery.delivered_members" = "consegnato a %1$d di %2$d membri";
|
|
||||||
"content.delivery.delivered_to" = "consegnato a %@";
|
|
||||||
"content.delivery.failed" = "non riuscito: %@";
|
|
||||||
"content.delivery.read_by" = "letto da %@";
|
|
||||||
"content.delivery.reason.blocked" = "utente bloccato";
|
|
||||||
"content.delivery.reason.self" = "impossibile inviarti il messaggio";
|
|
||||||
"content.delivery.reason.send_error" = "errore di invio";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "destinatario sconosciuto";
|
|
||||||
"content.delivery.reason.unreachable" = "peer irraggiungibile";
|
|
||||||
"content.header.people" = "PERSONE";
|
|
||||||
"content.help.verification" = "verifica: mostra il mio qr o scansiona un amico";
|
|
||||||
"content.input.message_placeholder" = "scrivi un messaggio...";
|
|
||||||
"content.input.nickname_placeholder" = "nickname";
|
|
||||||
"content.location.enable" = "attiva posizione";
|
|
||||||
"content.message.copy" = "copia messaggio";
|
|
||||||
"content.message.show_less" = "mostra meno";
|
|
||||||
"content.message.show_more" = "mostra di più";
|
|
||||||
"content.notes.location_unavailable" = "posizione non disponibile";
|
|
||||||
"content.notes.title" = "note";
|
|
||||||
"content.payment.cashu" = "paga con cashu";
|
|
||||||
"content.payment.lightning" = "paga con lightning";
|
|
||||||
"encryption.accessibility.establishing" = "avvio crittografia";
|
|
||||||
"encryption.accessibility.failed" = "crittografia fallita";
|
|
||||||
"encryption.accessibility.not_encrypted" = "non crittografato";
|
|
||||||
"encryption.accessibility.secured" = "crittografato";
|
|
||||||
"encryption.accessibility.verified" = "crittografato e verificato";
|
|
||||||
"encryption.status.establishing" = "avvio della crittografia...";
|
|
||||||
"encryption.status.failed" = "crittografia fallita";
|
|
||||||
"encryption.status.not_encrypted" = "non crittografato";
|
|
||||||
"encryption.status.secured" = "crittografato";
|
|
||||||
"encryption.status.verified" = "crittografato e verificato";
|
|
||||||
"fingerprint.action.mark_verified" = "segna come verificato";
|
|
||||||
"fingerprint.action.remove_verification" = "rimuovi verifica";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ NON VERIFICATO";
|
|
||||||
"fingerprint.badge.verified" = "✓ VERIFICATO";
|
|
||||||
"fingerprint.handshake_pending" = "non disponibile - handshake in corso";
|
|
||||||
"fingerprint.message.verified" = "hai verificato l'identità di questa persona.";
|
|
||||||
"fingerprint.message.verify_hint" = "confronta queste impronte con %@ tramite un canale sicuro.";
|
|
||||||
"fingerprint.their_label" = "impronta loro:";
|
|
||||||
"fingerprint.title" = "verifica di sicurezza";
|
|
||||||
"fingerprint.your_label" = "tua impronta:";
|
|
||||||
"geohash_people.action.block" = "blocca";
|
|
||||||
"geohash_people.action.unblock" = "sblocca";
|
|
||||||
"geohash_people.none_nearby" = "nessuno nei dintorni...";
|
|
||||||
"geohash_people.tooltip.blocked" = "bloccato su geohash";
|
|
||||||
"geohash_people.you_suffix" = " (tu)";
|
|
||||||
"location_channels.action.open_settings" = "apri impostazioni";
|
|
||||||
"location_channels.action.remove_access" = "revoca accesso alla posizione";
|
|
||||||
"location_channels.action.request_permissions" = "ottieni la mia posizione e i geohash";
|
|
||||||
"location_channels.action.teleport" = "teletrasporto";
|
|
||||||
"location_channels.bookmarked_section_title" = "salvati";
|
|
||||||
"location_channels.description" = "chatta con le persone vicine tramite canali geohash. condividiamo solo geohash approssimativi, mai il gps esatto. il tuo ip resta nascosto perché tutto il traffico passa da tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash non valido";
|
|
||||||
"location_channels.loading_nearby" = "ricerca canali vicini…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "autorizzazione posizione negata. abilitala nelle impostazioni per usare i canali.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#canali posizione";
|
|
||||||
"location_channels.tor.subtitle" = "nasconde il tuo ip per i canali posizione. consigliato: attivo.";
|
|
||||||
"location_channels.tor.title" = "instradamento tor";
|
|
||||||
"location_levels.block" = "isolato";
|
|
||||||
"location_levels.building" = "edificio";
|
|
||||||
"location_levels.city" = "città";
|
|
||||||
"location_levels.neighborhood" = "quartiere";
|
|
||||||
"location_levels.province" = "provincia";
|
|
||||||
"location_levels.region" = "regione";
|
|
||||||
"location_notes.action.dismiss" = "chiudi";
|
|
||||||
"location_notes.action.retry" = "riprova";
|
|
||||||
"location_notes.description" = "aggiungi brevi note permanenti su questo luogo per chi verrà.";
|
|
||||||
"location_notes.empty_subtitle" = "fai tu la prima nota qui.";
|
|
||||||
"location_notes.empty_title" = "ancora nessuna nota";
|
|
||||||
"location_notes.error.failed_to_send" = "impossibile inviare la nota. %@";
|
|
||||||
"location_notes.error.no_relays" = "nessun relay geo disponibile qui vicino. riprova presto.";
|
|
||||||
"location_notes.loading_notes" = "caricamento note…";
|
|
||||||
"location_notes.loading_recent" = "caricamento note recenti…";
|
|
||||||
"location_notes.no_relays_nearby" = "nessun relay geo vicino";
|
|
||||||
"location_notes.placeholder" = "aggiungi una nota per questo posto";
|
|
||||||
"location_notes.relays_paused" = "relay geo non disponibili; note in pausa";
|
|
||||||
"location_notes.relays_retry_hint" = "le note dipendono dai relay geo. controlla la connessione e riprova.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "nuovi messaggi";
|
|
||||||
"system.chat.blocked" = "impossibile avviare una chat con %@: utente bloccato.";
|
|
||||||
"system.chat.requires_favorite" = "impossibile avviare una chat con %@: servono preferiti reciproci per l'offline.";
|
|
||||||
"system.common.user" = "utente";
|
|
||||||
"system.dm.blocked_generic" = "invio non riuscito: utente bloccato.";
|
|
||||||
"system.dm.blocked_recipient" = "impossibile inviare a %@: utente bloccato.";
|
|
||||||
"system.dm.unreachable" = "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ è stato bloccato nei chat geohash";
|
|
||||||
"system.geohash.unblocked" = "%@ è stato sbloccato nei chat geohash";
|
|
||||||
"system.location.not_in_channel" = "invio fallito: non sei in un canale posizione";
|
|
||||||
"system.location.send_failed" = "impossibile inviare al canale posizione";
|
|
||||||
"system.tor.dev_bypass" = "build di sviluppo: bypass tor attivo.";
|
|
||||||
"system.tor.restarted" = "tor è stato riavviato. instradamento ripristinato.";
|
|
||||||
"system.tor.restarting" = "tor si sta riavviando per ripristinare la connettività...";
|
|
||||||
"system.tor.started" = "tor è avviato. tutta la chat passa da tor per la privacy.";
|
|
||||||
"system.tor.starting" = "avvio tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "codice qr di verifica";
|
|
||||||
"verification.my_qr.title" = "scansiona per verificarmi";
|
|
||||||
"verification.my_qr.unavailable" = "qr non disponibile";
|
|
||||||
"verification.scan.paste_prompt" = "incolla il contenuto del qr per convalidare:";
|
|
||||||
"verification.scan.prompt_friend" = "scansiona il qr di un amico";
|
|
||||||
"verification.scan.status.invalid" = "qr non valido o scaduto";
|
|
||||||
"verification.scan.status.no_peer" = "nessun peer corrispondente trovato";
|
|
||||||
"verification.scan.status.requested" = "verifica richiesta per %@";
|
|
||||||
"verification.scan.validate" = "convalida";
|
|
||||||
"verification.sheet.title" = "VERIFICA";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d nota</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d note</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d persona</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d persone</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d persona</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d persone</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Japanese)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "閉じる";
|
|
||||||
"app_info.done" = "完了";
|
|
||||||
"app_info.features.encryption.description" = "プライベートメッセージはnoiseプロトコルで暗号化されます";
|
|
||||||
"app_info.features.encryption.title" = "エンドツーエンド暗号";
|
|
||||||
"app_info.features.extended_range.description" = "メッセージはピア間でリレーされより遠くに届きます";
|
|
||||||
"app_info.features.extended_range.title" = "拡張レンジ";
|
|
||||||
"app_info.features.favorites.description" = "お気に入りの人が参加したら通知を受け取れます";
|
|
||||||
"app_info.features.favorites.title" = "お気に入り";
|
|
||||||
"app_info.features.geohash.description" = "geohashチャンネルで近くの人と匿名分散リレー越しにチャット";
|
|
||||||
"app_info.features.geohash.title" = "ローカルチャンネル";
|
|
||||||
"app_info.features.mentions.description" = "@nicknameで特定の人に通知";
|
|
||||||
"app_info.features.mentions.title" = "メンション";
|
|
||||||
"app_info.features.offline.description" = "bluetooth low energyでインターネットなしでも動作";
|
|
||||||
"app_info.features.offline.title" = "オフライン通信";
|
|
||||||
"app_info.features.title" = "機能";
|
|
||||||
"app_info.how_to_use.change_channels" = "• #meshをタップしてチャンネルを切り替え";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• チャットを3回タップするとクリア";
|
|
||||||
"app_info.how_to_use.commands" = "• /を入力してコマンド表示";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• 人アイコンをタップしてサイドバーを開く";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• ニックネームをタップして設定";
|
|
||||||
"app_info.how_to_use.start_dm" = "• ピアの名前をタップしてdm開始";
|
|
||||||
"app_info.how_to_use.title" = "使い方";
|
|
||||||
"app_info.privacy.ephemeral.description" = "新しいpeer idが定期的に生成されます";
|
|
||||||
"app_info.privacy.ephemeral.title" = "一時的なアイデンティティ";
|
|
||||||
"app_info.privacy.no_tracking.description" = "サーバーもアカウントもデータ収集もなし";
|
|
||||||
"app_info.privacy.no_tracking.title" = "追跡なし";
|
|
||||||
"app_info.privacy.panic.description" = "ロゴを3回タップすると全データを即削除";
|
|
||||||
"app_info.privacy.panic.title" = "パニックモード";
|
|
||||||
"app_info.privacy.title" = "プライバシー";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "プライベートメッセージの安全性はまだ完全に監査されていません。この警告が消えるまで重要な場面では使わないでください。";
|
|
||||||
"app_info.warning.title" = "警告";
|
|
||||||
"common.cancel" = "キャンセル";
|
|
||||||
"common.close" = "閉じる";
|
|
||||||
"common.copy" = "コピー";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "オフ";
|
|
||||||
"common.toggle.on" = "オン";
|
|
||||||
"common.unknown" = "不明";
|
|
||||||
"content.accessibility.add_favorite" = "お気に入りに追加";
|
|
||||||
"content.accessibility.available_nostr" = "nostrで利用可能";
|
|
||||||
"content.accessibility.back_to_main_chat" = "メインチャットに戻る";
|
|
||||||
"content.accessibility.connected_mesh" = "mesh経由で接続";
|
|
||||||
"content.accessibility.encryption_status" = "暗号状態: %@";
|
|
||||||
"content.accessibility.location_channels" = "ロケーションチャンネル";
|
|
||||||
"content.accessibility.location_notes" = "この場所のロケーションノート";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "未読のプライベートチャットを開く";
|
|
||||||
"content.accessibility.private_chat_header" = "%@とのプライベートチャット";
|
|
||||||
"content.accessibility.reachable_mesh" = "meshで到達可能";
|
|
||||||
"content.accessibility.remove_favorite" = "お気に入りから削除";
|
|
||||||
"content.accessibility.send_hint_empty" = "送信するメッセージを入力";
|
|
||||||
"content.accessibility.send_hint_ready" = "ダブルタップで送信";
|
|
||||||
"content.accessibility.send_message" = "メッセージ送信";
|
|
||||||
"content.accessibility.toggle_bookmark" = "#%@のブックマークを切り替え";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "ダブルタップでお気に入り状態を切り替え";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "暗号フィンガープリントを見る";
|
|
||||||
"content.actions.block" = "ブロック";
|
|
||||||
"content.actions.direct_message" = "ダイレクトメッセージ";
|
|
||||||
"content.actions.hug" = "ハグ";
|
|
||||||
"content.actions.mention" = "メンション";
|
|
||||||
"content.actions.slap" = "ビンタ";
|
|
||||||
"content.actions.title" = "アクション";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetoothがオフです。設定でbluetoothをオンにしてbitchatを使ってください。";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。";
|
|
||||||
"content.alert.bluetooth_required.settings" = "設定";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetoothが必要";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "このデバイスはbluetoothをサポートしていません。bitchatにはbluetoothが必要です。";
|
|
||||||
"content.alert.screenshot.message" = "ロケーションチャンネルのスクリーンショットはあなたの場所を明かします。公開前によく考えてください。";
|
|
||||||
"content.alert.screenshot.title" = "注意";
|
|
||||||
"content.commands.block" = "ブロックまたはブロック済みを表示";
|
|
||||||
"content.commands.clear" = "チャットをクリア";
|
|
||||||
"content.commands.favorite" = "お気に入りに追加";
|
|
||||||
"content.commands.hug" = "あたたかいハグを送る";
|
|
||||||
"content.commands.message" = "プライベートメッセージを送る";
|
|
||||||
"content.commands.slap" = "誰かをトラウトでたたく";
|
|
||||||
"content.commands.unblock" = "ピアのブロックを解除";
|
|
||||||
"content.commands.unfavorite" = "お気に入りから外す";
|
|
||||||
"content.commands.who" = "オンラインの人を見る";
|
|
||||||
"content.delivery.delivered_members" = "%2$d人中%1$d人に配信";
|
|
||||||
"content.delivery.delivered_to" = "%@に配信";
|
|
||||||
"content.delivery.failed" = "失敗: %@";
|
|
||||||
"content.delivery.read_by" = "%@が既読";
|
|
||||||
"content.delivery.reason.blocked" = "ユーザーをブロック中";
|
|
||||||
"content.delivery.reason.self" = "自分には送れません";
|
|
||||||
"content.delivery.reason.send_error" = "送信エラー";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "不明な宛先";
|
|
||||||
"content.delivery.reason.unreachable" = "ピアに到達できません";
|
|
||||||
"content.header.people" = "ユーザー";
|
|
||||||
"content.help.verification" = "検証: 自分のqrを表示するか友達をスキャン";
|
|
||||||
"content.input.message_placeholder" = "メッセージを入力...";
|
|
||||||
"content.input.nickname_placeholder" = "ニックネーム";
|
|
||||||
"content.location.enable" = "位置情報を有効化";
|
|
||||||
"content.message.copy" = "メッセージをコピー";
|
|
||||||
"content.message.show_less" = "表示を減らす";
|
|
||||||
"content.message.show_more" = "さらに表示";
|
|
||||||
"content.notes.location_unavailable" = "位置情報を取得できません";
|
|
||||||
"content.notes.title" = "ノート";
|
|
||||||
"content.payment.cashu" = "cashuで支払う";
|
|
||||||
"content.payment.lightning" = "lightningで支払う";
|
|
||||||
"encryption.accessibility.establishing" = "暗号を確立しています";
|
|
||||||
"encryption.accessibility.failed" = "暗号に失敗";
|
|
||||||
"encryption.accessibility.not_encrypted" = "未暗号";
|
|
||||||
"encryption.accessibility.secured" = "暗号化済み";
|
|
||||||
"encryption.accessibility.verified" = "暗号化し検証済み";
|
|
||||||
"encryption.status.establishing" = "暗号を確立中...";
|
|
||||||
"encryption.status.failed" = "暗号に失敗";
|
|
||||||
"encryption.status.not_encrypted" = "未暗号";
|
|
||||||
"encryption.status.secured" = "暗号化済み";
|
|
||||||
"encryption.status.verified" = "暗号化し検証済み";
|
|
||||||
"fingerprint.action.mark_verified" = "検証済みにする";
|
|
||||||
"fingerprint.action.remove_verification" = "検証を削除";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ 未検証";
|
|
||||||
"fingerprint.badge.verified" = "✓ 検証済み";
|
|
||||||
"fingerprint.handshake_pending" = "利用不可 - handshake進行中";
|
|
||||||
"fingerprint.message.verified" = "この人の身元を確認しました。";
|
|
||||||
"fingerprint.message.verify_hint" = "これらのフィンガープリントを%@と安全なチャネルで比較";
|
|
||||||
"fingerprint.their_label" = "相手のフィンガープリント:";
|
|
||||||
"fingerprint.title" = "セキュリティ検証";
|
|
||||||
"fingerprint.your_label" = "あなたのフィンガープリント:";
|
|
||||||
"geohash_people.action.block" = "ブロック";
|
|
||||||
"geohash_people.action.unblock" = "ブロック解除";
|
|
||||||
"geohash_people.none_nearby" = "近くに誰もいません...";
|
|
||||||
"geohash_people.tooltip.blocked" = "geohashでブロック中";
|
|
||||||
"geohash_people.you_suffix" = " (あなた)";
|
|
||||||
"location_channels.action.open_settings" = "設定を開く";
|
|
||||||
"location_channels.action.remove_access" = "位置アクセスを解除";
|
|
||||||
"location_channels.action.request_permissions" = "位置情報とgeohashを取得";
|
|
||||||
"location_channels.action.teleport" = "テレポート";
|
|
||||||
"location_channels.bookmarked_section_title" = "保存済み";
|
|
||||||
"location_channels.description" = "geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。";
|
|
||||||
"location_channels.error.invalid_geohash" = "無効なgeohash";
|
|
||||||
"location_channels.loading_nearby" = "近くのチャンネルを検索中…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "位置情報の許可が拒否されました。チャンネルを使うには設定で許可してください。";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#ロケーションチャンネル";
|
|
||||||
"location_channels.tor.subtitle" = "ロケーションチャンネル用にipを隠します。推奨: オン";
|
|
||||||
"location_channels.tor.title" = "torルーティング";
|
|
||||||
"location_levels.block" = "ブロック";
|
|
||||||
"location_levels.building" = "建物";
|
|
||||||
"location_levels.city" = "都市";
|
|
||||||
"location_levels.neighborhood" = "近所";
|
|
||||||
"location_levels.province" = "州";
|
|
||||||
"location_levels.region" = "地域";
|
|
||||||
"location_notes.action.dismiss" = "閉じる";
|
|
||||||
"location_notes.action.retry" = "再試行";
|
|
||||||
"location_notes.description" = "他の人が見つけられるようこの場所に短いノートを追加";
|
|
||||||
"location_notes.empty_subtitle" = "ここで最初のノートを残そう。";
|
|
||||||
"location_notes.empty_title" = "ノートはまだありません";
|
|
||||||
"location_notes.error.failed_to_send" = "ノートを送信できませんでした。%@";
|
|
||||||
"location_notes.error.no_relays" = "近くに利用できるジオリレーがありません。後で再試行してください。";
|
|
||||||
"location_notes.loading_notes" = "ノートを読み込み中…";
|
|
||||||
"location_notes.loading_recent" = "最新ノートを読み込み中…";
|
|
||||||
"location_notes.no_relays_nearby" = "近くにジオリレーなし";
|
|
||||||
"location_notes.placeholder" = "この場所のノートを追加";
|
|
||||||
"location_notes.relays_paused" = "ジオリレーが利用不可: ノート一時停止";
|
|
||||||
"location_notes.relays_retry_hint" = "ノートはジオリレーに依存します。接続を確認して再試行してください。";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "新しいメッセージ";
|
|
||||||
"system.chat.blocked" = "%@とはチャットできません: ユーザーをブロック中。";
|
|
||||||
"system.chat.requires_favorite" = "%@とはチャットできません: オフラインには相互のお気に入りが必要です。";
|
|
||||||
"system.common.user" = "ユーザー";
|
|
||||||
"system.dm.blocked_generic" = "送信できません: ユーザーをブロック中。";
|
|
||||||
"system.dm.blocked_recipient" = "%@に送れません: ユーザーをブロック中。";
|
|
||||||
"system.dm.unreachable" = "%@に送れません: 受信者はmeshやnostrで到達できません。";
|
|
||||||
"system.geohash.blocked" = "%@をgeohashチャットでブロックしました";
|
|
||||||
"system.geohash.unblocked" = "%@のgeohashチャットでのブロックを解除しました";
|
|
||||||
"system.location.not_in_channel" = "送信失敗: ロケーションチャンネルに参加していません";
|
|
||||||
"system.location.send_failed" = "ロケーションチャンネルに送信できませんでした";
|
|
||||||
"system.tor.dev_bypass" = "開発ビルド: torバイパスが有効です。";
|
|
||||||
"system.tor.restarted" = "torを再起動しました。ルーティングを復旧。";
|
|
||||||
"system.tor.restarting" = "接続回復のためtorを再起動しています...";
|
|
||||||
"system.tor.started" = "torを起動しました。全チャットをtor経由で配信します。";
|
|
||||||
"system.tor.starting" = "torを起動中...";
|
|
||||||
"verification.my_qr.accessibility_label" = "検証用qrコード";
|
|
||||||
"verification.my_qr.title" = "スキャンして確認";
|
|
||||||
"verification.my_qr.unavailable" = "qrは利用不可";
|
|
||||||
"verification.scan.paste_prompt" = "確認するqr内容を貼り付け:";
|
|
||||||
"verification.scan.prompt_friend" = "友達のqrをスキャン";
|
|
||||||
"verification.scan.status.invalid" = "qrが無効または期限切れ";
|
|
||||||
"verification.scan.status.no_peer" = "該当するピアが見つかりません";
|
|
||||||
"verification.scan.status.requested" = "%@の検証をリクエストしました";
|
|
||||||
"verification.scan.validate" = "確認";
|
|
||||||
"verification.sheet.title" = "確認";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d件のノート</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d件のノート</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d人</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d人</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d人</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d人</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Nepali)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "बन्द";
|
|
||||||
"app_info.done" = "सम्पन्न";
|
|
||||||
"app_info.features.encryption.description" = "व्यक्तिगत सन्देशहरू noise प्रोटोकलले सङ्केत गर्छ";
|
|
||||||
"app_info.features.encryption.title" = "एन्ड-टु-एन्ड सङ्केत";
|
|
||||||
"app_info.features.extended_range.description" = "सन्देशहरू सहकर्मीमार्फत रिले भएर टाढासम्म पुग्छन्";
|
|
||||||
"app_info.features.extended_range.title" = "विस्तारित पहुँच";
|
|
||||||
"app_info.features.favorites.description" = "तिम्रा मनपर्ने मानिस जोडिएपछि सूचनाहरू पाऊ";
|
|
||||||
"app_info.features.favorites.title" = "मनपर्ने";
|
|
||||||
"app_info.features.geohash.description" = "geohash च्यानलहरूले नजिकका व्यक्तिसँग विकेन्द्रित गोप्य रिलेबाट कुराकानी गर्न मद्दत गर्छ";
|
|
||||||
"app_info.features.geohash.title" = "स्थानीय च्यानल";
|
|
||||||
"app_info.features.mentions.description" = "विशेष व्यक्तिलाई सूचित गर्न @nickname प्रयोग गर";
|
|
||||||
"app_info.features.mentions.title" = "उल्लेख";
|
|
||||||
"app_info.features.offline.description" = "bluetooth low energy प्रयोग गरेर इन्टरनेट बिना काम गर्छ";
|
|
||||||
"app_info.features.offline.title" = "अफलाइन सञ्चार";
|
|
||||||
"app_info.features.title" = "विशेषता";
|
|
||||||
"app_info.how_to_use.change_channels" = "• च्यानल बदल्न #mesh ट्याप गर";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• च्याट खाली गर्न तीन पटक ट्याप गर";
|
|
||||||
"app_info.how_to_use.commands" = "• आदेशहरू हेर्न / टाइप गर";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• साइडबार खोल्न मान्छे आइकन ट्याप गर";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• आफ्नो उपनाममा ट्याप गरेर मिलाऊ";
|
|
||||||
"app_info.how_to_use.start_dm" = "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर";
|
|
||||||
"app_info.how_to_use.title" = "प्रयोग गर्ने तरिका";
|
|
||||||
"app_info.privacy.ephemeral.description" = "नयाँ peer id नियमित रूपमा सिर्जना हुन्छ";
|
|
||||||
"app_info.privacy.ephemeral.title" = "क्षणिक पहिचान";
|
|
||||||
"app_info.privacy.no_tracking.description" = "सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन";
|
|
||||||
"app_info.privacy.no_tracking.title" = "ट्र्याकिङ छैन";
|
|
||||||
"app_info.privacy.panic.description" = "लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ";
|
|
||||||
"app_info.privacy.panic.title" = "घबराहट मोड";
|
|
||||||
"app_info.privacy.title" = "गोपनीयता";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "व्यक्तिगत सन्देशको सुरक्षा पूर्ण रूपमा अडिट भएको छैन। यो चेतावनी हट्दासम्म गम्भीर अवस्थामा प्रयोग नगर्नु।";
|
|
||||||
"app_info.warning.title" = "चेतावनी";
|
|
||||||
"common.cancel" = "रद्द";
|
|
||||||
"common.close" = "बन्द";
|
|
||||||
"common.copy" = "प्रतिलिपि";
|
|
||||||
"common.ok" = "ठिक";
|
|
||||||
"common.toggle.off" = "अफ";
|
|
||||||
"common.toggle.on" = "अन";
|
|
||||||
"common.unknown" = "अज्ञात";
|
|
||||||
"content.accessibility.add_favorite" = "मनपर्नेमा थप";
|
|
||||||
"content.accessibility.available_nostr" = "nostr मार्फत उपलब्ध";
|
|
||||||
"content.accessibility.back_to_main_chat" = "मुख्य च्याटमा फर्क";
|
|
||||||
"content.accessibility.connected_mesh" = "mesh मार्फत जडान";
|
|
||||||
"content.accessibility.encryption_status" = "सङ्केतको अवस्था: %@";
|
|
||||||
"content.accessibility.location_channels" = "स्थान च्यानल";
|
|
||||||
"content.accessibility.location_notes" = "यस ठाउँका स्थान नोटहरू";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "नपढिएको निजी च्याट खोल";
|
|
||||||
"content.accessibility.private_chat_header" = "%@ सँग निजी च्याट";
|
|
||||||
"content.accessibility.reachable_mesh" = "mesh मार्फत पहुँचयोग्य";
|
|
||||||
"content.accessibility.remove_favorite" = "मनपर्नेबाट हटाउ";
|
|
||||||
"content.accessibility.send_hint_empty" = "पठाउन सन्देश लेख";
|
|
||||||
"content.accessibility.send_hint_ready" = "पठाउन दोहोरो ट्याप गर";
|
|
||||||
"content.accessibility.send_message" = "सन्देश पठाउ";
|
|
||||||
"content.accessibility.toggle_bookmark" = "#%@ का लागि बुकमार्क बदल";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "सङ्केत फिङ्गरप्रिन्ट हेर्न ट्याप गर";
|
|
||||||
"content.actions.block" = "ब्लक";
|
|
||||||
"content.actions.direct_message" = "प्रत्यक्ष सन्देश";
|
|
||||||
"content.actions.hug" = "अँगालो";
|
|
||||||
"content.actions.mention" = "उल्लेख";
|
|
||||||
"content.actions.slap" = "थप्पड";
|
|
||||||
"content.actions.title" = "कार्य";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth बन्द छ। bitchat प्रयोग गर्न bluetooth सेटिङमा अन गर।";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।";
|
|
||||||
"content.alert.bluetooth_required.settings" = "सेटिङ";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetooth आवश्यक";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "यो उपकरणले bluetooth समर्थन गर्दैन। bitchat चलाउन bluetooth चाहिन्छ।";
|
|
||||||
"content.alert.screenshot.message" = "स्थान च्यानलको स्क्रिनसटले तिम्रो स्थान खुलाउँछ। सार्वजनिकरूपमा बाँड्नु अघि सोच।";
|
|
||||||
"content.alert.screenshot.title" = "ध्यान";
|
|
||||||
"content.commands.block" = "ब्लक गर वा ब्लक गरिएको सूची देखाउ";
|
|
||||||
"content.commands.clear" = "च्याट सन्देश खाली गर";
|
|
||||||
"content.commands.favorite" = "मनपर्नेमा थप";
|
|
||||||
"content.commands.hug" = "न्यानो अँगालो पठाउ";
|
|
||||||
"content.commands.message" = "निजी सन्देश पठाउ";
|
|
||||||
"content.commands.slap" = "कसैलाई ट्राउटले थप्पड दे";
|
|
||||||
"content.commands.unblock" = "पीयर अनब्लक गर";
|
|
||||||
"content.commands.unfavorite" = "मनपर्नेबाट हटाउ";
|
|
||||||
"content.commands.who" = "अनलाइन को-को छन् हेर्नु";
|
|
||||||
"content.delivery.delivered_members" = "%2$d सदस्यमध्ये %1$d जनालाई पुर्याइयो";
|
|
||||||
"content.delivery.delivered_to" = "%@ लाई पुर्याइयो";
|
|
||||||
"content.delivery.failed" = "असफल: %@";
|
|
||||||
"content.delivery.read_by" = "%@ ले पढ्यो";
|
|
||||||
"content.delivery.reason.blocked" = "प्रयोगकर्ता ब्लक गरिएको";
|
|
||||||
"content.delivery.reason.self" = "आफूलाई पठाउन मिल्दैन";
|
|
||||||
"content.delivery.reason.send_error" = "पठाउने त्रुटि";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "अज्ञात प्राप्तकर्ता";
|
|
||||||
"content.delivery.reason.unreachable" = "पीयर पहुँचयोग्य छैन";
|
|
||||||
"content.header.people" = "मानिस";
|
|
||||||
"content.help.verification" = "प्रमाणीकरण: मेरो qr देखाउ वा साथी स्क्यान गर";
|
|
||||||
"content.input.message_placeholder" = "सन्देश टाइप गर...";
|
|
||||||
"content.input.nickname_placeholder" = "उपनाम";
|
|
||||||
"content.location.enable" = "स्थान सक्षम गर";
|
|
||||||
"content.message.copy" = "सन्देश प्रतिलिपि गर";
|
|
||||||
"content.message.show_less" = "थोरै देखाउ";
|
|
||||||
"content.message.show_more" = "थप देखाउ";
|
|
||||||
"content.notes.location_unavailable" = "स्थान उपलब्ध छैन";
|
|
||||||
"content.notes.title" = "नोट";
|
|
||||||
"content.payment.cashu" = "cashu मार्फत तिर्नु";
|
|
||||||
"content.payment.lightning" = "lightning मार्फत तिर्नु";
|
|
||||||
"encryption.accessibility.establishing" = "सङ्केत सेट हुँदै";
|
|
||||||
"encryption.accessibility.failed" = "सङ्केत असफल";
|
|
||||||
"encryption.accessibility.not_encrypted" = "सङ्केत छैन";
|
|
||||||
"encryption.accessibility.secured" = "सङ्केत गरिएको";
|
|
||||||
"encryption.accessibility.verified" = "सङ्केत र प्रमाणित";
|
|
||||||
"encryption.status.establishing" = "सङ्केत सेट गर्दै...";
|
|
||||||
"encryption.status.failed" = "सङ्केत असफल";
|
|
||||||
"encryption.status.not_encrypted" = "सङ्केत छैन";
|
|
||||||
"encryption.status.secured" = "सङ्केत गरिएको";
|
|
||||||
"encryption.status.verified" = "सङ्केत र प्रमाणित";
|
|
||||||
"fingerprint.action.mark_verified" = "प्रमाणित चिन्ह लगाउ";
|
|
||||||
"fingerprint.action.remove_verification" = "प्रमाणीकरण हटाउ";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ प्रमाणित छैन";
|
|
||||||
"fingerprint.badge.verified" = "✓ प्रमाणित";
|
|
||||||
"fingerprint.handshake_pending" = "उपलब्ध छैन - handshake हुँदै";
|
|
||||||
"fingerprint.message.verified" = "तिमीले यस व्यक्तिको पहिचान प्रमाणित गरेको छौ.";
|
|
||||||
"fingerprint.message.verify_hint" = "यी फिङ्गरप्रिन्टहरू %@ सँग सुरक्षित च्यानलमा तुलना गर।";
|
|
||||||
"fingerprint.their_label" = "उनको फिङ्गरप्रिन्ट:";
|
|
||||||
"fingerprint.title" = "सुरक्षा प्रमाणीकरण";
|
|
||||||
"fingerprint.your_label" = "तिम्रो फिङ्गरप्रिन्ट:";
|
|
||||||
"geohash_people.action.block" = "ब्लक";
|
|
||||||
"geohash_people.action.unblock" = "अनब्लक";
|
|
||||||
"geohash_people.none_nearby" = "वरिपरि कोही छैन...";
|
|
||||||
"geohash_people.tooltip.blocked" = "geohash मा ब्लक";
|
|
||||||
"geohash_people.you_suffix" = " (तिमी)";
|
|
||||||
"location_channels.action.open_settings" = "सेटिङ खोल";
|
|
||||||
"location_channels.action.remove_access" = "स्थान पहुँच हटाउ";
|
|
||||||
"location_channels.action.request_permissions" = "मेरो स्थान र geohash प्राप्त गर";
|
|
||||||
"location_channels.action.teleport" = "टेलिपोर्ट";
|
|
||||||
"location_channels.bookmarked_section_title" = "बुकमार्क";
|
|
||||||
"location_channels.description" = "geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।";
|
|
||||||
"location_channels.error.invalid_geohash" = "अवैध geohash";
|
|
||||||
"location_channels.loading_nearby" = "नजिकका च्यानल खोज्दै…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "स्थान अनुमति अस्वीकार। स्थान च्यानल प्रयोग गर्न सेटिङमा सक्षम गर।";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#स्थान च्यानल";
|
|
||||||
"location_channels.tor.subtitle" = "स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।";
|
|
||||||
"location_channels.tor.title" = "tor रूटिङ";
|
|
||||||
"location_levels.block" = "ब्लक";
|
|
||||||
"location_levels.building" = "भवन";
|
|
||||||
"location_levels.city" = "सहर";
|
|
||||||
"location_levels.neighborhood" = "छिमेक";
|
|
||||||
"location_levels.province" = "प्रदेश";
|
|
||||||
"location_levels.region" = "क्षेत्र";
|
|
||||||
"location_notes.action.dismiss" = "बन्द गर";
|
|
||||||
"location_notes.action.retry" = "फेरि प्रयास गर";
|
|
||||||
"location_notes.description" = "अन्यले भेटून् भनी यस स्थानमा छोटो स्थायी नोट थप।";
|
|
||||||
"location_notes.empty_subtitle" = "यस ठाउँमा नोट थप्ने पहिलो व्यक्ती बन।";
|
|
||||||
"location_notes.empty_title" = "अहिले नोट छैन";
|
|
||||||
"location_notes.error.failed_to_send" = "नोट पठाउन सकेन। %@";
|
|
||||||
"location_notes.error.no_relays" = "यस स्थान नजिक georelay उपलब्ध छैन। केही बेरपछि प्रयास गर।";
|
|
||||||
"location_notes.loading_notes" = "नोट लोड हुँदै…";
|
|
||||||
"location_notes.loading_recent" = "हालैका नोट लोड गर्दै…";
|
|
||||||
"location_notes.no_relays_nearby" = "नजिक georelay छैन";
|
|
||||||
"location_notes.placeholder" = "यस स्थानका लागि नोट थप";
|
|
||||||
"location_notes.relays_paused" = "georelay उपलब्ध छैन; नोट रोकिएको";
|
|
||||||
"location_notes.relays_retry_hint" = "नोट georelay मा निर्भर छन्। जडान जाँच गरेर फेरि प्रयास गर.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "नयाँ सन्देश";
|
|
||||||
"system.chat.blocked" = "%@ सँग च्याट सुरु गर्न मिलेन: प्रयोगकर्ता ब्लक गरिएको";
|
|
||||||
"system.chat.requires_favorite" = "%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ";
|
|
||||||
"system.common.user" = "प्रयोगकर्ता";
|
|
||||||
"system.dm.blocked_generic" = "पठाउन मिलेन: प्रयोगकर्ता ब्लक";
|
|
||||||
"system.dm.blocked_recipient" = "%@ लाई पठाउन मिलेन: प्रयोगकर्ता ब्लक";
|
|
||||||
"system.dm.unreachable" = "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन";
|
|
||||||
"system.geohash.blocked" = "%@ लाई geohash च्याटमा ब्लक गरियो";
|
|
||||||
"system.geohash.unblocked" = "%@ लाई geohash च्याटमा अनब्लक गरियो";
|
|
||||||
"system.location.not_in_channel" = "पठाउन मिलेन: तिमी स्थान च्यानलमा छैनौ";
|
|
||||||
"system.location.send_failed" = "स्थान च्यानलमा पठाउन सकेन";
|
|
||||||
"system.tor.dev_bypass" = "डेभ बिल्ड: tor बाइपास सक्षम।";
|
|
||||||
"system.tor.restarted" = "tor फेरि सुरु भयो। रूटिङ पुनःस्थापित।";
|
|
||||||
"system.tor.restarting" = "tor जडान फर्काउन पुनः सुरु हुँदैछ...";
|
|
||||||
"system.tor.started" = "tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।";
|
|
||||||
"system.tor.starting" = "tor सुरु हुँदै...";
|
|
||||||
"verification.my_qr.accessibility_label" = "प्रमाणीकरण qr कोड";
|
|
||||||
"verification.my_qr.title" = "मलाई प्रमाणित गर्न स्क्यान गर";
|
|
||||||
"verification.my_qr.unavailable" = "qr उपलब्ध छैन";
|
|
||||||
"verification.scan.paste_prompt" = "प्रमाणित गर्न qr सामग्री पेस्ट गर:";
|
|
||||||
"verification.scan.prompt_friend" = "साथीको qr स्क्यान गर";
|
|
||||||
"verification.scan.status.invalid" = "qr अवैध या म्याद सकिएको";
|
|
||||||
"verification.scan.status.no_peer" = "मिल्ने peer फेला परेन";
|
|
||||||
"verification.scan.status.requested" = "%@ को लागि प्रमाणीकरण अनुरोध भयो";
|
|
||||||
"verification.scan.validate" = "प्रमाणित गर";
|
|
||||||
"verification.sheet.title" = "प्रमाणित";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d नोट</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d नोटहरू</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d व्यक्ति</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d व्यक्तिहरू</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d व्यक्ति</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d व्यक्तिहरू</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Portuguese - Brazil)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "fechar";
|
|
||||||
"app_info.done" = "CONCLUÍDO";
|
|
||||||
"app_info.features.encryption.description" = "mensagens privadas criptografadas com o protocolo noise";
|
|
||||||
"app_info.features.encryption.title" = "criptografia ponto a ponto";
|
|
||||||
"app_info.features.extended_range.description" = "mensagens retransmitidas entre pares para alcançar mais longe";
|
|
||||||
"app_info.features.extended_range.title" = "alcance estendido";
|
|
||||||
"app_info.features.favorites.description" = "receba avisos quando suas pessoas favoritas entrarem";
|
|
||||||
"app_info.features.favorites.title" = "favoritos";
|
|
||||||
"app_info.features.geohash.description" = "canais geohash para conversar com pessoas em regiões próximas por relays descentralizados anônimos";
|
|
||||||
"app_info.features.geohash.title" = "canais locais";
|
|
||||||
"app_info.features.mentions.description" = "use @nickname para notificar pessoas específicas";
|
|
||||||
"app_info.features.mentions.title" = "menções";
|
|
||||||
"app_info.features.offline.description" = "funciona sem internet usando bluetooth de baixa energia";
|
|
||||||
"app_info.features.offline.title" = "comunicação offline";
|
|
||||||
"app_info.features.title" = "RECURSOS";
|
|
||||||
"app_info.how_to_use.change_channels" = "• toque #mesh para trocar de canal";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• toque o chat três vezes para limpar";
|
|
||||||
"app_info.how_to_use.commands" = "• digite / para ver comandos";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• toque o ícone de pessoas para abrir a barra lateral";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• defina seu apelido tocando nele";
|
|
||||||
"app_info.how_to_use.start_dm" = "• toque o nome de um par para iniciar um dm";
|
|
||||||
"app_info.how_to_use.title" = "COMO USAR";
|
|
||||||
"app_info.privacy.ephemeral.description" = "novo id de peer gerado regularmente";
|
|
||||||
"app_info.privacy.ephemeral.title" = "identidade efêmera";
|
|
||||||
"app_info.privacy.no_tracking.description" = "sem servidores, contas ou coleta de dados";
|
|
||||||
"app_info.privacy.no_tracking.title" = "sem rastreamento";
|
|
||||||
"app_info.privacy.panic.description" = "toque o logo três vezes para limpar todos os dados instantaneamente";
|
|
||||||
"app_info.privacy.panic.title" = "modo pânico";
|
|
||||||
"app_info.privacy.title" = "PRIVACIDADE";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "a segurança das mensagens privadas ainda não foi totalmente auditada. não use em situações críticas até que este aviso desapareça.";
|
|
||||||
"app_info.warning.title" = "AVISO";
|
|
||||||
"common.cancel" = "cancelar";
|
|
||||||
"common.close" = "fechar";
|
|
||||||
"common.copy" = "copiar";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "desligado";
|
|
||||||
"common.toggle.on" = "ligado";
|
|
||||||
"common.unknown" = "desconhecido";
|
|
||||||
"content.accessibility.add_favorite" = "adicionar aos favoritos";
|
|
||||||
"content.accessibility.available_nostr" = "disponível via nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "voltar ao chat principal";
|
|
||||||
"content.accessibility.connected_mesh" = "conectado por mesh";
|
|
||||||
"content.accessibility.encryption_status" = "status da criptografia: %@";
|
|
||||||
"content.accessibility.location_channels" = "canais de localização";
|
|
||||||
"content.accessibility.location_notes" = "notas de localização deste lugar";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "abrir chat privado não lido";
|
|
||||||
"content.accessibility.private_chat_header" = "chat privado com %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "alcançável por mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "remover dos favoritos";
|
|
||||||
"content.accessibility.send_hint_empty" = "digite uma mensagem para enviar";
|
|
||||||
"content.accessibility.send_hint_ready" = "toque duas vezes para enviar";
|
|
||||||
"content.accessibility.send_message" = "enviar mensagem";
|
|
||||||
"content.accessibility.toggle_bookmark" = "alternar favorito para #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "toque duas vezes para alternar status de favorito";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "toque para ver a impressão de criptografia";
|
|
||||||
"content.actions.block" = "bloquear";
|
|
||||||
"content.actions.direct_message" = "mensagem direta";
|
|
||||||
"content.actions.hug" = "abraço";
|
|
||||||
"content.actions.mention" = "mencionar";
|
|
||||||
"content.actions.slap" = "tapa";
|
|
||||||
"content.actions.title" = "ações";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth está desligado. ative o bluetooth em ajustes para usar bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "ajustes";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetooth necessário";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "este dispositivo não suporta bluetooth. bitchat precisa de bluetooth para funcionar.";
|
|
||||||
"content.alert.screenshot.message" = "capturas de canais de localização revelam sua localização. pense antes de compartilhar publicamente.";
|
|
||||||
"content.alert.screenshot.title" = "atenção";
|
|
||||||
"content.commands.block" = "bloquear ou listar pares bloqueados";
|
|
||||||
"content.commands.clear" = "limpar mensagens do chat";
|
|
||||||
"content.commands.favorite" = "adicionar aos favoritos";
|
|
||||||
"content.commands.hug" = "enviar um abraço quente";
|
|
||||||
"content.commands.message" = "enviar mensagem privada";
|
|
||||||
"content.commands.slap" = "dar um tapa em alguém com uma truta";
|
|
||||||
"content.commands.unblock" = "desbloquear um par";
|
|
||||||
"content.commands.unfavorite" = "remover dos favoritos";
|
|
||||||
"content.commands.who" = "ver quem está online";
|
|
||||||
"content.delivery.delivered_members" = "entregue a %1$d de %2$d membros";
|
|
||||||
"content.delivery.delivered_to" = "entregue para %@";
|
|
||||||
"content.delivery.failed" = "falhou: %@";
|
|
||||||
"content.delivery.read_by" = "lido por %@";
|
|
||||||
"content.delivery.reason.blocked" = "usuário bloqueado";
|
|
||||||
"content.delivery.reason.self" = "não é possível enviar mensagem para si mesmo";
|
|
||||||
"content.delivery.reason.send_error" = "erro ao enviar";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "destinatário desconhecido";
|
|
||||||
"content.delivery.reason.unreachable" = "par inalcançável";
|
|
||||||
"content.header.people" = "PESSOAS";
|
|
||||||
"content.help.verification" = "verificação: mostrar meu qr ou escanear um amigo";
|
|
||||||
"content.input.message_placeholder" = "digite uma mensagem...";
|
|
||||||
"content.input.nickname_placeholder" = "apelido";
|
|
||||||
"content.location.enable" = "habilitar localização";
|
|
||||||
"content.message.copy" = "copiar mensagem";
|
|
||||||
"content.message.show_less" = "mostrar menos";
|
|
||||||
"content.message.show_more" = "mostrar mais";
|
|
||||||
"content.notes.location_unavailable" = "localização indisponível";
|
|
||||||
"content.notes.title" = "notas";
|
|
||||||
"content.payment.cashu" = "pagar via cashu";
|
|
||||||
"content.payment.lightning" = "pagar via lightning";
|
|
||||||
"encryption.accessibility.establishing" = "estabelecendo criptografia";
|
|
||||||
"encryption.accessibility.failed" = "falha na criptografia";
|
|
||||||
"encryption.accessibility.not_encrypted" = "não criptografado";
|
|
||||||
"encryption.accessibility.secured" = "criptografado";
|
|
||||||
"encryption.accessibility.verified" = "criptografado e verificado";
|
|
||||||
"encryption.status.establishing" = "estabelecendo criptografia...";
|
|
||||||
"encryption.status.failed" = "falha na criptografia";
|
|
||||||
"encryption.status.not_encrypted" = "não criptografado";
|
|
||||||
"encryption.status.secured" = "criptografado";
|
|
||||||
"encryption.status.verified" = "criptografado e verificado";
|
|
||||||
"fingerprint.action.mark_verified" = "marcar como verificado";
|
|
||||||
"fingerprint.action.remove_verification" = "remover verificação";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ NÃO VERIFICADO";
|
|
||||||
"fingerprint.badge.verified" = "✓ VERIFICADO";
|
|
||||||
"fingerprint.handshake_pending" = "indisponível - handshake em andamento";
|
|
||||||
"fingerprint.message.verified" = "você verificou a identidade dessa pessoa.";
|
|
||||||
"fingerprint.message.verify_hint" = "compare essas impressões com %@ usando um canal seguro.";
|
|
||||||
"fingerprint.their_label" = "impressão digital deles:";
|
|
||||||
"fingerprint.title" = "verificação de segurança";
|
|
||||||
"fingerprint.your_label" = "sua impressão digital:";
|
|
||||||
"geohash_people.action.block" = "bloquear";
|
|
||||||
"geohash_people.action.unblock" = "desbloquear";
|
|
||||||
"geohash_people.none_nearby" = "ninguém por perto...";
|
|
||||||
"geohash_people.tooltip.blocked" = "bloqueado em geohash";
|
|
||||||
"geohash_people.you_suffix" = " (você)";
|
|
||||||
"location_channels.action.open_settings" = "abrir ajustes";
|
|
||||||
"location_channels.action.remove_access" = "remover acesso à localização";
|
|
||||||
"location_channels.action.request_permissions" = "obter localização e meus geohashes";
|
|
||||||
"location_channels.action.teleport" = "teletransportar";
|
|
||||||
"location_channels.bookmarked_section_title" = "marcados";
|
|
||||||
"location_channels.description" = "converse com pessoas próximas usando canais geohash. apenas um geohash grosseiro é compartilhado, nunca gps exato. seu ip fica oculto ao rotear todo o tráfego por tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "geohash inválido";
|
|
||||||
"location_channels.loading_nearby" = "procurando canais próximos…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "permissão de localização negada. habilite em ajustes para usar canais de localização.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#canais de localização";
|
|
||||||
"location_channels.tor.subtitle" = "oculta seu ip para canais de localização. recomendado: ligado.";
|
|
||||||
"location_channels.tor.title" = "roteamento tor";
|
|
||||||
"location_levels.block" = "quadra";
|
|
||||||
"location_levels.building" = "prédio";
|
|
||||||
"location_levels.city" = "cidade";
|
|
||||||
"location_levels.neighborhood" = "bairro";
|
|
||||||
"location_levels.province" = "estado";
|
|
||||||
"location_levels.region" = "região";
|
|
||||||
"location_notes.action.dismiss" = "dispensar";
|
|
||||||
"location_notes.action.retry" = "tentar novamente";
|
|
||||||
"location_notes.description" = "adicione notas curtas permanentes neste local para outras pessoas encontrarem.";
|
|
||||||
"location_notes.empty_subtitle" = "seja a primeira pessoa a adicionar uma aqui.";
|
|
||||||
"location_notes.empty_title" = "nenhuma nota ainda";
|
|
||||||
"location_notes.error.failed_to_send" = "não foi possível enviar a nota. %@";
|
|
||||||
"location_notes.error.no_relays" = "nenhum relay geográfico disponível perto deste local. tente novamente em breve.";
|
|
||||||
"location_notes.loading_notes" = "carregando notas…";
|
|
||||||
"location_notes.loading_recent" = "carregando notas recentes…";
|
|
||||||
"location_notes.no_relays_nearby" = "nenhum relay geográfico próximo";
|
|
||||||
"location_notes.placeholder" = "adicione uma nota para este lugar";
|
|
||||||
"location_notes.relays_paused" = "relays geográficos indisponíveis; notas pausadas";
|
|
||||||
"location_notes.relays_retry_hint" = "notas dependem de relays geográficos. verifique a conexão e tente de novo.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "novas mensagens";
|
|
||||||
"system.chat.blocked" = "não é possível iniciar chat com %@: usuário bloqueado.";
|
|
||||||
"system.chat.requires_favorite" = "não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline.";
|
|
||||||
"system.common.user" = "usuário";
|
|
||||||
"system.dm.blocked_generic" = "não foi possível enviar: usuário bloqueado.";
|
|
||||||
"system.dm.blocked_recipient" = "não é possível enviar mensagem para %@: usuário bloqueado.";
|
|
||||||
"system.dm.unreachable" = "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ foi bloqueado nos chats geohash";
|
|
||||||
"system.geohash.unblocked" = "%@ foi desbloqueado nos chats geohash";
|
|
||||||
"system.location.not_in_channel" = "não foi possível enviar: você não está em um canal de localização";
|
|
||||||
"system.location.send_failed" = "não foi possível enviar para o canal de localização";
|
|
||||||
"system.tor.dev_bypass" = "compilação de desenvolvimento: bypass de tor ativo.";
|
|
||||||
"system.tor.restarted" = "tor reiniciou. roteamento restaurado.";
|
|
||||||
"system.tor.restarting" = "tor está reiniciando para recuperar conectividade...";
|
|
||||||
"system.tor.started" = "tor iniciou. todo o chat é roteado por tor para privacidade.";
|
|
||||||
"system.tor.starting" = "iniciando tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "código qr de verificação";
|
|
||||||
"verification.my_qr.title" = "escaneie para me verificar";
|
|
||||||
"verification.my_qr.unavailable" = "qr indisponível";
|
|
||||||
"verification.scan.paste_prompt" = "cole o conteúdo do qr para validar:";
|
|
||||||
"verification.scan.prompt_friend" = "escaneie o qr de um amigo";
|
|
||||||
"verification.scan.status.invalid" = "qr inválido ou expirado";
|
|
||||||
"verification.scan.status.no_peer" = "nenhum peer correspondente encontrado";
|
|
||||||
"verification.scan.status.requested" = "verificação solicitada para %@";
|
|
||||||
"verification.scan.validate" = "validar";
|
|
||||||
"verification.sheet.title" = "VERIFICAR";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d nota</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d notas</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d pessoa</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d pessoas</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d pessoa</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d pessoas</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Russian)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "закрыть";
|
|
||||||
"app_info.done" = "ГОТОВО";
|
|
||||||
"app_info.features.encryption.description" = "личные сообщения шифруются протоколом noise";
|
|
||||||
"app_info.features.encryption.title" = "сквозное шифрование";
|
|
||||||
"app_info.features.extended_range.description" = "сообщения ретранслируются между пирами и уходят дальше";
|
|
||||||
"app_info.features.extended_range.title" = "расширенный радиус";
|
|
||||||
"app_info.features.favorites.description" = "получай уведомления, когда подключаются любимые люди";
|
|
||||||
"app_info.features.favorites.title" = "избранное";
|
|
||||||
"app_info.features.geohash.description" = "каналы geohash для чата с людьми поблизости через децентрализованные анонимные реле";
|
|
||||||
"app_info.features.geohash.title" = "локальные каналы";
|
|
||||||
"app_info.features.mentions.description" = "используй @nickname, чтобы уведомить конкретных людей";
|
|
||||||
"app_info.features.mentions.title" = "упоминания";
|
|
||||||
"app_info.features.offline.description" = "работает без интернета через bluetooth low energy";
|
|
||||||
"app_info.features.offline.title" = "офлайн-связь";
|
|
||||||
"app_info.features.title" = "ВОЗМОЖНОСТИ";
|
|
||||||
"app_info.how_to_use.change_channels" = "• нажми #mesh, чтобы сменить канал";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• тройной тап по чату очистит его";
|
|
||||||
"app_info.how_to_use.commands" = "• введи /, чтобы увидеть команды";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• нажми на иконку людей, чтобы открыть боковое меню";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• коснись своего ника, чтобы изменить его";
|
|
||||||
"app_info.how_to_use.start_dm" = "• нажми имя пользователя, чтобы начать лс";
|
|
||||||
"app_info.how_to_use.title" = "КАК ИСПОЛЬЗОВАТЬ";
|
|
||||||
"app_info.privacy.ephemeral.description" = "новый id пира создаётся регулярно";
|
|
||||||
"app_info.privacy.ephemeral.title" = "эфемерная личность";
|
|
||||||
"app_info.privacy.no_tracking.description" = "без серверов, аккаунтов и сбора данных";
|
|
||||||
"app_info.privacy.no_tracking.title" = "без трекинга";
|
|
||||||
"app_info.privacy.panic.description" = "тройной тап по логотипу мгновенно очищает все данные";
|
|
||||||
"app_info.privacy.panic.title" = "режим паники";
|
|
||||||
"app_info.privacy.title" = "КОНФИДЕНЦИАЛЬНОСТЬ";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "безопасность приватных сообщений ещё не прошла полный аудит. не используй для критичных случаев, пока предупреждение не исчезнет.";
|
|
||||||
"app_info.warning.title" = "ПРЕДУПРЕЖДЕНИЕ";
|
|
||||||
"common.cancel" = "отмена";
|
|
||||||
"common.close" = "закрыть";
|
|
||||||
"common.copy" = "копировать";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "выкл";
|
|
||||||
"common.toggle.on" = "вкл";
|
|
||||||
"common.unknown" = "неизвестно";
|
|
||||||
"content.accessibility.add_favorite" = "добавить в избранное";
|
|
||||||
"content.accessibility.available_nostr" = "доступно через nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "назад в основной чат";
|
|
||||||
"content.accessibility.connected_mesh" = "подключено через mesh";
|
|
||||||
"content.accessibility.encryption_status" = "статус шифрования: %@";
|
|
||||||
"content.accessibility.location_channels" = "каналы локации";
|
|
||||||
"content.accessibility.location_notes" = "заметки для этого места";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "открыть непрочитанный приватный чат";
|
|
||||||
"content.accessibility.private_chat_header" = "приватный чат с %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "достижим через mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "убрать из избранного";
|
|
||||||
"content.accessibility.send_hint_empty" = "введи сообщение для отправки";
|
|
||||||
"content.accessibility.send_hint_ready" = "дважды тапни, чтобы отправить";
|
|
||||||
"content.accessibility.send_message" = "отправить сообщение";
|
|
||||||
"content.accessibility.toggle_bookmark" = "переключить закладку для #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "дважды тапни, чтобы переключить статус избранного";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "нажми, чтобы увидеть криптографический отпечаток";
|
|
||||||
"content.actions.block" = "заблокировать";
|
|
||||||
"content.actions.direct_message" = "личное сообщение";
|
|
||||||
"content.actions.hug" = "обнять";
|
|
||||||
"content.actions.mention" = "упомянуть";
|
|
||||||
"content.actions.slap" = "дать леща";
|
|
||||||
"content.actions.title" = "действия";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth выключен. включи bluetooth в настройках, чтобы использовать bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "настройки";
|
|
||||||
"content.alert.bluetooth_required.title" = "bluetooth обязателен";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "это устройство не поддерживает bluetooth. bitchat нужен bluetooth для работы.";
|
|
||||||
"content.alert.screenshot.message" = "скриншоты каналов местоположения раскроют твою позицию. подумай, прежде чем делиться публично.";
|
|
||||||
"content.alert.screenshot.title" = "внимание";
|
|
||||||
"content.commands.block" = "заблокировать или показать заблокированных";
|
|
||||||
"content.commands.clear" = "очистить чат";
|
|
||||||
"content.commands.favorite" = "добавить в избранное";
|
|
||||||
"content.commands.hug" = "отправить тёплое объятие";
|
|
||||||
"content.commands.message" = "отправить приватное сообщение";
|
|
||||||
"content.commands.slap" = "дать кому-то пощёчину форелью";
|
|
||||||
"content.commands.unblock" = "разблокировать пира";
|
|
||||||
"content.commands.unfavorite" = "убрать из избранного";
|
|
||||||
"content.commands.who" = "посмотреть, кто онлайн";
|
|
||||||
"content.delivery.delivered_members" = "доставлено %1$d из %2$d участников";
|
|
||||||
"content.delivery.delivered_to" = "доставлено %@";
|
|
||||||
"content.delivery.failed" = "ошибка: %@";
|
|
||||||
"content.delivery.read_by" = "прочитано %@";
|
|
||||||
"content.delivery.reason.blocked" = "пользователь заблокирован";
|
|
||||||
"content.delivery.reason.self" = "нельзя отправить себе";
|
|
||||||
"content.delivery.reason.send_error" = "ошибка отправки";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "неизвестный получатель";
|
|
||||||
"content.delivery.reason.unreachable" = "пир недостижим";
|
|
||||||
"content.header.people" = "ЛЮДИ";
|
|
||||||
"content.help.verification" = "верификация: показать мой qr или сканировать друга";
|
|
||||||
"content.input.message_placeholder" = "напиши сообщение...";
|
|
||||||
"content.input.nickname_placeholder" = "ник";
|
|
||||||
"content.location.enable" = "включить локацию";
|
|
||||||
"content.message.copy" = "копировать сообщение";
|
|
||||||
"content.message.show_less" = "показать меньше";
|
|
||||||
"content.message.show_more" = "показать больше";
|
|
||||||
"content.notes.location_unavailable" = "локация недоступна";
|
|
||||||
"content.notes.title" = "заметки";
|
|
||||||
"content.payment.cashu" = "оплатить через cashu";
|
|
||||||
"content.payment.lightning" = "оплатить через lightning";
|
|
||||||
"encryption.accessibility.establishing" = "устанавливается шифрование";
|
|
||||||
"encryption.accessibility.failed" = "шифрование не удалось";
|
|
||||||
"encryption.accessibility.not_encrypted" = "не зашифровано";
|
|
||||||
"encryption.accessibility.secured" = "зашифровано";
|
|
||||||
"encryption.accessibility.verified" = "зашифровано и проверено";
|
|
||||||
"encryption.status.establishing" = "устанавливаем шифрование...";
|
|
||||||
"encryption.status.failed" = "шифрование не удалось";
|
|
||||||
"encryption.status.not_encrypted" = "не зашифровано";
|
|
||||||
"encryption.status.secured" = "зашифровано";
|
|
||||||
"encryption.status.verified" = "зашифровано и проверено";
|
|
||||||
"fingerprint.action.mark_verified" = "пометить как проверено";
|
|
||||||
"fingerprint.action.remove_verification" = "удалить проверку";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ НЕ ПРОВЕРЕНО";
|
|
||||||
"fingerprint.badge.verified" = "✓ ПРОВЕРЕНО";
|
|
||||||
"fingerprint.handshake_pending" = "недоступно — handshake выполняется";
|
|
||||||
"fingerprint.message.verified" = "ты подтвердил личность этого человека.";
|
|
||||||
"fingerprint.message.verify_hint" = "сравни эти отпечатки с %@ по безопасному каналу.";
|
|
||||||
"fingerprint.their_label" = "их отпечаток:";
|
|
||||||
"fingerprint.title" = "проверка безопасности";
|
|
||||||
"fingerprint.your_label" = "твой отпечаток:";
|
|
||||||
"geohash_people.action.block" = "заблокировать";
|
|
||||||
"geohash_people.action.unblock" = "разблокировать";
|
|
||||||
"geohash_people.none_nearby" = "никого рядом...";
|
|
||||||
"geohash_people.tooltip.blocked" = "заблокирован в geohash";
|
|
||||||
"geohash_people.you_suffix" = " (ты)";
|
|
||||||
"location_channels.action.open_settings" = "открыть настройки";
|
|
||||||
"location_channels.action.remove_access" = "отключить доступ к локации";
|
|
||||||
"location_channels.action.request_permissions" = "получить мою локацию и geohash";
|
|
||||||
"location_channels.action.teleport" = "телепорт";
|
|
||||||
"location_channels.bookmarked_section_title" = "закреплённые";
|
|
||||||
"location_channels.description" = "общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "некорректный geohash";
|
|
||||||
"location_channels.loading_nearby" = "поиск каналов рядом…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "доступ к локации запрещён. включи разрешение в настройках, чтобы использовать каналы.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#каналы локации";
|
|
||||||
"location_channels.tor.subtitle" = "скрывает твой ip для каналов локации. рекомендуем включить.";
|
|
||||||
"location_channels.tor.title" = "маршрутизация tor";
|
|
||||||
"location_levels.block" = "квартал";
|
|
||||||
"location_levels.building" = "здание";
|
|
||||||
"location_levels.city" = "город";
|
|
||||||
"location_levels.neighborhood" = "район";
|
|
||||||
"location_levels.province" = "область";
|
|
||||||
"location_levels.region" = "регион";
|
|
||||||
"location_notes.action.dismiss" = "закрыть";
|
|
||||||
"location_notes.action.retry" = "повторить";
|
|
||||||
"location_notes.description" = "добавь короткие постоянные заметки об этом месте для других.";
|
|
||||||
"location_notes.empty_subtitle" = "стань первым, кто добавит здесь заметку.";
|
|
||||||
"location_notes.empty_title" = "заметок пока нет";
|
|
||||||
"location_notes.error.failed_to_send" = "не удалось отправить заметку. %@";
|
|
||||||
"location_notes.error.no_relays" = "рядом нет георелеев. попробуй позже.";
|
|
||||||
"location_notes.loading_notes" = "загрузка заметок…";
|
|
||||||
"location_notes.loading_recent" = "загрузка свежих заметок…";
|
|
||||||
"location_notes.no_relays_nearby" = "рядом нет георелеев";
|
|
||||||
"location_notes.placeholder" = "добавь заметку для этого места";
|
|
||||||
"location_notes.relays_paused" = "геореле недоступны; заметки приостановлены";
|
|
||||||
"location_notes.relays_retry_hint" = "заметки зависят от георелеев. проверь подключение и попробуй снова.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "новые сообщения";
|
|
||||||
"system.chat.blocked" = "нельзя начать чат с %@: пользователь заблокирован.";
|
|
||||||
"system.chat.requires_favorite" = "нельзя начать чат с %@: нужны взаимные избранные для офлайна.";
|
|
||||||
"system.common.user" = "пользователь";
|
|
||||||
"system.dm.blocked_generic" = "отправка невозможна: пользователь заблокирован.";
|
|
||||||
"system.dm.blocked_recipient" = "нельзя отправить %@: пользователь заблокирован.";
|
|
||||||
"system.dm.unreachable" = "нельзя отправить %@: адресат недоступен через mesh или nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ заблокирован в geohash-чатах";
|
|
||||||
"system.geohash.unblocked" = "%@ разблокирован в geohash-чатах";
|
|
||||||
"system.location.not_in_channel" = "отправка невозможна: ты не в канале локации";
|
|
||||||
"system.location.send_failed" = "не удалось отправить в канал локации";
|
|
||||||
"system.tor.dev_bypass" = "dev-сборка: обход tor включён.";
|
|
||||||
"system.tor.restarted" = "tor перезапущен. маршрутизация восстановлена.";
|
|
||||||
"system.tor.restarting" = "tor перезапускается, чтобы вернуть связь...";
|
|
||||||
"system.tor.started" = "tor запущен. весь чат идёт через tor для приватности.";
|
|
||||||
"system.tor.starting" = "запуск tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "qr-код проверки";
|
|
||||||
"verification.my_qr.title" = "отсканируй, чтобы подтвердить меня";
|
|
||||||
"verification.my_qr.unavailable" = "qr недоступен";
|
|
||||||
"verification.scan.paste_prompt" = "вставь содержимое qr для проверки:";
|
|
||||||
"verification.scan.prompt_friend" = "отсканируй qr друга";
|
|
||||||
"verification.scan.status.invalid" = "qr недействителен или просрочен";
|
|
||||||
"verification.scan.status.no_peer" = "соответствующий пир не найден";
|
|
||||||
"verification.scan.status.requested" = "проверка запрошена для %@";
|
|
||||||
"verification.scan.validate" = "проверить";
|
|
||||||
"verification.sheet.title" = "ПРОВЕРИТЬ";
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d заметка</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d заметки</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d заметок</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d заметки</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d человек</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d человека</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d человек</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d человека</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d человек</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d человека</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d человек</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d человека</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Ukrainian)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "закрити";
|
|
||||||
"app_info.done" = "ГОТОВО";
|
|
||||||
"app_info.features.encryption.description" = "приватні повідомлення шифруються протоколом noise";
|
|
||||||
"app_info.features.encryption.title" = "скрізьове шифрування";
|
|
||||||
"app_info.features.extended_range.description" = "повідомлення ретранслюються між пірами й долітають далі";
|
|
||||||
"app_info.features.extended_range.title" = "розширена дальність";
|
|
||||||
"app_info.features.favorites.description" = "отримуй сповіщення, коли підключаються улюблені люди";
|
|
||||||
"app_info.features.favorites.title" = "вибране";
|
|
||||||
"app_info.features.geohash.description" = "канали geohash для спілкування з людьми поблизу через децентралізовані анонімні ретранслятори";
|
|
||||||
"app_info.features.geohash.title" = "локальні канали";
|
|
||||||
"app_info.features.mentions.description" = "використовуй @nickname, щоб сповістити конкретних людей";
|
|
||||||
"app_info.features.mentions.title" = "згадки";
|
|
||||||
"app_info.features.offline.description" = "працює без інтернету через bluetooth low energy";
|
|
||||||
"app_info.features.offline.title" = "офлайн-зв'язок";
|
|
||||||
"app_info.features.title" = "МОЖЛИВОСТІ";
|
|
||||||
"app_info.how_to_use.change_channels" = "• торкнися #mesh, щоб змінити канал";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• торкни чат тричі, щоб очистити";
|
|
||||||
"app_info.how_to_use.commands" = "• введи /, щоб побачити команди";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• торкни піктограму людей, щоб відкрити бічну панель";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• змінюй свій нік, торкаючись його";
|
|
||||||
"app_info.how_to_use.start_dm" = "• торкни ім'я піра, щоб почати приватний чат";
|
|
||||||
"app_info.how_to_use.title" = "ЯК КОРИСТУВАТИСЯ";
|
|
||||||
"app_info.privacy.ephemeral.description" = "новий id піра генерується регулярно";
|
|
||||||
"app_info.privacy.ephemeral.title" = "ефемерна ідентичність";
|
|
||||||
"app_info.privacy.no_tracking.description" = "жодних серверів, обліковок чи збору даних";
|
|
||||||
"app_info.privacy.no_tracking.title" = "без відстеження";
|
|
||||||
"app_info.privacy.panic.description" = "тричі торкни логотип, щоб миттєво стерти всі дані";
|
|
||||||
"app_info.privacy.panic.title" = "режим паніки";
|
|
||||||
"app_info.privacy.title" = "КОНФІДЕНЦІЙНІСТЬ";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "безпека приватних повідомлень ще не пройшла повний аудит. не використовуй для критичних ситуацій, поки це попередження не зникне.";
|
|
||||||
"app_info.warning.title" = "ПОПЕРЕДЖЕННЯ";
|
|
||||||
"common.cancel" = "скасувати";
|
|
||||||
"common.close" = "закрити";
|
|
||||||
"common.copy" = "скопіювати";
|
|
||||||
"common.ok" = "OK";
|
|
||||||
"common.toggle.off" = "вимк";
|
|
||||||
"common.toggle.on" = "увімк";
|
|
||||||
"common.unknown" = "невідомо";
|
|
||||||
"content.accessibility.add_favorite" = "додати до вибраного";
|
|
||||||
"content.accessibility.available_nostr" = "доступно через nostr";
|
|
||||||
"content.accessibility.back_to_main_chat" = "назад до основного чату";
|
|
||||||
"content.accessibility.connected_mesh" = "з'єднано через mesh";
|
|
||||||
"content.accessibility.encryption_status" = "стан шифрування: %@";
|
|
||||||
"content.accessibility.location_channels" = "канали локації";
|
|
||||||
"content.accessibility.location_notes" = "замітки про це місце";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "відкрити непрочитаний приватний чат";
|
|
||||||
"content.accessibility.private_chat_header" = "приватний чат з %@";
|
|
||||||
"content.accessibility.reachable_mesh" = "досяжно через mesh";
|
|
||||||
"content.accessibility.remove_favorite" = "видалити з вибраного";
|
|
||||||
"content.accessibility.send_hint_empty" = "введи повідомлення для надсилання";
|
|
||||||
"content.accessibility.send_hint_ready" = "торкни двічі, щоб надіслати";
|
|
||||||
"content.accessibility.send_message" = "надіслати повідомлення";
|
|
||||||
"content.accessibility.toggle_bookmark" = "перемкнути закладку для #%@";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "торкни двічі, щоб змінити статус вибраного";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "торкни, щоб переглянути криптографічний відбиток";
|
|
||||||
"content.actions.block" = "заблокувати";
|
|
||||||
"content.actions.direct_message" = "приватне повідомлення";
|
|
||||||
"content.actions.hug" = "обійняти";
|
|
||||||
"content.actions.mention" = "згадати";
|
|
||||||
"content.actions.slap" = "ляпас";
|
|
||||||
"content.actions.title" = "дії";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth вимкнений. увімкни bluetooth у налаштуваннях, щоб користуватися bitchat.";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.";
|
|
||||||
"content.alert.bluetooth_required.settings" = "налаштування";
|
|
||||||
"content.alert.bluetooth_required.title" = "потрібен bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "цей пристрій не підтримує bluetooth. bitchat потрібен bluetooth для роботи.";
|
|
||||||
"content.alert.screenshot.message" = "скріншоти каналів локації розкриють твоє місце. подумай, перш ніж ділитися публічно.";
|
|
||||||
"content.alert.screenshot.title" = "увага";
|
|
||||||
"content.commands.block" = "заблокувати або показати заблокованих";
|
|
||||||
"content.commands.clear" = "очистити чат";
|
|
||||||
"content.commands.favorite" = "додати до вибраного";
|
|
||||||
"content.commands.hug" = "відправити теплі обійми";
|
|
||||||
"content.commands.message" = "надіслати приватне повідомлення";
|
|
||||||
"content.commands.slap" = "лупнути когось фореллю";
|
|
||||||
"content.commands.unblock" = "розблокувати піра";
|
|
||||||
"content.commands.unfavorite" = "видалити з вибраного";
|
|
||||||
"content.commands.who" = "подивитися, хто онлайн";
|
|
||||||
"content.delivery.delivered_members" = "доставлено %1$d з %2$d учасників";
|
|
||||||
"content.delivery.delivered_to" = "доставлено %@";
|
|
||||||
"content.delivery.failed" = "не вдалося: %@";
|
|
||||||
"content.delivery.read_by" = "прочитано %@";
|
|
||||||
"content.delivery.reason.blocked" = "користувач заблокований";
|
|
||||||
"content.delivery.reason.self" = "не можна надіслати собі";
|
|
||||||
"content.delivery.reason.send_error" = "помилка надсилання";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "невідомий одержувач";
|
|
||||||
"content.delivery.reason.unreachable" = "пір недосяжний";
|
|
||||||
"content.header.people" = "ЛЮДИ";
|
|
||||||
"content.help.verification" = "верифікація: показати мій qr або сканувати друга";
|
|
||||||
"content.input.message_placeholder" = "напиши повідомлення...";
|
|
||||||
"content.input.nickname_placeholder" = "нік";
|
|
||||||
"content.location.enable" = "увімкнути локацію";
|
|
||||||
"content.message.copy" = "скопіювати повідомлення";
|
|
||||||
"content.message.show_less" = "показати менше";
|
|
||||||
"content.message.show_more" = "показати більше";
|
|
||||||
"content.notes.location_unavailable" = "локація недоступна";
|
|
||||||
"content.notes.title" = "замітки";
|
|
||||||
"content.payment.cashu" = "оплатити через cashu";
|
|
||||||
"content.payment.lightning" = "оплатити через lightning";
|
|
||||||
"encryption.accessibility.establishing" = "встановлюється шифрування";
|
|
||||||
"encryption.accessibility.failed" = "шифрування не вдалося";
|
|
||||||
"encryption.accessibility.not_encrypted" = "не зашифровано";
|
|
||||||
"encryption.accessibility.secured" = "зашифровано";
|
|
||||||
"encryption.accessibility.verified" = "зашифровано та перевірено";
|
|
||||||
"encryption.status.establishing" = "встановлюємо шифрування...";
|
|
||||||
"encryption.status.failed" = "шифрування не вдалося";
|
|
||||||
"encryption.status.not_encrypted" = "не зашифровано";
|
|
||||||
"encryption.status.secured" = "зашифровано";
|
|
||||||
"encryption.status.verified" = "зашифровано та перевірено";
|
|
||||||
"fingerprint.action.mark_verified" = "позначити як перевірено";
|
|
||||||
"fingerprint.action.remove_verification" = "зняти перевірку";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ НЕ ПЕРЕВІРЕНО";
|
|
||||||
"fingerprint.badge.verified" = "✓ ПЕРЕВІРЕНО";
|
|
||||||
"fingerprint.handshake_pending" = "недоступно — handshake триває";
|
|
||||||
"fingerprint.message.verified" = "ти підтвердив особу цієї людини.";
|
|
||||||
"fingerprint.message.verify_hint" = "порівняй ці відбитки з %@ у безпечному каналі.";
|
|
||||||
"fingerprint.their_label" = "їхній відбиток:";
|
|
||||||
"fingerprint.title" = "перевірка безпеки";
|
|
||||||
"fingerprint.your_label" = "твій відбиток:";
|
|
||||||
"geohash_people.action.block" = "заблокувати";
|
|
||||||
"geohash_people.action.unblock" = "розблокувати";
|
|
||||||
"geohash_people.none_nearby" = "поруч нікого...";
|
|
||||||
"geohash_people.tooltip.blocked" = "заблоковано в geohash";
|
|
||||||
"geohash_people.you_suffix" = " (ти)";
|
|
||||||
"location_channels.action.open_settings" = "відкрити налаштування";
|
|
||||||
"location_channels.action.remove_access" = "відключити доступ до локації";
|
|
||||||
"location_channels.action.request_permissions" = "отримати мою локацію та geohash";
|
|
||||||
"location_channels.action.teleport" = "телепорт";
|
|
||||||
"location_channels.bookmarked_section_title" = "закладені";
|
|
||||||
"location_channels.description" = "спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor.";
|
|
||||||
"location_channels.error.invalid_geohash" = "некоректний geohash";
|
|
||||||
"location_channels.loading_nearby" = "пошук каналів поруч…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "доступ до локації заборонено. увімкни дозвіл у налаштуваннях, щоб користуватися каналами.";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#канали локації";
|
|
||||||
"location_channels.tor.subtitle" = "приховує твій ip для каналів локації. рекомендовано ввімкнути.";
|
|
||||||
"location_channels.tor.title" = "маршрутизація tor";
|
|
||||||
"location_levels.block" = "квартал";
|
|
||||||
"location_levels.building" = "будівля";
|
|
||||||
"location_levels.city" = "місто";
|
|
||||||
"location_levels.neighborhood" = "район";
|
|
||||||
"location_levels.province" = "область";
|
|
||||||
"location_levels.region" = "регіон";
|
|
||||||
"location_notes.action.dismiss" = "закрити";
|
|
||||||
"location_notes.action.retry" = "повторити";
|
|
||||||
"location_notes.description" = "додай короткі постійні замітки про це місце для інших.";
|
|
||||||
"location_notes.empty_subtitle" = "стань першим, хто додасть тут замітку.";
|
|
||||||
"location_notes.empty_title" = "заміток ще немає";
|
|
||||||
"location_notes.error.failed_to_send" = "не вдалося надіслати замітку. %@";
|
|
||||||
"location_notes.error.no_relays" = "поруч немає гео-релеїв. спробуй пізніше.";
|
|
||||||
"location_notes.loading_notes" = "завантаження заміток…";
|
|
||||||
"location_notes.loading_recent" = "завантаження свіжих заміток…";
|
|
||||||
"location_notes.no_relays_nearby" = "немає гео-релеїв поблизу";
|
|
||||||
"location_notes.placeholder" = "додай замітку для цього місця";
|
|
||||||
"location_notes.relays_paused" = "гео-релеї недоступні; замітки призупинено";
|
|
||||||
"location_notes.relays_retry_hint" = "замітки залежать від гео-релеїв. перевір з'єднання й спробуй ще раз.";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "нові повідомлення";
|
|
||||||
"system.chat.blocked" = "не можна почати чат з %@: користувач заблокований.";
|
|
||||||
"system.chat.requires_favorite" = "не можна почати чат з %@: потрібне взаємне вибране для офлайна.";
|
|
||||||
"system.common.user" = "користувач";
|
|
||||||
"system.dm.blocked_generic" = "не вдалося надіслати: користувач заблокований.";
|
|
||||||
"system.dm.blocked_recipient" = "неможливо надіслати %@: користувач заблокований.";
|
|
||||||
"system.dm.unreachable" = "неможливо надіслати %@: одержувач недосяжний через mesh або nostr.";
|
|
||||||
"system.geohash.blocked" = "%@ заблоковано в geohash-чатах";
|
|
||||||
"system.geohash.unblocked" = "%@ розблоковано в geohash-чатах";
|
|
||||||
"system.location.not_in_channel" = "не вдалося надіслати: ти не в каналі локації";
|
|
||||||
"system.location.send_failed" = "не вдалося надіслати в канал локації";
|
|
||||||
"system.tor.dev_bypass" = "dev-збірка: обхід tor увімкнено.";
|
|
||||||
"system.tor.restarted" = "tor перезапущено. маршрутизацію відновлено.";
|
|
||||||
"system.tor.restarting" = "tor перезапускається, щоб відновити підключення...";
|
|
||||||
"system.tor.started" = "tor запущено. увесь чат іде через tor для приватності.";
|
|
||||||
"system.tor.starting" = "запуск tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "qr-код підтвердження";
|
|
||||||
"verification.my_qr.title" = "скануй, щоб підтвердити мене";
|
|
||||||
"verification.my_qr.unavailable" = "qr недоступний";
|
|
||||||
"verification.scan.paste_prompt" = "встав вміст qr для перевірки:";
|
|
||||||
"verification.scan.prompt_friend" = "скануй qr друга";
|
|
||||||
"verification.scan.status.invalid" = "qr недійсний або прострочений";
|
|
||||||
"verification.scan.status.no_peer" = "відповідний пір не знайдений";
|
|
||||||
"verification.scan.status.requested" = "перевірка запитана для %@";
|
|
||||||
"verification.scan.validate" = "перевірити";
|
|
||||||
"verification.sheet.title" = "ПЕРЕВІРИТИ";
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d замітка</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d замітки</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d заміток</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d замітки</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d людина</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d людини</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d людей</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d людини</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d людина</string>
|
|
||||||
<key>few</key>
|
|
||||||
<string>%d людини</string>
|
|
||||||
<key>many</key>
|
|
||||||
<string>%d людей</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d людини</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
bitchat (Simplified Chinese)
|
|
||||||
*/
|
|
||||||
|
|
||||||
"app_info.app_name" = "bitchat";
|
|
||||||
"app_info.close" = "关闭";
|
|
||||||
"app_info.done" = "完成";
|
|
||||||
"app_info.features.encryption.description" = "私密消息使用 noise 协议加密";
|
|
||||||
"app_info.features.encryption.title" = "端到端加密";
|
|
||||||
"app_info.features.extended_range.description" = "消息通过同伴中继,传得更远";
|
|
||||||
"app_info.features.extended_range.title" = "扩展范围";
|
|
||||||
"app_info.features.favorites.description" = "你喜欢的人加入时立刻提醒";
|
|
||||||
"app_info.features.favorites.title" = "收藏";
|
|
||||||
"app_info.features.geohash.description" = "geohash 频道让你通过去中心化匿名中继与附近地区的人聊天";
|
|
||||||
"app_info.features.geohash.title" = "本地频道";
|
|
||||||
"app_info.features.mentions.description" = "使用 @nickname 提醒特定的人";
|
|
||||||
"app_info.features.mentions.title" = "提及";
|
|
||||||
"app_info.features.offline.description" = "利用低功耗 bluetooth 离线工作";
|
|
||||||
"app_info.features.offline.title" = "离线通信";
|
|
||||||
"app_info.features.title" = "功能";
|
|
||||||
"app_info.how_to_use.change_channels" = "• 轻点 #mesh 切换频道";
|
|
||||||
"app_info.how_to_use.clear_chat" = "• 三击聊天即可清除";
|
|
||||||
"app_info.how_to_use.commands" = "• 输入 / 查看指令";
|
|
||||||
"app_info.how_to_use.open_sidebar" = "• 轻点人物图标打开侧栏";
|
|
||||||
"app_info.how_to_use.set_nickname" = "• 轻点昵称即可设置";
|
|
||||||
"app_info.how_to_use.start_dm" = "• 轻点同伴名字开始 dm";
|
|
||||||
"app_info.how_to_use.title" = "使用方法";
|
|
||||||
"app_info.privacy.ephemeral.description" = "定期生成新的 peer id";
|
|
||||||
"app_info.privacy.ephemeral.title" = "临时身份";
|
|
||||||
"app_info.privacy.no_tracking.description" = "无服务器、无账号、无数据收集";
|
|
||||||
"app_info.privacy.no_tracking.title" = "无跟踪";
|
|
||||||
"app_info.privacy.panic.description" = "三击标志立即清除全部数据";
|
|
||||||
"app_info.privacy.panic.title" = "紧急模式";
|
|
||||||
"app_info.privacy.title" = "隐私";
|
|
||||||
"app_info.tagline" = "sidegroupchat";
|
|
||||||
"app_info.warning.message" = "私信安全尚未完全审计。在此警告消失前不要用于关键情境。";
|
|
||||||
"app_info.warning.title" = "警告";
|
|
||||||
"common.cancel" = "取消";
|
|
||||||
"common.close" = "关闭";
|
|
||||||
"common.copy" = "复制";
|
|
||||||
"common.ok" = "确定";
|
|
||||||
"common.toggle.off" = "关闭";
|
|
||||||
"common.toggle.on" = "开启";
|
|
||||||
"common.unknown" = "未知";
|
|
||||||
"content.accessibility.add_favorite" = "加入收藏";
|
|
||||||
"content.accessibility.available_nostr" = "通过 Nostr 可用";
|
|
||||||
"content.accessibility.back_to_main_chat" = "返回主聊天";
|
|
||||||
"content.accessibility.connected_mesh" = "通过 mesh 已连接";
|
|
||||||
"content.accessibility.encryption_status" = "加密状态:%@";
|
|
||||||
"content.accessibility.location_channels" = "位置频道";
|
|
||||||
"content.accessibility.location_notes" = "此位置的笔记";
|
|
||||||
"content.accessibility.open_unread_private_chat" = "打开未读私聊";
|
|
||||||
"content.accessibility.private_chat_header" = "与 %@ 的私聊";
|
|
||||||
"content.accessibility.reachable_mesh" = "可通过 mesh 到达";
|
|
||||||
"content.accessibility.remove_favorite" = "移出收藏";
|
|
||||||
"content.accessibility.send_hint_empty" = "输入要发送的消息";
|
|
||||||
"content.accessibility.send_hint_ready" = "双击发送";
|
|
||||||
"content.accessibility.send_message" = "发送消息";
|
|
||||||
"content.accessibility.toggle_bookmark" = "切换 #%@ 的书签";
|
|
||||||
"content.accessibility.toggle_favorite_hint" = "双击切换收藏状态";
|
|
||||||
"content.accessibility.view_fingerprint_hint" = "轻点查看加密指纹";
|
|
||||||
"content.actions.block" = "屏蔽";
|
|
||||||
"content.actions.direct_message" = "私信";
|
|
||||||
"content.actions.hug" = "拥抱";
|
|
||||||
"content.actions.mention" = "提及";
|
|
||||||
"content.actions.slap" = "拍打";
|
|
||||||
"content.actions.title" = "操作";
|
|
||||||
"content.alert.bluetooth_required.off" = "bluetooth 已关闭。请在设置中开启以使用 bitchat。";
|
|
||||||
"content.alert.bluetooth_required.permission" = "bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。";
|
|
||||||
"content.alert.bluetooth_required.settings" = "设置";
|
|
||||||
"content.alert.bluetooth_required.title" = "需要 bluetooth";
|
|
||||||
"content.alert.bluetooth_required.unsupported" = "此设备不支持 bluetooth。bitchat 需要 bluetooth 才能运行。";
|
|
||||||
"content.alert.screenshot.message" = "位置频道的截图会暴露你的位置。公开分享前请三思。";
|
|
||||||
"content.alert.screenshot.title" = "注意";
|
|
||||||
"content.commands.block" = "屏蔽或查看已屏蔽的同伴";
|
|
||||||
"content.commands.clear" = "清除聊天消息";
|
|
||||||
"content.commands.favorite" = "加入收藏";
|
|
||||||
"content.commands.hug" = "送出温暖拥抱";
|
|
||||||
"content.commands.message" = "发送私信";
|
|
||||||
"content.commands.slap" = "用鳟鱼拍某人";
|
|
||||||
"content.commands.unblock" = "取消屏蔽同伴";
|
|
||||||
"content.commands.unfavorite" = "移出收藏";
|
|
||||||
"content.commands.who" = "查看谁在线";
|
|
||||||
"content.delivery.delivered_members" = "已送达 %2$d 人中的 %1$d 人";
|
|
||||||
"content.delivery.delivered_to" = "已送达 %@";
|
|
||||||
"content.delivery.failed" = "失败:%@";
|
|
||||||
"content.delivery.read_by" = "已读:%@";
|
|
||||||
"content.delivery.reason.blocked" = "用户已被屏蔽";
|
|
||||||
"content.delivery.reason.self" = "不能给自己发消息";
|
|
||||||
"content.delivery.reason.send_error" = "发送错误";
|
|
||||||
"content.delivery.reason.unknown_recipient" = "未知收件人";
|
|
||||||
"content.delivery.reason.unreachable" = "同伴不可达";
|
|
||||||
"content.header.people" = "成员";
|
|
||||||
"content.help.verification" = "验证:展示我的 qr 或扫描好友";
|
|
||||||
"content.input.message_placeholder" = "输入消息...";
|
|
||||||
"content.input.nickname_placeholder" = "昵称";
|
|
||||||
"content.location.enable" = "启用位置";
|
|
||||||
"content.message.copy" = "复制消息";
|
|
||||||
"content.message.show_less" = "收起";
|
|
||||||
"content.message.show_more" = "展开";
|
|
||||||
"content.notes.location_unavailable" = "位置不可用";
|
|
||||||
"content.notes.title" = "笔记";
|
|
||||||
"content.payment.cashu" = "通过 cashu 支付";
|
|
||||||
"content.payment.lightning" = "通过 lightning 支付";
|
|
||||||
"encryption.accessibility.establishing" = "正在建立加密";
|
|
||||||
"encryption.accessibility.failed" = "加密失败";
|
|
||||||
"encryption.accessibility.not_encrypted" = "未加密";
|
|
||||||
"encryption.accessibility.secured" = "已加密";
|
|
||||||
"encryption.accessibility.verified" = "已加密并验证";
|
|
||||||
"encryption.status.establishing" = "正在建立加密...";
|
|
||||||
"encryption.status.failed" = "加密失败";
|
|
||||||
"encryption.status.not_encrypted" = "未加密";
|
|
||||||
"encryption.status.secured" = "已加密";
|
|
||||||
"encryption.status.verified" = "已加密并验证";
|
|
||||||
"fingerprint.action.mark_verified" = "标记为已验证";
|
|
||||||
"fingerprint.action.remove_verification" = "移除验证";
|
|
||||||
"fingerprint.badge.not_verified" = "⚠️ 未验证";
|
|
||||||
"fingerprint.badge.verified" = "✓ 已验证";
|
|
||||||
"fingerprint.handshake_pending" = "暂不可用 - handshake 进行中";
|
|
||||||
"fingerprint.message.verified" = "你已经核实了此人的身份。";
|
|
||||||
"fingerprint.message.verify_hint" = "通过安全渠道与 %@ 比对这些指纹。";
|
|
||||||
"fingerprint.their_label" = "对方指纹:";
|
|
||||||
"fingerprint.title" = "安全验证";
|
|
||||||
"fingerprint.your_label" = "你的指纹:";
|
|
||||||
"geohash_people.action.block" = "屏蔽";
|
|
||||||
"geohash_people.action.unblock" = "取消屏蔽";
|
|
||||||
"geohash_people.none_nearby" = "附近没人...";
|
|
||||||
"geohash_people.tooltip.blocked" = "在 geohash 中已屏蔽";
|
|
||||||
"geohash_people.you_suffix" = " (你)";
|
|
||||||
"location_channels.action.open_settings" = "打开设置";
|
|
||||||
"location_channels.action.remove_access" = "移除位置访问";
|
|
||||||
"location_channels.action.request_permissions" = "获取位置和我的 geohash";
|
|
||||||
"location_channels.action.teleport" = "瞬移";
|
|
||||||
"location_channels.bookmarked_section_title" = "已收藏";
|
|
||||||
"location_channels.description" = "使用 geohash 频道与附近的人聊天。只会共享粗略 geohash,从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。";
|
|
||||||
"location_channels.error.invalid_geohash" = "无效的 geohash";
|
|
||||||
"location_channels.loading_nearby" = "正在寻找附近频道…";
|
|
||||||
"location_channels.mesh_label" = "mesh";
|
|
||||||
"location_channels.permission_denied" = "位置权限被拒。请在设置中启用以使用位置频道。";
|
|
||||||
"location_channels.subtitle_prefix" = "#%@ • %@";
|
|
||||||
"location_channels.subtitle_with_name" = "%1$@ • %2$@";
|
|
||||||
"location_channels.title" = "#位置频道";
|
|
||||||
"location_channels.tor.subtitle" = "为位置频道隐藏你的 IP。推荐:开启。";
|
|
||||||
"location_channels.tor.title" = "tor 路由";
|
|
||||||
"location_levels.block" = "街区";
|
|
||||||
"location_levels.building" = "楼栋";
|
|
||||||
"location_levels.city" = "城市";
|
|
||||||
"location_levels.neighborhood" = "社区";
|
|
||||||
"location_levels.province" = "省份";
|
|
||||||
"location_levels.region" = "区域";
|
|
||||||
"location_notes.action.dismiss" = "关闭";
|
|
||||||
"location_notes.action.retry" = "重试";
|
|
||||||
"location_notes.description" = "为此地点添加简短的常驻笔记,方便其他访客发现。";
|
|
||||||
"location_notes.empty_subtitle" = "成为这里的第一条笔记。";
|
|
||||||
"location_notes.empty_title" = "尚无笔记";
|
|
||||||
"location_notes.error.failed_to_send" = "无法发送笔记。%@";
|
|
||||||
"location_notes.error.no_relays" = "附近没有可用的地理中继。稍后再试。";
|
|
||||||
"location_notes.loading_notes" = "正在加载笔记…";
|
|
||||||
"location_notes.loading_recent" = "正在加载最新笔记…";
|
|
||||||
"location_notes.no_relays_nearby" = "附近没有地理中继";
|
|
||||||
"location_notes.placeholder" = "为此地点添加笔记";
|
|
||||||
"location_notes.relays_paused" = "地理中继不可用;笔记已暂停";
|
|
||||||
"location_notes.relays_retry_hint" = "笔记依赖地理中继。检查连接后再试。";
|
|
||||||
"mesh_peers.tooltip.new_messages" = "新消息";
|
|
||||||
"system.chat.blocked" = "无法与 %@ 开始聊天:用户已被屏蔽。";
|
|
||||||
"system.chat.requires_favorite" = "无法与 %@ 开始聊天:离线消息需要互相关注。";
|
|
||||||
"system.common.user" = "用户";
|
|
||||||
"system.dm.blocked_generic" = "无法发送:用户已被屏蔽。";
|
|
||||||
"system.dm.blocked_recipient" = "无法向 %@ 发送:用户已被屏蔽。";
|
|
||||||
"system.dm.unreachable" = "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。";
|
|
||||||
"system.geohash.blocked" = "已在 geohash 聊天中屏蔽 %@";
|
|
||||||
"system.geohash.unblocked" = "已在 geohash 聊天中解除屏蔽 %@";
|
|
||||||
"system.location.not_in_channel" = "发送失败:你不在位置频道中";
|
|
||||||
"system.location.send_failed" = "无法发送到位置频道";
|
|
||||||
"system.tor.dev_bypass" = "开发构建:tor 绕过已启用。";
|
|
||||||
"system.tor.restarted" = "tor 已重启。网络路由已恢复。";
|
|
||||||
"system.tor.restarting" = "tor 正在重启以恢复连接...";
|
|
||||||
"system.tor.started" = "tor 已启动。所有聊天通过 tor 路由以保护 IP。";
|
|
||||||
"system.tor.starting" = "正在启动 tor...";
|
|
||||||
"verification.my_qr.accessibility_label" = "验证 QR 码";
|
|
||||||
"verification.my_qr.title" = "扫描验证我";
|
|
||||||
"verification.my_qr.unavailable" = "QR 不可用";
|
|
||||||
"verification.scan.paste_prompt" = "粘贴 QR 内容以验证:";
|
|
||||||
"verification.scan.prompt_friend" = "扫描好友的 QR";
|
|
||||||
"verification.scan.status.invalid" = "QR 无效或已过期";
|
|
||||||
"verification.scan.status.no_peer" = "未找到匹配的同伴";
|
|
||||||
"verification.scan.status.requested" = "已请求 %@ 的验证";
|
|
||||||
"verification.scan.validate" = "验证";
|
|
||||||
"verification.sheet.title" = "验证";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<key>location_notes.header</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>#%@ • %#@note_count@</string>
|
|
||||||
<key>note_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d 条笔记</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d 条笔记</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>location_channels.row_title</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%@ [%#@people_count@]</string>
|
|
||||||
<key>people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d 人</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d 人</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>content.accessibility.people_count</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
|
||||||
<string>%#@people@</string>
|
|
||||||
<key>people</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
|
||||||
<string>NSStringPluralRuleType</string>
|
|
||||||
<key>NSStringFormatValueTypeKey</key>
|
|
||||||
<string>d</string>
|
|
||||||
<key>one</key>
|
|
||||||
<string>%d 人</string>
|
|
||||||
<key>other</key>
|
|
||||||
<string>%d 人</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
|||||||
let originalSender: String?
|
let originalSender: String?
|
||||||
let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeerID: String?
|
let senderPeerID: PeerID?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
|
|||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
init(
|
||||||
|
id: String? = nil,
|
||||||
|
sender: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isRelay: Bool,
|
||||||
|
originalSender: String? = nil,
|
||||||
|
isPrivate: Bool = false,
|
||||||
|
recipientNickname: String? = nil,
|
||||||
|
senderPeerID: PeerID? = nil,
|
||||||
|
mentions: [String]? = nil,
|
||||||
|
deliveryStatus: DeliveryStatus? = nil
|
||||||
|
) {
|
||||||
self.id = id ?? UUID().uuidString
|
self.id = id ?? UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -151,7 +163,7 @@ extension BitchatMessage {
|
|||||||
data.append(recipData.prefix(255))
|
data.append(recipData.prefix(255))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||||
data.append(UInt8(min(peerData.count, 255)))
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
data.append(peerData.prefix(255))
|
data.append(peerData.prefix(255))
|
||||||
}
|
}
|
||||||
@@ -264,11 +276,11 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var senderPeerID: String?
|
var senderPeerID: PeerID?
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
if offset + length <= dataCopy.count {
|
if offset + length <= dataCopy.count {
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||||
offset += length
|
offset += length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
|
|||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||||
self.version = 1
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
self.recipientID = recipientID
|
self.recipientID = recipientID
|
||||||
@@ -34,12 +34,12 @@ struct BitchatPacket: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
var senderData = Data()
|
var senderData = Data()
|
||||||
var tempID = senderID
|
var tempID = senderID.id
|
||||||
while tempID.count >= 2 {
|
while tempID.count >= 2 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -80,7 +80,8 @@ struct BitchatPacket: Codable {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
version: version
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Foundation
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Identifiable, Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
let id: String // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIcon: String {
|
var statusIcon: String {
|
||||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Initialize from mesh service data
|
// Initialize from mesh service data
|
||||||
init(
|
init(
|
||||||
id: String,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||||
lhs.id == rhs.id
|
lhs.peerID == rhs.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//
|
||||||
|
// PeerID.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PeerID: Equatable, Hashable {
|
||||||
|
enum Prefix: String, CaseIterable {
|
||||||
|
/// When no prefix is provided
|
||||||
|
case empty = ""
|
||||||
|
/// `"mesh:"`
|
||||||
|
case mesh = "mesh:"
|
||||||
|
/// `"name:"`
|
||||||
|
case name = "name:"
|
||||||
|
/// `"noise:"` (+ 64 characters hex)
|
||||||
|
case noise = "noise:"
|
||||||
|
/// `"nostr_"` (+ 16 characters hex)
|
||||||
|
case geoDM = "nostr_"
|
||||||
|
/// `"nostr:"` (+ 8 characters hex)
|
||||||
|
case geoChat = "nostr:"
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix: Prefix
|
||||||
|
|
||||||
|
/// Returns the actual value without any prefix
|
||||||
|
let bare: String
|
||||||
|
|
||||||
|
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||||
|
var id: String { prefix.rawValue + bare }
|
||||||
|
|
||||||
|
// Private so the callers have to go through a convenience init
|
||||||
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
|
self.prefix = prefix
|
||||||
|
self.bare = String(bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||||
|
init(nostr_ pubKey: String) {
|
||||||
|
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||||
|
init(nostr pubKey: String) {
|
||||||
|
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||||
|
init(str: any StringProtocol) {
|
||||||
|
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||||
|
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||||
|
} else {
|
||||||
|
self.init(prefix: .empty, bare: str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to handle `Optional<String>`
|
||||||
|
init?(str: (any StringProtocol)?) {
|
||||||
|
guard let str else { return nil }
|
||||||
|
self.init(str: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID by converting Data to String
|
||||||
|
init?(data: Data) {
|
||||||
|
self.init(str: String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
|
init(hexData: Data) {
|
||||||
|
self.init(str: hexData.hexEncodedString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
|
init(publicKey: Data) {
|
||||||
|
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||||
|
func toShort() -> PeerID {
|
||||||
|
if let noiseKey {
|
||||||
|
return PeerID(publicKey: noiseKey)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
extension PeerID: Codable {
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
var isEmpty: Bool {
|
||||||
|
id.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr:`"
|
||||||
|
var isGeoChat: Bool {
|
||||||
|
prefix == .geoChat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr_`"
|
||||||
|
var isGeoDM: Bool {
|
||||||
|
prefix == .geoDM
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPercentEncoded() -> String {
|
||||||
|
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
|
var isValid: Bool {
|
||||||
|
if prefix != .empty {
|
||||||
|
return PeerID(str: bare).isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||||
|
if isShort || isNoiseKeyHex {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If length equals short or full but isn't valid hex, reject
|
||||||
|
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||||
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
|
return !id.isEmpty &&
|
||||||
|
id.count < Constants.maxIDLength &&
|
||||||
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the `bare` id is all hex
|
||||||
|
var isHex: Bool {
|
||||||
|
bare.allSatisfy { $0.isHexDigit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short routing IDs (exact 16-hex)
|
||||||
|
var isShort: Bool {
|
||||||
|
bare.count == Constants.hexIDLength && isHex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
var isNoiseKeyHex: Bool {
|
||||||
|
noiseKey != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key (exact 64-hex) as Data
|
||||||
|
var noiseKey: Data? {
|
||||||
|
guard bare.count == Constants.maxIDLength else { return nil }
|
||||||
|
return Data(hexString: bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comparable
|
||||||
|
|
||||||
|
extension PeerID: Comparable {
|
||||||
|
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||||
|
lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
|
extension PeerID: CustomStringConvertible {
|
||||||
|
/// So it returns the actual `id` like before even inside another String
|
||||||
|
var description: String {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
|
|||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
let readerID = readerIDData.hexEncodedString()
|
||||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
guard PeerID(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
|||||||
if nameData.count <= 32 {
|
if nameData.count <= 32 {
|
||||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||||
} else {
|
} else {
|
||||||
self.hash = Data(SHA256.hash(data: nameData))
|
self.hash = nameData.sha256Hash()
|
||||||
}
|
}
|
||||||
self.chainingKey = self.hash
|
self.chainingKey = self.hash
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mixHash(_ data: Data) {
|
func mixHash(_ data: Data) {
|
||||||
hash = Data(SHA256.hash(data: hash + data))
|
hash = (hash + data).sha256Hash()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||||
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
|
|||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
|
|
||||||
default:
|
case .e, .s:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// NoiseRateLimiter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseRateLimiter {
|
||||||
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
|
// Global rate limiting
|
||||||
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new handshake
|
||||||
|
timestamps.append(now)
|
||||||
|
handshakeTimestamps[peerID] = timestamps
|
||||||
|
globalHandshakeTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||||
|
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = messageTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new message
|
||||||
|
timestamps.append(now)
|
||||||
|
messageTimestamps[peerID] = timestamps
|
||||||
|
globalMessageTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(for peerID: PeerID) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAll() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeAll()
|
||||||
|
self.messageTimestamps.removeAll()
|
||||||
|
self.globalHandshakeTimestamps.removeAll()
|
||||||
|
self.globalMessageTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
//
|
|
||||||
// NoiseSecurityConsiderations.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
|
||||||
// Maximum message size to prevent memory exhaustion
|
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
|
||||||
|
|
||||||
// Maximum handshake message size
|
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
|
||||||
|
|
||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
static let maxHandshakesPerMinute = 10
|
|
||||||
static let maxMessagesPerSecond = 100
|
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
|
||||||
static let maxGlobalHandshakesPerMinute = 30
|
|
||||||
static let maxGlobalMessagesPerSecond = 500
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Validations
|
|
||||||
|
|
||||||
struct NoiseSecurityValidator {
|
|
||||||
|
|
||||||
/// Validate message size
|
|
||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate handshake message size
|
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate peer ID format using unified validator
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
return InputValidator.validatePeerID(peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
|
||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
|
||||||
private(set) var messageCount: UInt64 = 0
|
|
||||||
private let sessionStartTime = Date()
|
|
||||||
private(set) var lastActivityTime = Date()
|
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check message count
|
|
||||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
|
||||||
throw NoiseSecurityError.sessionExhausted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let encrypted = try super.encrypt(plaintext)
|
|
||||||
messageCount += 1
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let decrypted = try super.decrypt(ciphertext)
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsRenegotiation() -> Bool {
|
|
||||||
// Check if we've used more than 90% of message limit
|
|
||||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
|
||||||
if messageCount >= messageThreshold {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if last activity was more than 30 minutes ago
|
|
||||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Testing Support
|
|
||||||
#if DEBUG
|
|
||||||
func setLastActivityTimeForTesting(_ date: Date) {
|
|
||||||
lastActivityTime = date
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMessageCountForTesting(_ count: UInt64) {
|
|
||||||
messageCount = count
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Rate Limiter
|
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
|
||||||
|
|
||||||
// Global rate limiting
|
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
|
||||||
private var globalMessageTimestamps: [Date] = []
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
|
||||||
|
|
||||||
func allowHandshake(from peerID: String) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new handshake
|
|
||||||
timestamps.append(now)
|
|
||||||
handshakeTimestamps[peerID] = timestamps
|
|
||||||
globalHandshakeTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allowMessage(from peerID: String) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
|
||||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = messageTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new message
|
|
||||||
timestamps.append(now)
|
|
||||||
messageTimestamps[peerID] = timestamps
|
|
||||||
globalMessageTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(for peerID: String) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetAll() {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeAll()
|
|
||||||
self.messageTimestamps.removeAll()
|
|
||||||
self.globalHandshakeTimestamps.removeAll()
|
|
||||||
self.globalMessageTimestamps.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Errors
|
|
||||||
|
|
||||||
enum NoiseSecurityError: Error {
|
|
||||||
case sessionExpired
|
|
||||||
case sessionExhausted
|
|
||||||
case messageTooLarge
|
|
||||||
case invalidPeerID
|
|
||||||
case rateLimitExceeded
|
|
||||||
case handshakeTimeout
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityConstants.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityConstants {
|
||||||
|
// Maximum message size to prevent memory exhaustion
|
||||||
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
|
// Maximum handshake message size
|
||||||
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
|
// Handshake timeout - abandon incomplete handshakes
|
||||||
|
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||||
|
|
||||||
|
// Maximum concurrent sessions per peer
|
||||||
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
static let maxHandshakesPerMinute = 10
|
||||||
|
static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
|
// Global rate limiting (across all peers)
|
||||||
|
static let maxGlobalHandshakesPerMinute = 30
|
||||||
|
static let maxGlobalMessagesPerSecond = 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityError: Error {
|
||||||
|
case sessionExpired
|
||||||
|
case sessionExhausted
|
||||||
|
case messageTooLarge
|
||||||
|
case invalidPeerID
|
||||||
|
case rateLimitExceeded
|
||||||
|
case handshakeTimeout
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityValidator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NoiseSecurityValidator {
|
||||||
|
|
||||||
|
/// Validate message size
|
||||||
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate handshake message size
|
||||||
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,32 +10,8 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Noise Session State
|
|
||||||
|
|
||||||
enum NoiseSessionState: Equatable {
|
|
||||||
case uninitialized
|
|
||||||
case handshaking
|
|
||||||
case established
|
|
||||||
case failed(Error)
|
|
||||||
|
|
||||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.uninitialized, .uninitialized),
|
|
||||||
(.handshaking, .handshaking),
|
|
||||||
(.established, .established):
|
|
||||||
return true
|
|
||||||
case (.failed, .failed):
|
|
||||||
return true // We don't compare the errors
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Noise Session
|
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private var state: NoiseSessionState = .uninitialized
|
private var state: NoiseSessionState = .uninitialized
|
||||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
|||||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||||
|
|
||||||
init(
|
init(
|
||||||
peerID: String,
|
peerID: PeerID,
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
|||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
|||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -220,12 +196,6 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeHash() -> Data? {
|
|
||||||
return sessionQueue.sync {
|
|
||||||
return handshakeHash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
sessionQueue.sync(flags: .barrier) {
|
sessionQueue.sync(flags: .barrier) {
|
||||||
let wasEstablished = state == .established
|
let wasEstablished = state == .established
|
||||||
@@ -252,249 +222,8 @@ class NoiseSession {
|
|||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
if wasEstablished {
|
if wasEstablished {
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Manager
|
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
|
||||||
private var sessions: [String: NoiseSession] = [:]
|
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
|
||||||
var onSessionFailed: ((String, Error) -> Void)?
|
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
|
||||||
|
|
||||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
|
||||||
return managerQueue.sync(flags: .barrier) {
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSession(for peerID: String) -> NoiseSession? {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions[peerID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeSession(for peerID: String) {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
if let session = sessions[peerID] {
|
|
||||||
if session.isEstablished() {
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
// Clear sensitive data before removing
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAllSessions() {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
for (_, session) in sessions {
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
sessions.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions.filter { $0.value.isEstablished() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Handshake Helpers
|
|
||||||
|
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
|
||||||
// Check if we already have an established session
|
|
||||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
|
||||||
// Session already established, don't recreate
|
|
||||||
throw NoiseSessionError.alreadyEstablished
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any existing non-established session
|
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new initiator session
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
|
|
||||||
do {
|
|
||||||
let handshakeData = try session.startHandshake()
|
|
||||||
return handshakeData
|
|
||||||
} catch {
|
|
||||||
// Clean up failed session
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
|
||||||
var shouldCreateNew = false
|
|
||||||
var existingSession: NoiseSession? = nil
|
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
|
||||||
// If we have an established session, the peer must have cleared their session
|
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
|
||||||
// We should accept the new handshake to re-establish encryption
|
|
||||||
if existing.isEstablished() {
|
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
|
||||||
// reset and start fresh (the other side may have restarted)
|
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
existingSession = existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: .responder,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = newSession
|
|
||||||
session = newSession
|
|
||||||
} else {
|
|
||||||
session = existingSession!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
|
||||||
do {
|
|
||||||
let response = try session.processHandshakeMessage(message)
|
|
||||||
|
|
||||||
// Check if session is established after processing
|
|
||||||
if session.isEstablished() {
|
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch {
|
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionFailed?(peerID, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
|
||||||
guard let session = getSession(for: peerID) else {
|
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return try session.encrypt(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
|
||||||
guard let session = getSession(for: peerID) else {
|
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return try session.decrypt(ciphertext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Management
|
|
||||||
|
|
||||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
|
||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHandshakeHash(for peerID: String) -> Data? {
|
|
||||||
return getSession(for: peerID)?.getHandshakeHash()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
|
||||||
|
|
||||||
for (peerID, session) in sessions {
|
|
||||||
if let secureSession = session as? SecureNoiseSession,
|
|
||||||
secureSession.isEstablished(),
|
|
||||||
secureSession.needsRenegotiation() {
|
|
||||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return needingRekey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initiateRekey(for peerID: String) throws {
|
|
||||||
// Remove old session
|
|
||||||
removeSession(for: peerID)
|
|
||||||
|
|
||||||
// Initiate new handshake
|
|
||||||
_ = try initiateHandshake(with: peerID)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
enum NoiseSessionError: Error {
|
|
||||||
case invalidState
|
|
||||||
case notEstablished
|
|
||||||
case sessionNotFound
|
|
||||||
case handshakeFailed(Error)
|
|
||||||
case alreadyEstablished
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionError: Error, Equatable {
|
||||||
|
case invalidState
|
||||||
|
case notEstablished
|
||||||
|
case sessionNotFound
|
||||||
|
case alreadyEstablished
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionManager.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseSessionManager {
|
||||||
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSession(for peerID: PeerID) {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
|
session.reset() // Clear sensitive data before removing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllSessions() {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
for (_, session) in sessions {
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
sessions.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
// Check if we already have an established session
|
||||||
|
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||||
|
// Session already established, don't recreate
|
||||||
|
throw NoiseSessionError.alreadyEstablished
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing non-established session
|
||||||
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new initiator session
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
|
||||||
|
do {
|
||||||
|
let handshakeData = try session.startHandshake()
|
||||||
|
return handshakeData
|
||||||
|
} catch {
|
||||||
|
// Clean up failed session
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
var shouldCreateNew = false
|
||||||
|
var existingSession: NoiseSession? = nil
|
||||||
|
|
||||||
|
if let existing = sessions[peerID] {
|
||||||
|
// If we have an established session, the peer must have cleared their session
|
||||||
|
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||||
|
// We should accept the new handshake to re-establish encryption
|
||||||
|
if existing.isEstablished() {
|
||||||
|
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
// If we're in the middle of a handshake and receive a new initiation,
|
||||||
|
// reset and start fresh (the other side may have restarted)
|
||||||
|
if existing.getState() == .handshaking && message.count == 32 {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
existingSession = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldCreateNew = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create session
|
||||||
|
let session: NoiseSession
|
||||||
|
if shouldCreateNew {
|
||||||
|
let newSession = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = newSession
|
||||||
|
session = newSession
|
||||||
|
} else {
|
||||||
|
session = existingSession!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the handshake message within the synchronized block
|
||||||
|
do {
|
||||||
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
|
// Check if session is established after processing
|
||||||
|
if session.isEstablished() {
|
||||||
|
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch {
|
||||||
|
// Reset the session on handshake failure so next attempt can start fresh
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionFailed?(peerID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Management
|
||||||
|
|
||||||
|
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
|
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||||
|
|
||||||
|
for (peerID, session) in sessions {
|
||||||
|
if let secureSession = session as? SecureNoiseSession,
|
||||||
|
secureSession.isEstablished(),
|
||||||
|
secureSession.needsRenegotiation() {
|
||||||
|
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needingRekey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initiateRekey(for peerID: PeerID) throws {
|
||||||
|
// Remove old session
|
||||||
|
removeSession(for: peerID)
|
||||||
|
|
||||||
|
// Initiate new handshake
|
||||||
|
_ = try initiateHandshake(with: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionState.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionState: Equatable {
|
||||||
|
case uninitialized
|
||||||
|
case handshaking
|
||||||
|
case established
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// SecureNoiseSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class SecureNoiseSession: NoiseSession {
|
||||||
|
private(set) var messageCount: UInt64 = 0
|
||||||
|
private let sessionStartTime = Date()
|
||||||
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message count
|
||||||
|
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||||
|
throw NoiseSecurityError.sessionExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = try super.encrypt(plaintext)
|
||||||
|
messageCount += 1
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try super.decrypt(ciphertext)
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRenegotiation() -> Bool {
|
||||||
|
// Check if we've used more than 90% of message limit
|
||||||
|
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||||
|
if messageCount >= messageThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if last activity was more than 30 minutes ago
|
||||||
|
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Testing Support
|
||||||
|
#if DEBUG
|
||||||
|
func setLastActivityTimeForTesting(_ date: Date) {
|
||||||
|
lastActivityTime = date
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
|
messageCount = count
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bech32 encoding for Nostr (minimal implementation)
|
||||||
|
enum Bech32 {
|
||||||
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
static func encode(hrp: String, data: Data) throws -> String {
|
||||||
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||||
|
let checksum = createChecksum(hrp: hrp, values: values)
|
||||||
|
let combined = values + checksum
|
||||||
|
|
||||||
|
return hrp + "1" + combined.map {
|
||||||
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
|
return String(charset[index])
|
||||||
|
}.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||||
|
// Find the last occurrence of '1'
|
||||||
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||||
|
throw Bech32Error.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
|
// Validate HRP contains only ASCII characters
|
||||||
|
for char in hrp {
|
||||||
|
guard char.asciiValue != nil else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
|
// Convert characters to values
|
||||||
|
var values = [UInt8]()
|
||||||
|
for char in dataString {
|
||||||
|
guard let index = charset.firstIndex(of: char) else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
guard values.count >= 6 else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadValues = Array(values.dropLast(6))
|
||||||
|
let checksum = Array(values.suffix(6))
|
||||||
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
|
guard checksum == expectedChecksum else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to bytes
|
||||||
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
|
return (hrp: hrp, data: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bech32Error: Error {
|
||||||
|
case invalidFormat
|
||||||
|
case invalidCharacter
|
||||||
|
case invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
var result = [UInt8]()
|
||||||
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
|
for value in data {
|
||||||
|
acc = (acc << from) | Int(value)
|
||||||
|
bits += from
|
||||||
|
|
||||||
|
while bits >= to {
|
||||||
|
bits -= to
|
||||||
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pad && bits > 0 {
|
||||||
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
|
for i in 0..<6 {
|
||||||
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
|
var result = [UInt8]()
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
|
}
|
||||||
|
result.append(0)
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
|
var chk = 1
|
||||||
|
for value in values {
|
||||||
|
let b = chk >> 25
|
||||||
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||||
|
for i in 0..<5 {
|
||||||
|
if (b >> i) & 1 == 1 {
|
||||||
|
chk ^= generator[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
|
func load(key: String, service: String) -> Data?
|
||||||
|
func delete(key: String, service: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper: KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
return PeerID(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
import P256K
|
import P256K
|
||||||
import Security
|
|
||||||
|
|
||||||
// Keychain helper for secure storage
|
|
||||||
struct KeychainHelper {
|
|
||||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
|
||||||
if let accessible = accessible {
|
|
||||||
query[kSecAttrAccessible as String] = accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(key: String, service: String) -> Data? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func delete(key: String, service: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -103,269 +58,3 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
return publicKey.hexEncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge between Noise and Nostr identities
|
|
||||||
struct NostrIdentityBridge {
|
|
||||||
private static let keychainService = "chat.bitchat.nostr"
|
|
||||||
private static let currentIdentityKey = "nostr-current-identity"
|
|
||||||
private static let deviceSeedKey = "nostr-device-seed"
|
|
||||||
// In-memory cache to avoid transient keychain access issues
|
|
||||||
private static var deviceSeedCache: Data?
|
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
|
||||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
||||||
// Check if we already have a Nostr identity
|
|
||||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
|
||||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new Nostr identity
|
|
||||||
let nostrIdentity = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Store it
|
|
||||||
let data = try JSONEncoder().encode(nostrIdentity)
|
|
||||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
|
||||||
|
|
||||||
return nostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
|
||||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
|
||||||
let pubkey = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
|
||||||
static func clearAllAssociations() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
|
||||||
|
|
||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
||||||
/// Stored only on device keychain.
|
|
||||||
private static func getOrCreateDeviceSeed() -> Data {
|
|
||||||
if let cached = deviceSeedCache { return cached }
|
|
||||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
|
||||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = existing
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
var seed = Data(count: 32)
|
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = seed
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
|
||||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateKey(iteration: UInt32) -> Data {
|
|
||||||
var input = Data(msg)
|
|
||||||
var iterBE = iteration.bigEndian
|
|
||||||
withUnsafeBytes(of: &iterBE) { bytes in
|
|
||||||
input.append(contentsOf: bytes)
|
|
||||||
}
|
|
||||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
||||||
return Data(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a few iterations to ensure a valid key can be formed
|
|
||||||
for i in 0..<10 {
|
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As a final fallback, hash the seed+msg and try again
|
|
||||||
var combined = Data()
|
|
||||||
combined.append(seed)
|
|
||||||
combined.append(msg)
|
|
||||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bech32 encoding for Nostr (minimal implementation)
|
|
||||||
enum Bech32 {
|
|
||||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
|
||||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
||||||
let checksum = createChecksum(hrp: hrp, values: values)
|
|
||||||
let combined = values + checksum
|
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
||||||
return String(charset[index])
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
||||||
// Find the last occurrence of '1'
|
|
||||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
||||||
throw Bech32Error.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
|
||||||
for char in hrp {
|
|
||||||
guard char.asciiValue != nil else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
||||||
|
|
||||||
// Convert characters to values
|
|
||||||
var values = [UInt8]()
|
|
||||||
for char in dataString {
|
|
||||||
guard let index = charset.firstIndex(of: char) else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
guard values.count >= 6 else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
|
||||||
let checksum = Array(values.suffix(6))
|
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to bytes
|
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
||||||
return (hrp: hrp, data: Data(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
|
||||||
case invalidFormat
|
|
||||||
case invalidCharacter
|
|
||||||
case invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
||||||
var acc = 0
|
|
||||||
var bits = 0
|
|
||||||
var result = [UInt8]()
|
|
||||||
let maxv = (1 << to) - 1
|
|
||||||
|
|
||||||
for value in data {
|
|
||||||
acc = (acc << from) | Int(value)
|
|
||||||
bits += from
|
|
||||||
|
|
||||||
while bits >= to {
|
|
||||||
bits -= to
|
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pad && bits > 0 {
|
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
|
||||||
var checksum = [UInt8]()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
||||||
var result = [UInt8]()
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue >> 5))
|
|
||||||
}
|
|
||||||
result.append(0)
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue & 31))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
|
||||||
var chk = 1
|
|
||||||
for value in values {
|
|
||||||
let b = chk >> 25
|
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
||||||
for i in 0..<5 {
|
|
||||||
if (b >> i) & 1 == 1 {
|
|
||||||
chk ^= generator[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Bridge between Noise and Nostr identities
|
||||||
|
final class NostrIdentityBridge {
|
||||||
|
private let keychainService = "chat.bitchat.nostr"
|
||||||
|
private let currentIdentityKey = "nostr-current-identity"
|
||||||
|
private let deviceSeedKey = "nostr-device-seed"
|
||||||
|
// In-memory cache to avoid transient keychain access issues
|
||||||
|
private var deviceSeedCache: Data?
|
||||||
|
// Cache derived identities to avoid repeated crypto during view rendering
|
||||||
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||||
|
private let cacheLock = NSLock()
|
||||||
|
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create the current Nostr identity
|
||||||
|
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
|
// Check if we already have a Nostr identity
|
||||||
|
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||||
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Nostr identity
|
||||||
|
let nostrIdentity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
let data = try JSONEncoder().encode(nostrIdentity)
|
||||||
|
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||||
|
|
||||||
|
return nostrIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Nostr public key associated with a Noise public key
|
||||||
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
guard let data = keychain.load(key: key, service: keychainService),
|
||||||
|
let pubkey = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
func clearAllAssociations() {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSeedCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|
||||||
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
|
/// Stored only on device keychain.
|
||||||
|
private func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
|
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
var seed = Data(count: 32)
|
||||||
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||||
|
cacheLock.lock()
|
||||||
|
if let cached = derivedIdentityCache[geohash] {
|
||||||
|
cacheLock.unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheLock.unlock()
|
||||||
|
|
||||||
|
let seed = getOrCreateDeviceSeed()
|
||||||
|
guard let msg = geohash.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(iteration: UInt32) -> Data {
|
||||||
|
var input = Data(msg)
|
||||||
|
var iterBE = iteration.bigEndian
|
||||||
|
withUnsafeBytes(of: &iterBE) { bytes in
|
||||||
|
input.append(contentsOf: bytes)
|
||||||
|
}
|
||||||
|
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||||
|
return Data(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a few iterations to ensure a valid key can be formed
|
||||||
|
for i in 0..<10 {
|
||||||
|
let keyData = candidateKey(iteration: UInt32(i))
|
||||||
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||||
|
// Cache the result
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache[geohash] = identity
|
||||||
|
cacheLock.unlock()
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As a final fallback, hash the seed+msg and try again
|
||||||
|
let fallback = (seed + msg).sha256Hash()
|
||||||
|
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache[geohash] = identity
|
||||||
|
cacheLock.unlock()
|
||||||
|
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
|||||||
] as [Any]
|
] as [Any]
|
||||||
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
let hash = CryptoKit.SHA256.hash(data: data)
|
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||||
let hashData = Data(hash)
|
|
||||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
|
||||||
return (hashHex, hashData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
func jsonString() throws -> String {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ extension Data {
|
|||||||
return self.map { String(format: "%02x", $0) }.joined()
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sha256Hex() -> String {
|
||||||
|
let digest = SHA256.hash(data: self)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
init?(hexString: String) {
|
init?(hexString: String) {
|
||||||
let len = hexString.count / 2
|
let len = hexString.count / 2
|
||||||
var data = Data(capacity: len)
|
var data = Data(capacity: len)
|
||||||
@@ -197,7 +203,7 @@ extension Data {
|
|||||||
offset += 16
|
offset += 16
|
||||||
|
|
||||||
// Convert 16 bytes to UUID string format
|
// Convert 16 bytes to UUID string format
|
||||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
let uuid = uuidData.hexEncodedString()
|
||||||
|
|
||||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||||
var result = ""
|
var result = ""
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
///
|
///
|
||||||
/// ## Wire Format
|
/// ## Wire Format
|
||||||
/// ```
|
/// ```
|
||||||
/// Header (Fixed 13 bytes):
|
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
///
|
///
|
||||||
/// Variable sections:
|
/// Variable sections:
|
||||||
/// +----------+-------------+---------+------------+
|
/// +----------+-------------+---------+------------+
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
/// ## Flag Bits
|
/// ## Flag Bits
|
||||||
/// - Bit 0: Has recipient ID (directed message)
|
/// - Bit 0: Has recipient ID (directed message)
|
||||||
/// - Bit 1: Has signature (authenticated message)
|
/// - Bit 1: Has signature (authenticated message)
|
||||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
/// - Bit 2: Is compressed (zlib compression applied)
|
||||||
/// - Bits 3-7: Reserved for future use
|
/// - Bits 3-7: Reserved for future use
|
||||||
///
|
///
|
||||||
/// ## Size Constraints
|
/// ## Size Constraints
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
func trimmingNullBytes() -> Data {
|
func trimmingNullBytes() -> Data {
|
||||||
@@ -105,11 +106,33 @@ extension Data {
|
|||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
static let headerSize = 13
|
static let v1HeaderSize = 14
|
||||||
|
static let v2HeaderSize = 16
|
||||||
static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
// Field offsets within packet header
|
||||||
|
struct Offsets {
|
||||||
|
static let version = 0
|
||||||
|
static let type = 1
|
||||||
|
static let ttl = 2
|
||||||
|
static let timestamp = 3
|
||||||
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
|
switch version {
|
||||||
|
case 1: return v1HeaderSize
|
||||||
|
case 2: return v2HeaderSize
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||||
|
return version == 2 ? 4 : 2
|
||||||
|
}
|
||||||
|
|
||||||
struct Flags {
|
struct Flags {
|
||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
@@ -118,70 +141,69 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
var data = Data()
|
let version = packet.version
|
||||||
|
guard version == 1 || version == 2 else { return nil }
|
||||||
|
|
||||||
|
// Try to compress payload when beneficial, keeping original size for later decoding
|
||||||
// Try to compress payload if beneficial
|
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
var originalPayloadSize: UInt16? = nil
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
var originalPayloadSize: Int?
|
||||||
if CompressionUtil.shouldCompress(payload) {
|
if CompressionUtil.shouldCompress(payload) {
|
||||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
// Only compress when we can represent the original length in the outbound frame
|
||||||
// Store original size for decompression (2 bytes after payload)
|
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
||||||
originalPayloadSize = UInt16(payload.count)
|
if payload.count <= maxRepresentable,
|
||||||
|
let compressedPayload = CompressionUtil.compress(payload) {
|
||||||
|
originalPayloadSize = payload.count
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
isCompressed = true
|
isCompressed = true
|
||||||
|
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
|
||||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
data.reserveCapacity(estimated)
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
data.append(packet.version)
|
|
||||||
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
|
let estimatedPayload = payloadDataSize
|
||||||
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
||||||
|
|
||||||
|
data.append(version)
|
||||||
data.append(packet.type)
|
data.append(packet.type)
|
||||||
data.append(packet.ttl)
|
data.append(packet.ttl)
|
||||||
|
|
||||||
// Timestamp (8 bytes, big-endian)
|
for shift in stride(from: 56, through: 0, by: -8) {
|
||||||
for i in (0..<8).reversed() {
|
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
||||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
var flags: UInt8 = 0
|
var flags: UInt8 = 0
|
||||||
if packet.recipientID != nil {
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
flags |= Flags.hasRecipient
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
}
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
if packet.signature != nil {
|
|
||||||
flags |= Flags.hasSignature
|
|
||||||
}
|
|
||||||
if isCompressed {
|
|
||||||
flags |= Flags.isCompressed
|
|
||||||
}
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
if version == 2 {
|
||||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
let length = UInt32(payloadDataSize)
|
||||||
let payloadLength = UInt16(payloadDataSize)
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let length = UInt16(payloadDataSize)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(payloadLength & 0xFF))
|
|
||||||
|
|
||||||
// SenderID (exactly 8 bytes)
|
|
||||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
data.append(senderBytes)
|
data.append(senderBytes)
|
||||||
if senderBytes.count < senderIDSize {
|
if senderBytes.count < senderIDSize {
|
||||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipientID (if present)
|
|
||||||
if let recipientID = packet.recipientID {
|
if let recipientID = packet.recipientID {
|
||||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||||
data.append(recipientBytes)
|
data.append(recipientBytes)
|
||||||
@@ -190,29 +212,29 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload (with original size prepended if compressed)
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
// Prepend original size (2 bytes, big-endian)
|
if version == 2 {
|
||||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
let value = UInt32(originalSize)
|
||||||
data.append(UInt8(originalSize & 0xFF))
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = UInt16(originalSize)
|
||||||
|
data.append(UInt8((value >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value & 0xFF))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
data.append(payload)
|
data.append(payload)
|
||||||
|
|
||||||
// Signature (if present)
|
|
||||||
if let signature = packet.signature {
|
if let signature = packet.signature {
|
||||||
data.append(signature.prefix(signatureSize))
|
data.append(signature.prefix(signatureSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Apply padding to standard block sizes for traffic analysis resistance
|
|
||||||
if padding {
|
if padding {
|
||||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
return MessagePadding.pad(data, toSize: optimalSize)
|
||||||
return paddedData
|
|
||||||
} else {
|
|
||||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
@@ -227,87 +249,112 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
// Minimum size: header + senderID
|
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
||||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
|
||||||
|
|
||||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
guard let base = buf.baseAddress else { return nil }
|
guard let base = buf.baseAddress else { return nil }
|
||||||
var offset = 0
|
var offset = 0
|
||||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
// Read single byte
|
|
||||||
func read8() -> UInt8? {
|
func read8() -> UInt8? {
|
||||||
guard require(1) else { return nil }
|
guard require(1) else { return nil }
|
||||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
offset += 1
|
offset += 1
|
||||||
return v
|
return value
|
||||||
}
|
}
|
||||||
// Read big-endian 16-bit
|
|
||||||
func read16() -> UInt16? {
|
func read16() -> UInt16? {
|
||||||
guard require(2) else { return nil }
|
guard require(2) else { return nil }
|
||||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return v
|
return value
|
||||||
|
}
|
||||||
|
func read32() -> UInt32? {
|
||||||
|
guard require(4) else { return nil }
|
||||||
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
|
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
||||||
|
offset += 4
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
// Copy N bytes into Data
|
|
||||||
func readData(_ n: Int) -> Data? {
|
func readData(_ n: Int) -> Data? {
|
||||||
guard require(n) else { return nil }
|
guard require(n) else { return nil }
|
||||||
let ptr = base.advanced(by: offset)
|
let ptr = base.advanced(by: offset)
|
||||||
let d = Data(bytes: ptr, count: n)
|
let data = Data(bytes: ptr, count: n)
|
||||||
offset += n
|
offset += n
|
||||||
return d
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version
|
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
||||||
guard let version = read8(), version == 1 else { return nil }
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
guard let type = read8() else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
guard let ttl = read8() else { return nil }
|
let minimumRequired = headerSize + senderIDSize
|
||||||
|
guard raw.count >= minimumRequired else { return nil }
|
||||||
|
|
||||||
// Timestamp 8 bytes BE
|
guard let type = read8(), let ttl = read8() else { return nil }
|
||||||
guard require(8) else { return nil }
|
|
||||||
var ts: UInt64 = 0
|
var timestamp: UInt64 = 0
|
||||||
for _ in 0..<8 {
|
for _ in 0..<8 {
|
||||||
guard let b = read8() else { return nil }
|
guard let byte = read8() else { return nil }
|
||||||
ts = (ts << 8) | UInt64(b)
|
timestamp = (timestamp << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
guard let flags = read8() else { return nil }
|
guard let flags = read8() else { return nil }
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
// Payload length
|
let payloadLength: Int
|
||||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
if version == 2 {
|
||||||
|
guard let len = read32() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
} else {
|
||||||
|
guard let len = read16() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard payloadLength >= 0 else { return nil }
|
||||||
|
|
||||||
// SenderID
|
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
// Recipient
|
|
||||||
var recipientID: Data? = nil
|
var recipientID: Data? = nil
|
||||||
if hasRecipient {
|
if hasRecipient {
|
||||||
recipientID = readData(recipientIDSize)
|
recipientID = readData(recipientIDSize)
|
||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
// Need original size (2 bytes)
|
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||||
guard let origSize16 = read16() else { return nil }
|
let originalSize: Int
|
||||||
let originalSize = Int(origSize16)
|
if version == 2 {
|
||||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
let compSize = Int(payloadLen) - 2
|
originalSize = Int(rawSize)
|
||||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
} else {
|
||||||
|
guard let rawSize = read16() else { return nil }
|
||||||
|
originalSize = Int(rawSize)
|
||||||
|
}
|
||||||
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||||
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
|
||||||
|
// Validate compression ratio to prevent zip bomb attacks
|
||||||
|
// Primary protection: originalSize capped at 1MB (line 336)
|
||||||
|
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||||
|
guard compressedSize > 0 else { return nil }
|
||||||
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
|
guard compressionRatio <= 50_000.0 else {
|
||||||
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||||
payload = p
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature
|
|
||||||
var signature: Data? = nil
|
var signature: Data? = nil
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
signature = readData(signatureSize)
|
signature = readData(signatureSize)
|
||||||
@@ -320,10 +367,11 @@ struct BinaryProtocol {
|
|||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl
|
ttl: ttl,
|
||||||
|
version: version
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// BitchatFilePacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||||
|
struct BitchatFilePacket {
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var mimeType: String?
|
||||||
|
var content: Data
|
||||||
|
|
||||||
|
/// Canonical TLV tags defined by the Android implementation.
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case fileName = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case mimeType = 0x03
|
||||||
|
case content = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
|
func encode() -> Data? {
|
||||||
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
|
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||||
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
|
||||||
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
|
var big = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
|
||||||
|
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||||
|
encoded.append(TLVType.fileName.rawValue)
|
||||||
|
appendBE(UInt16(nameData.count), into: &encoded)
|
||||||
|
encoded.append(nameData)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.append(TLVType.fileSize.rawValue)
|
||||||
|
appendBE(UInt16(4), into: &encoded)
|
||||||
|
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||||
|
|
||||||
|
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||||
|
encoded.append(TLVType.mimeType.rawValue)
|
||||||
|
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||||
|
encoded.append(mimeData)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.append(TLVType.content.rawValue)
|
||||||
|
appendBE(UInt32(content.count), into: &encoded)
|
||||||
|
encoded.append(content)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
|
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var mimeType: String?
|
||||||
|
var content = Data()
|
||||||
|
|
||||||
|
while cursor < end {
|
||||||
|
let typeRaw = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
|
||||||
|
guard cursor <= end else { return nil }
|
||||||
|
let tlvType = TLVType(rawValue: typeRaw)
|
||||||
|
|
||||||
|
func readBigEndianLength(bytes: Int) -> Int? {
|
||||||
|
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||||
|
// Use UInt64 to prevent integer overflow during shift operations
|
||||||
|
var result: UInt64 = 0
|
||||||
|
for _ in 0..<bytes {
|
||||||
|
result = (result << 8) | UInt64(data[cursor])
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
}
|
||||||
|
// Safely convert to Int with overflow check
|
||||||
|
guard result <= Int.max else { return nil }
|
||||||
|
return Int(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let length: Int?
|
||||||
|
if tlvType == .content {
|
||||||
|
let snapshot = cursor
|
||||||
|
let canonical = readBigEndianLength(bytes: 4)
|
||||||
|
if let canonical = canonical,
|
||||||
|
canonical <= data.distance(from: cursor, to: end) {
|
||||||
|
length = canonical
|
||||||
|
} else {
|
||||||
|
cursor = snapshot
|
||||||
|
length = readBigEndianLength(bytes: 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
length = readBigEndianLength(bytes: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||||
|
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||||
|
|
||||||
|
let valueStart = cursor
|
||||||
|
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||||
|
let value = data[valueStart..<cursor]
|
||||||
|
|
||||||
|
switch tlvType {
|
||||||
|
case .fileName:
|
||||||
|
fileName = String(data: Data(value), encoding: .utf8)
|
||||||
|
case .fileSize:
|
||||||
|
if tlvLength == 4 || tlvLength == 8 {
|
||||||
|
var size: UInt64 = 0
|
||||||
|
for byte in value {
|
||||||
|
size = (size << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fileSize = size
|
||||||
|
}
|
||||||
|
case .mimeType:
|
||||||
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
let proposedSize = content.count + value.count
|
||||||
|
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content.append(contentsOf: value)
|
||||||
|
case nil:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !content.isEmpty else { return nil }
|
||||||
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
return BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
mimeType: mimeType,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ enum MessageType: UInt8 {
|
|||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -88,6 +90,7 @@ enum MessageType: UInt8 {
|
|||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +134,7 @@ enum LazyHandshakeState {
|
|||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
// Delivery status for messages
|
// Delivery status for messages
|
||||||
enum DeliveryStatus: Codable, Equatable {
|
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
case sending
|
case sending
|
||||||
case sent // Left our device
|
case sent // Left our device
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
@@ -161,9 +164,9 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: String)
|
func didConnectToPeer(_ peerID: PeerID)
|
||||||
func didDisconnectFromPeer(_ peerID: String)
|
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||||
func didUpdatePeerList(_ peers: [String])
|
func didUpdatePeerList(_ peers: [PeerID])
|
||||||
|
|
||||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
@@ -171,8 +174,11 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||||
|
|
||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
|
||||||
|
// Bluetooth state updates for user notifications
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -185,11 +191,11 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Validates a geohash string for building-level precision (8 characters).
|
||||||
|
/// - Parameter geohash: The geohash string to validate
|
||||||
|
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||||
|
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||||
|
guard geohash.count == 8 else { return false }
|
||||||
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
|
}
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - latitude: Latitude in degrees (-90...90)
|
/// - latitude: Latitude in degrees (-90...90)
|
||||||
|
|||||||
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building:
|
case .building:
|
||||||
return L10n.string("location_levels.building", comment: "Name for building-level location channel")
|
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||||
case .block:
|
case .block:
|
||||||
return L10n.string("location_levels.block", comment: "Name for block-level location channel")
|
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||||
case .neighborhood:
|
case .neighborhood:
|
||||||
return L10n.string("location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||||
case .city:
|
case .city:
|
||||||
return L10n.string("location_levels.city", comment: "Name for city-level location channel")
|
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||||
case .province:
|
case .province:
|
||||||
return L10n.string("location_levels.province", comment: "Name for province-level location channel")
|
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||||
case .region:
|
case .region:
|
||||||
return L10n.string("location_levels.region", comment: "Name for region-level location channel")
|
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Peer ID Utilities
|
|
||||||
|
|
||||||
struct PeerIDUtils {
|
|
||||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
|
||||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: publicKey)
|
|
||||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
return String(hex.prefix(16))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+2334
-1300
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -104,7 +104,7 @@ final class CommandProcessor {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.visibleGeohashPeople().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
@@ -285,7 +285,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let noisePublicKey = Data(hexString: peerID) else {
|
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
private init() {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -179,125 +181,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
guard peerID.count == 16 else { return nil }
|
guard peerID.isShort else { return nil }
|
||||||
for (pubkey, rel) in favorites {
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
return rel
|
||||||
if derived == peerID { return rel }
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update Nostr public key for a peer
|
|
||||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: nostrPubkey,
|
|
||||||
peerNickname: existing.peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update nickname for an existing favorite
|
|
||||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
// Skip if nickname hasn't changed
|
|
||||||
if existing.peerNickname == newNickname { return }
|
|
||||||
|
|
||||||
// Updating nickname for favorite
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: newNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update noise public key when peer reconnects with new ID
|
|
||||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
|
||||||
guard let existing = favorites[oldKey] else {
|
|
||||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already have a favorite with the new key
|
|
||||||
if favorites[newKey] != nil {
|
|
||||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
saveFavorites()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updating noise public key
|
|
||||||
|
|
||||||
// Remove old entry
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
|
|
||||||
// Add with new key
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: newKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[newKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers with both old and new keys
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"peerPublicKey": newKey,
|
|
||||||
"oldPeerPublicKey": oldKey,
|
|
||||||
"isKeyUpdate": true
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorites (including non-mutual)
|
|
||||||
func getAllFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isFavorite }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get only mutual favorites
|
|
||||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isMutual }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorite relationships (including where they favorited us)
|
|
||||||
func getAllRelationships() -> [FavoriteRelationship] {
|
|
||||||
Array(favorites.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all favorites - used for panic mode
|
/// Clear all favorites - used for panic mode
|
||||||
func clearAllFavorites() {
|
func clearAllFavorites() {
|
||||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||||
@@ -306,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
KeychainHelper.delete(
|
keychain.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -326,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
KeychainHelper.save(
|
keychain.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService
|
service: Self.keychainService,
|
||||||
|
accessible: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
@@ -341,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = KeychainHelper.load(
|
guard let data = keychain.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private init() {
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
private func load() {
|
private func load() {
|
||||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
guard let data = storage.data(forKey: storeKey) else { return }
|
||||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
@@ -81,7 +84,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
membership = seen
|
membership = seen
|
||||||
}
|
}
|
||||||
// Load any saved names
|
// Load any saved names
|
||||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
if let namesData = storage.data(forKey: namesStoreKey),
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
bookmarkNames = dict
|
bookmarkNames = dict
|
||||||
}
|
}
|
||||||
@@ -89,13 +92,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
UserDefaults.standard.set(data, forKey: storeKey)
|
storage.set(data, forKey: storeKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistNames() {
|
private func persistNames() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
storage.set(data, forKey: namesStoreKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Testing-only reset helper
|
|
||||||
func _resetForTesting() {
|
|
||||||
bookmarks.removeAll()
|
|
||||||
membership.removeAll()
|
|
||||||
bookmarkNames.removeAll()
|
|
||||||
persist()
|
|
||||||
persistNames()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
switch status {
|
switch status {
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
break // will compute from location
|
break // will compute from location
|
||||||
default:
|
case .notDetermined, .restricted, .denied:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
if case .location(let ch) = selectedChannel {
|
if case .location(let ch) = selectedChannel {
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
func subscribe(geohash gh: String) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
@@ -74,7 +79,7 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct LocationNotesDependencies {
|
|||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
},
|
},
|
||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||||
},
|
},
|
||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
@@ -74,38 +76,52 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var state: State = .loading
|
@Published private(set) var state: State = .loading
|
||||||
@Published private(set) var errorMessage: String?
|
@Published private(set) var errorMessage: String?
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
private let dependencies: LocationNotesDependencies
|
private let dependencies: LocationNotesDependencies
|
||||||
|
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||||
|
|
||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let noRelays = NSLocalizedString(
|
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||||
"location_notes.error.no_relays",
|
|
||||||
comment: "Shown when no geo relays are available near the selected location"
|
|
||||||
)
|
|
||||||
|
|
||||||
static func failedToSend(_ detail: String) -> String {
|
static func failedToSend(_ detail: String) -> String {
|
||||||
let format = NSLocalizedString(
|
String(
|
||||||
"location_notes.error.failed_to_send",
|
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||||
comment: "Shown when a location note fails to send"
|
locale: .current,
|
||||||
|
detail
|
||||||
)
|
)
|
||||||
return String(format: format, detail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
self.geohash = geohash.lowercased()
|
let norm = geohash.lowercased()
|
||||||
|
self.geohash = norm
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
if !Geohash.isValidBuildingGeohash(norm) {
|
||||||
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
}
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +130,12 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +171,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||||
if self.notes.contains(where: { $0.id == event.id }) { return }
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
|
self.enforceMemoryCap()
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
}, { [weak self] in
|
}, { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -191,10 +214,12 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: id.publicKeyHex,
|
pubkey: id.publicKeyHex,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: dependencies.now(),
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
nickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.enforceMemoryCap()
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
self.errorMessage = nil
|
self.errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
@@ -203,6 +228,15 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||||
|
private func enforceMemoryCap() {
|
||||||
|
if notes.count > maxNotesInMemory {
|
||||||
|
let removed = notes.count - maxNotesInMemory
|
||||||
|
notes = Array(notes.prefix(maxNotesInMemory))
|
||||||
|
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Explicitly cancel subscription and release resources.
|
/// Explicitly cancel subscription and release resources.
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Foundation
|
|||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrTransport
|
private let nostr: NostrTransport
|
||||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
init(mesh: Transport, nostr: NostrTransport) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.mesh = mesh
|
self.mesh = mesh
|
||||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
|||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
let peerID = PeerID(publicKey: data)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
|||||||
// Handle key updates
|
// Handle key updates
|
||||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
let peerID = PeerID(publicKey: newKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -37,44 +37,44 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
if reachableMesh {
|
if reachableMesh {
|
||||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// BLEService will initiate a handshake if needed and queue the message
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
nostr.sendReadReceipt(receipt, to: peerID)
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
// Route via mesh when connected; else use Nostr
|
// Route via mesh when connected; else use Nostr
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
// Two forms are supported:
|
// Two forms are supported:
|
||||||
// - 64-hex Noise public key (32 bytes)
|
// - 64-hex Noise public key (32 bytes)
|
||||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
if let noiseKey = peerID.noiseKey {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if peerID.count == 16 {
|
} else if peerID.isShort {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: String) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Keep unsent items queued
|
// Keep unsent items queued
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
/// Policy: permit start when either location permissions are authorized OR
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
|
|||||||
@@ -115,60 +115,30 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return L10n.string(
|
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||||
"encryption.status.failed",
|
|
||||||
comment: "Status text when encryption failed"
|
|
||||||
)
|
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return L10n.string(
|
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||||
"encryption.status.not_encrypted",
|
|
||||||
comment: "Status text when no encryption handshake happened"
|
|
||||||
)
|
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return L10n.string(
|
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||||
"encryption.status.establishing",
|
|
||||||
comment: "Status text when encryption is being established"
|
|
||||||
)
|
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return L10n.string(
|
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||||
"encryption.status.secured",
|
|
||||||
comment: "Status text when encryption is secured but not verified"
|
|
||||||
)
|
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return L10n.string(
|
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||||
"encryption.status.verified",
|
|
||||||
comment: "Status text when encryption is verified"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessibilityDescription: String {
|
var accessibilityDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return L10n.string(
|
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||||
"encryption.accessibility.failed",
|
|
||||||
comment: "Accessibility text when encryption failed"
|
|
||||||
)
|
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return L10n.string(
|
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||||
"encryption.accessibility.not_encrypted",
|
|
||||||
comment: "Accessibility text when encryption is not established"
|
|
||||||
)
|
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return L10n.string(
|
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||||
"encryption.accessibility.establishing",
|
|
||||||
comment: "Accessibility text when encryption is being established"
|
|
||||||
)
|
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return L10n.string(
|
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||||
"encryption.accessibility.secured",
|
|
||||||
comment: "Accessibility text when encryption is secured"
|
|
||||||
)
|
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return L10n.string(
|
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||||
"encryption.accessibility.verified",
|
|
||||||
comment: "Accessibility text when encryption is verified"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,8 +162,8 @@ final class NoiseEncryptionService {
|
|||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
@@ -208,7 +178,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
@@ -302,12 +272,11 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Get our identity fingerprint
|
/// Get our identity fingerprint
|
||||||
func getIdentityFingerprint() -> String {
|
func getIdentityFingerprint() -> String {
|
||||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,11 +404,11 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +418,7 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||||
|
|
||||||
// Return raw handshake data without wrapper
|
// Return raw handshake data without wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
@@ -458,17 +427,17 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,19 +457,19 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have a session (established or handshaking) with a peer
|
/// Check if we have a session (established or handshaking) with a peer
|
||||||
func hasSession(with peerID: String) -> Bool {
|
func hasSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
return sessionManager.getSession(for: peerID) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
/// Encrypt data for a specific peer
|
/// Encrypt data for a specific peer
|
||||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -522,7 +491,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -544,33 +513,12 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
return peerFingerprints[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for a fingerprint
|
|
||||||
func getPeerID(for fingerprint: String) -> String? {
|
|
||||||
return serviceQueue.sync {
|
|
||||||
return fingerprintToPeerID[fingerprint]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a peer session
|
|
||||||
func removePeer(_ peerID: String) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
if let fingerprint = peerFingerprints[peerID] {
|
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
||||||
}
|
|
||||||
peerFingerprints.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -582,9 +530,9 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -593,21 +541,16 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peerID.id, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
|
||||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
|
|
||||||
private func startRekeyTimer() {
|
private func startRekeyTimer() {
|
||||||
|
|||||||
@@ -4,46 +4,51 @@ import Combine
|
|||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport {
|
final class NostrTransport: Transport {
|
||||||
weak var delegate: BitchatDelegate?
|
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
|
||||||
Just([]).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
|
||||||
|
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID: String = ""
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
// Throttle READ receipts to avoid relay rate limits
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
private struct QueuedRead {
|
private struct QueuedRead {
|
||||||
let receipt: ReadReceipt
|
let receipt: ReadReceipt
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
}
|
}
|
||||||
private var readQueue: [QueuedRead] = []
|
private var readQueue: [QueuedRead] = []
|
||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
|
Just([]).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||||
|
|
||||||
|
var myPeerID: PeerID { senderPeerID }
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
func startServices() { /* no-op */ }
|
func startServices() { /* no-op */ }
|
||||||
func stopServices() { /* no-op */ }
|
func stopServices() { /* no-op */ }
|
||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||||
|
|
||||||
func getFingerprint(for peerID: String) -> String? { nil }
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService?
|
||||||
@@ -59,11 +64,11 @@ final class NostrTransport: Transport {
|
|||||||
// Public broadcast not supported over Nostr here
|
// Public broadcast not supported over Nostr here
|
||||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Convert recipient npub -> hex (x-only)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -77,7 +82,7 @@ final class NostrTransport: Transport {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,12 +95,113 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
processReadQueueIfNeeded()
|
processReadQueueIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash Helpers
|
||||||
|
|
||||||
|
extension NostrTransport {
|
||||||
|
|
||||||
|
// MARK: Geohash ACK helpers
|
||||||
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Geohash DMs (per-geohash identity)
|
||||||
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !recipientHex.isEmpty else { return }
|
||||||
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
extension NostrTransport {
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
guard !readQueue.isEmpty else { return }
|
guard !readQueue.isEmpty else { return }
|
||||||
@@ -108,7 +214,7 @@ final class NostrTransport: Transport {
|
|||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
@@ -117,7 +223,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
@@ -139,111 +245,18 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
// Convert recipient npub -> hex
|
|
||||||
let recipientHex: String
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
|
||||||
guard hrp == "npub" else { return }
|
|
||||||
recipientHex = data.hexEncodedString()
|
|
||||||
} catch { return }
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.count == 16,
|
if peerID.id.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
let recipientHex: String
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
|
||||||
guard hrp == "npub" else { return }
|
|
||||||
recipientHex = data.hexEncodedString()
|
|
||||||
} catch { return }
|
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash ACK helpers
|
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash DMs (per-geohash identity)
|
|
||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard !recipientHex.isEmpty else { return }
|
|
||||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,26 +70,6 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteOnlineNotification(nickname: String) {
|
|
||||||
// Send directly without checking app state for favorites
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "⭐ \(nickname) is online!"
|
|
||||||
content.body = "wanna get in there?"
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: "favorite-online-\(UUID().uuidString)",
|
|
||||||
content: content,
|
|
||||||
trigger: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { _ in
|
|
||||||
// Notification added
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geohash public chat notification with deep link to a specific geohash
|
// Geohash public chat notification with deep link to a specific geohash
|
||||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||||
let title = "\(titlePrefix)\(geohash)"
|
let title = "\(titlePrefix)\(geohash)"
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// NotificationStreamAssembler.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NotificationStreamAssembler {
|
||||||
|
private var buffer = Data()
|
||||||
|
private var pendingFrameStartedAt: DispatchTime?
|
||||||
|
private var pendingFrameExpectedLength: Int = 0
|
||||||
|
|
||||||
|
private mutating func resetState() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
|
|
||||||
|
buffer.append(chunk)
|
||||||
|
|
||||||
|
var frames: [Data] = []
|
||||||
|
var dropped: [UInt8] = []
|
||||||
|
var didReset = false
|
||||||
|
let now = DispatchTime.now()
|
||||||
|
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||||
|
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||||
|
|
||||||
|
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||||
|
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||||
|
resetState()
|
||||||
|
return ([], [], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
while buffer.count >= minimumFramePrefix {
|
||||||
|
guard let version = buffer.first else { break }
|
||||||
|
guard version == 1 || version == 2 else {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
||||||
|
guard buffer.count >= framePrefix else { break }
|
||||||
|
|
||||||
|
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||||
|
guard flagsIndex < buffer.endIndex else { break }
|
||||||
|
let flags = buffer[flagsIndex]
|
||||||
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
|
|
||||||
|
let lengthOffset = 12
|
||||||
|
let payloadLength: Int
|
||||||
|
if version == 2 {
|
||||||
|
let lengthIndex = buffer.startIndex + lengthOffset
|
||||||
|
payloadLength =
|
||||||
|
(Int(buffer[lengthIndex]) << 24) |
|
||||||
|
(Int(buffer[lengthIndex + 1]) << 16) |
|
||||||
|
(Int(buffer[lengthIndex + 2]) << 8) |
|
||||||
|
Int(buffer[lengthIndex + 3])
|
||||||
|
} else {
|
||||||
|
let lengthIndex = buffer.startIndex + lengthOffset
|
||||||
|
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameLength = framePrefix + payloadLength
|
||||||
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
if isCompressed {
|
||||||
|
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||||
|
if payloadLength < rawLengthFieldBytes {
|
||||||
|
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||||
|
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer.count < frameLength {
|
||||||
|
let remaining = frameLength - buffer.count
|
||||||
|
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||||
|
pendingFrameStartedAt = now
|
||||||
|
pendingFrameExpectedLength = frameLength
|
||||||
|
} else if let started = pendingFrameStartedAt {
|
||||||
|
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
||||||
|
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
||||||
|
if elapsed >= threshold {
|
||||||
|
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
|
||||||
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
|
frames.append(frame)
|
||||||
|
buffer.removeFirst(frameLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (frames, dropped, didReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Manages all private chat functionality
|
/// Manages all private chat functionality
|
||||||
final class PrivateChatManager: ObservableObject {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: String? = nil
|
@Published var selectedPeer: PeerID? = nil
|
||||||
@Published var unreadMessages: Set<String> = []
|
@Published var unreadMessages: Set<PeerID> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -31,7 +31,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
@@ -54,108 +54,32 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeerFingerprint = nil
|
selectedPeerFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a private message
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sendMessage(_ content: String, to peerID: String) {
|
func sanitizeChat(for peerID: PeerID) {
|
||||||
guard let meshService = meshService,
|
guard let arr = privateChats[peerID] else { return }
|
||||||
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
if arr.count <= 1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageID = UUID().uuidString
|
var indexByID: [String: Int] = [:]
|
||||||
|
indexByID.reserveCapacity(arr.count)
|
||||||
// Create local message
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: meshService.myNickname,
|
|
||||||
content: content,
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: peerNickname,
|
|
||||||
senderPeerID: meshService.myPeerID,
|
|
||||||
mentions: nil,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add to chat
|
|
||||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
|
||||||
privateChats[peerID]?.append(message)
|
|
||||||
// Enforce per-chat cap on local append
|
|
||||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
|
||||||
let remove = arr.count - privateChatCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
privateChats[peerID] = arr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send via mesh service
|
|
||||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle incoming private message
|
|
||||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
|
||||||
guard let senderPeerID = message.senderPeerID else { return }
|
|
||||||
|
|
||||||
// Initialize chat if needed
|
|
||||||
if privateChats[senderPeerID] == nil {
|
|
||||||
privateChats[senderPeerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate by ID: replace existing message if present, else append
|
|
||||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
privateChats[senderPeerID]?[idx] = message
|
|
||||||
} else {
|
|
||||||
privateChats[senderPeerID]?.append(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
|
||||||
sanitizeChat(for: senderPeerID)
|
|
||||||
// Enforce cap after sanitize
|
|
||||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
|
||||||
let remove = arr.count - privateChatCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
privateChats[senderPeerID] = arr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as unread if not in this chat
|
|
||||||
if selectedPeer != senderPeerID {
|
|
||||||
unreadMessages.insert(senderPeerID)
|
|
||||||
|
|
||||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
|
||||||
if !sentReadReceipts.contains(message.id) {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
|
||||||
from: message.sender,
|
|
||||||
message: message.content,
|
|
||||||
peerID: senderPeerID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Send read receipt if viewing this chat
|
|
||||||
sendReadReceipt(for: message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
|
||||||
func sanitizeChat(for peerID: String) {
|
|
||||||
guard let arr = privateChats[peerID] else { return }
|
|
||||||
var seen = Set<String>()
|
|
||||||
var deduped: [BitchatMessage] = []
|
var deduped: [BitchatMessage] = []
|
||||||
|
deduped.reserveCapacity(arr.count)
|
||||||
|
|
||||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
if !seen.contains(msg.id) {
|
if let existing = indexByID[msg.id] {
|
||||||
seen.insert(msg.id)
|
deduped[existing] = msg
|
||||||
deduped.append(msg)
|
|
||||||
} else {
|
} else {
|
||||||
// Replace previous with the latest occurrence (which is later in sort)
|
indexByID[msg.id] = deduped.count
|
||||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
deduped.append(msg)
|
||||||
deduped[index] = msg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats[peerID] = deduped
|
privateChats[peerID] = deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
func markAsRead(from peerID: String) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
unreadMessages.remove(peerID)
|
unreadMessages.remove(peerID)
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
@@ -168,48 +92,6 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
|
||||||
func updateSelectedPeer(peers: [String: String]) {
|
|
||||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
|
||||||
|
|
||||||
// Find peer with matching fingerprint
|
|
||||||
for (peerID, _) in peers {
|
|
||||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
|
||||||
selectedPeer = peerID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get chat messages for current context
|
|
||||||
func getCurrentMessages() -> [BitchatMessage] {
|
|
||||||
guard let peer = selectedPeer else { return [] }
|
|
||||||
return privateChats[peer] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear a private chat
|
|
||||||
func clearChat(with peerID: String) {
|
|
||||||
privateChats[peerID]?.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle delivery acknowledgment
|
|
||||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
|
||||||
guard privateChats[peerID] != nil else { return }
|
|
||||||
|
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle read receipt
|
|
||||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
|
||||||
guard privateChats[peerID] != nil else { return }
|
|
||||||
|
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
@@ -223,13 +105,13 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? "",
|
readerID: meshService?.myPeerID.id ?? "",
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Centralized progress bus for Bluetooth file transfers.
|
||||||
|
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
|
||||||
|
final class TransferProgressManager {
|
||||||
|
static let shared = TransferProgressManager()
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case started(id: String, totalFragments: Int)
|
||||||
|
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
case completed(id: String, totalFragments: Int)
|
||||||
|
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let subject = PassthroughSubject<Event, Never>()
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
|
||||||
|
private var states: [String: (sent: Int, total: Int)] = [:]
|
||||||
|
|
||||||
|
var publisher: AnyPublisher<Event, Never> {
|
||||||
|
subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(id: String, totalFragments: Int) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.states[id] = (sent: 0, total: totalFragments)
|
||||||
|
self.subject.send(.started(id: id, totalFragments: totalFragments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordFragmentSent(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self, var state = self.states[id] else { return }
|
||||||
|
state.sent = min(state.sent + 1, state.total)
|
||||||
|
self.states[id] = state
|
||||||
|
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
if state.sent >= state.total {
|
||||||
|
self.states.removeValue(forKey: id)
|
||||||
|
self.subject.send(.completed(id: id, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
|
||||||
|
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.states.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||||
|
var result: (sent: Int, total: Int)?
|
||||||
|
queue.sync {
|
||||||
|
result = states[id]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Combine
|
|||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||||
let id: String
|
let peerID: PeerID
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let noisePublicKey: Data?
|
let noisePublicKey: Data?
|
||||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Peer events (preferred over publishers for UI)
|
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
// Peer events (preferred over publishers for UI)
|
||||||
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
|
||||||
|
// Peer snapshots (for non-UI services)
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
var myPeerID: String { get }
|
var myPeerID: PeerID { get }
|
||||||
var myNickname: String { get }
|
var myNickname: String { get }
|
||||||
func setNickname(_ nickname: String)
|
func setNickname(_ nickname: String)
|
||||||
|
|
||||||
@@ -28,37 +32,39 @@ protocol Transport: AnyObject {
|
|||||||
func emergencyDisconnectAll()
|
func emergencyDisconnectAll()
|
||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
func isPeerReachable(_ peerID: String) -> Bool
|
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: PeerID) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [PeerID: String]
|
||||||
|
|
||||||
// Protocol utilities
|
// Protocol utilities
|
||||||
func getFingerprint(for peerID: String) -> String?
|
func getFingerprint(for peerID: PeerID) -> String?
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||||
func triggerHandshake(with peerID: String)
|
func triggerHandshake(with peerID: PeerID)
|
||||||
func getNoiseService() -> NoiseEncryptionService
|
func getNoiseService() -> NoiseEncryptionService
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||||
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
|
func cancelTransfer(_ transferId: String) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ enum TransportConfig {
|
|||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
static let bleConnectionCandidatesMax: Int = 100
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||||
static let blePendingNotificationsCapCount: Int = 20
|
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
||||||
|
static let bleAssemblerStallResetMs: Int = 250
|
||||||
|
static let blePendingNotificationsCapCount: Int = 128
|
||||||
|
static let bleNotificationRetryDelayMs: Int = 25
|
||||||
|
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -19,15 +18,16 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published private(set) var peers: [BitchatPeer] = []
|
@Published private(set) var peers: [BitchatPeer] = []
|
||||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
||||||
@Published private(set) var favorites: [BitchatPeer] = []
|
@Published private(set) var favorites: [BitchatPeer] = []
|
||||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private var peerIndex: [String: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
@@ -35,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
meshService: Transport,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
@@ -78,12 +83,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<PeerID> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<PeerID> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.peerID
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
@@ -104,7 +109,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 2: Add offline favorites that we actively favorite
|
// Phase 2: Add offline favorites that we actively favorite
|
||||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
let peerID = favoriteKey.hexEncodedString()
|
let peerID = PeerID(hexData: favoriteKey)
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
// Skip if already added (connected peer)
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
if addedPeerIDs.contains(peerID) { continue }
|
||||||
@@ -138,10 +143,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Phase 4: Build subsets and indices
|
// Phase 4: Build subsets and indices
|
||||||
var favoritesList: [BitchatPeer] = []
|
var favoritesList: [BitchatPeer] = []
|
||||||
var mutualsList: [BitchatPeer] = []
|
var mutualsList: [BitchatPeer] = []
|
||||||
var newIndex: [String: BitchatPeer] = [:]
|
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||||
|
|
||||||
for peer in enrichedPeers {
|
for peer in enrichedPeers {
|
||||||
newIndex[peer.id] = peer
|
newIndex[peer.peerID] = peer
|
||||||
|
|
||||||
if peer.isFavorite {
|
if peer.isFavorite {
|
||||||
favoritesList.append(peer)
|
favoritesList.append(peer)
|
||||||
@@ -185,7 +190,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
peerID: peerInfo.peerID,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
@@ -205,10 +210,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromFavorite(
|
private func buildPeerFromFavorite(
|
||||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||||
peerID: String
|
peerID: PeerID
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerID,
|
peerID: peerID,
|
||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
@@ -225,27 +230,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Get peer by ID
|
/// Get peer by ID
|
||||||
func getPeer(by id: String) -> BitchatPeer? {
|
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||||
return peerIndex[id]
|
return peerIndex[peerID]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> String? {
|
func getPeerID(for nickname: String) -> PeerID? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.id
|
return peer.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if peer is online
|
|
||||||
func isOnline(_ peerID: String) -> Bool {
|
|
||||||
return connectedPeerIDs.contains(peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if peer is blocked
|
/// Check if peer is blocked
|
||||||
func isBlocked(_ peerID: String) -> Bool {
|
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||||
// Get fingerprint
|
// Get fingerprint
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle favorite status
|
/// Toggle favorite status
|
||||||
func toggleFavorite(_ peerID: String) {
|
func toggleFavorite(_ peerID: PeerID) {
|
||||||
guard let peer = getPeer(by: peerID) else {
|
guard let peer = getPeer(by: peerID) else {
|
||||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -291,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
@@ -322,39 +322,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle blocked status
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
func toggleBlocked(_ peerID: String) {
|
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
|
||||||
|
|
||||||
// Get or create social identity
|
|
||||||
var identity = identityManager.getSocialIdentity(for: fingerprint)
|
|
||||||
?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Toggle blocked status
|
|
||||||
identity.isBlocked = !identity.isBlocked
|
|
||||||
|
|
||||||
// Can't be both favorite and blocked
|
|
||||||
if identity.isBlocked {
|
|
||||||
identity.isFavorite = false
|
|
||||||
// Also remove from favorites service
|
|
||||||
if let peer = getPeer(by: peerID) {
|
|
||||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identityManager.updateSocialIdentity(identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get fingerprint for peer ID
|
|
||||||
func getFingerprint(for peerID: String) -> String? {
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let cached = fingerprintCache[peerID] {
|
if let cached = fingerprintCache[peerID] {
|
||||||
return cached
|
return cached
|
||||||
@@ -379,23 +347,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
var blockedUsers: Set<String> {
|
var blockedUsers: Set<String> {
|
||||||
Set(peers.compactMap { peer in
|
Set(peers.compactMap { peer in
|
||||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Extensions
|
|
||||||
|
|
||||||
extension Data {
|
|
||||||
func sha256Fingerprint() -> String {
|
|
||||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
|
||||||
let hash = SHA256.hash(data: self)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
|||||||
nickname: payload.nickname,
|
nickname: payload.nickname,
|
||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
nonceB64: payload.nonceB64,
|
nonceB64: payload.nonceB64,
|
||||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
sigHex: sig.hexEncodedString())
|
||||||
let out = signed.toURLString()
|
let out = signed.toURLString()
|
||||||
Cache.last = (nickname, npub, Date(), out)
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
protocol Delegate: AnyObject {
|
protocol Delegate: AnyObject {
|
||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,9 +12,13 @@ final class GossipSyncManager {
|
|||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: String
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
@@ -26,8 +30,9 @@ final class GossipSyncManager {
|
|||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
|
|
||||||
init(myPeerID: String, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
}
|
}
|
||||||
@@ -35,8 +40,11 @@ final class GossipSyncManager {
|
|||||||
func start() {
|
func start() {
|
||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
||||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.performPeriodicMaintenance()
|
||||||
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
}
|
}
|
||||||
@@ -45,7 +53,7 @@ final class GossipSyncManager {
|
|||||||
periodicTimer?.cancel(); periodicTimer = nil
|
periodicTimer?.cancel(); periodicTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
self?.sendRequestSync(to: peerID)
|
self?.sendRequestSync(to: peerID)
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,27 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to check if a packet is within the age threshold
|
||||||
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||||
|
|
||||||
|
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||||
|
guard nowMs >= ageThresholdMs else { return true }
|
||||||
|
|
||||||
|
let cutoffMs = nowMs - ageThresholdMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return true }
|
||||||
|
let cutoffMs = nowMs - timeoutMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
@@ -67,6 +96,17 @@ final class GossipSyncManager {
|
|||||||
let isAnnounce = (mt == .announce)
|
let isAnnounce = (mt == .announce)
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
|
if isAnnounce {
|
||||||
|
guard isAnnouncementFresh(packet) else {
|
||||||
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
|
removeState(forNormalizedPeerID: sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -81,7 +121,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isAnnounce {
|
} else if isAnnounce {
|
||||||
let sender = packet.senderID.hexEncodedString()
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +130,7 @@ final class GossipSyncManager {
|
|||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload()
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: nil, // broadcast
|
recipientID: nil, // broadcast
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -101,10 +141,10 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: String) {
|
private func sendRequestSync(to peerID: PeerID) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload()
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
let hexByte = String(temp.prefix(2))
|
let hexByte = String(temp.prefix(2))
|
||||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||||
@@ -112,7 +152,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: recipient,
|
recipientID: recipient,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -123,13 +163,13 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(to: peerID, packet: signed)
|
delegate?.sendPacket(to: peerID, packet: signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
self?._handleRequestSync(from: peerID, request: request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -137,36 +177,46 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them
|
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing
|
// 2) Broadcast messages: send all missing (and not expired)
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
private func buildGcsPayload() -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages
|
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
if isPacketFresh(pair.packet) {
|
||||||
|
candidates.append(pair.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in messageOrder {
|
||||||
|
if let p = messages[id], isPacketFresh(p) {
|
||||||
|
candidates.append(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sort by timestamp desc
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
@@ -184,17 +234,65 @@ final class GossipSyncManager {
|
|||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of expired messages and announcements
|
||||||
|
private func cleanupExpiredMessages() {
|
||||||
|
// Remove expired announcements
|
||||||
|
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired messages
|
||||||
|
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||||
|
isPacketFresh(pkt) ? nil : id
|
||||||
|
}
|
||||||
|
for id in expiredMessageIds {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
|
cleanupExpiredMessages()
|
||||||
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
|
sendRequestSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastStalePeerCleanup = now
|
||||||
|
cleanupStaleAnnouncements(now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupStaleAnnouncements(now: Date) {
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return }
|
||||||
|
let cutoff = nowMs - timeoutMs
|
||||||
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||||
|
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||||
|
}
|
||||||
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
|
for peerKey in stalePeerIDs {
|
||||||
|
removeState(forNormalizedPeerID: peerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: String) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?._removeAnnouncementForPeer(peerID)
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.lowercased()
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
// Collect IDs to remove first to avoid concurrent modification
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
@@ -208,3 +306,25 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension GossipSyncManager {
|
||||||
|
func _performMaintenanceSynchronously(now: Date = Date()) {
|
||||||
|
queue.sync {
|
||||||
|
performPeriodicMaintenance(now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
|
queue.sync {
|
||||||
|
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
|||||||
let digest = hasher.finalize()
|
let digest = hasher.finalize()
|
||||||
return Data(digest.prefix(16))
|
return Data(digest.prefix(16))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
|
||||||
return computeId(packet).hexEncodedString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user