diff --git a/Config/Luma-Info.plist b/Config/Luma-Info.plist
index 0d31df7..55ccf24 100644
--- a/Config/Luma-Info.plist
+++ b/Config/Luma-Info.plist
@@ -24,6 +24,8 @@
$(CURRENT_PROJECT_VERSION)
NSCameraUsageDescription
Luma использует камеру для фотографий, видеосообщений и видеозвонков.
+ NSFaceIDUsageDescription
+ Luma использует Face ID для разблокировки приложения.
NSLocalNetworkUsageDescription
Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.
NSLocationWhenInUseUsageDescription
diff --git a/Config/LumaMac-Info.plist b/Config/LumaMac-Info.plist
index 8bc023e..3530fb8 100644
--- a/Config/LumaMac-Info.plist
+++ b/Config/LumaMac-Info.plist
@@ -26,6 +26,8 @@
public.app-category.social-networking
NSCameraUsageDescription
Luma использует камеру для фотографий, видеосообщений и видеозвонков.
+ NSFaceIDUsageDescription
+ Luma использует Touch ID для разблокировки приложения.
NSHighResolutionCapable
NSLocationUsageDescription
diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md
index 4be86c3..403d918 100644
--- a/Docs/SECURITY.md
+++ b/Docs/SECURITY.md
@@ -7,6 +7,9 @@
- TLS trust оценивается системным Apple Security framework как сертификат
сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка
доверия обязательны даже при ручном адресе подключения.
+- Приложение можно заблокировать паролем (хранится в Keychain, выключено по
+ умолчанию): блокировка срабатывает при запуске и при уходе в фон, а
+ разблокировка возможна по Face ID/Touch ID.
- Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с
серверной нормализацией; для PLAIN пароль отправляется как введён.
- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma),
@@ -14,11 +17,6 @@
- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем
`negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах
с поддержкой SecureTransport договаривается TLS 1.3.
-- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma),
- SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера.
-- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем
- `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах
- с поддержкой SecureTransport договаривается TLS 1.3.
- Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения
не откатываются на plaintext при ошибке: ошибка показывается пользователю.
- Пользователь может осознанно отключить OMEMO глобально или для отдельного
diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj
index 6f360c0..02632b8 100644
--- a/Luma.xcodeproj/project.pbxproj
+++ b/Luma.xcodeproj/project.pbxproj
@@ -29,6 +29,7 @@
127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; };
12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */; };
137816B417A865C97F935A00 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = EFCAF9B0DE67466AF22E76C3 /* WebRTC */; };
+ 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; };
1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; };
18606BEB0A2ABB7FDD995645 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; };
18627D523DEC6B1A3064906D /* AttachmentPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */; };
@@ -51,6 +52,7 @@
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; };
28D758624032D813C607DE5B /* MediaSendActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */; };
2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; };
+ 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; };
2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; };
2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; };
2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; };
@@ -87,6 +89,7 @@
4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; };
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */; };
4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; };
+ 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */; };
4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; };
4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; };
51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; };
@@ -96,6 +99,7 @@
5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
+ 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; };
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; };
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; };
@@ -132,6 +136,7 @@
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; };
7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; };
+ 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; };
814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2786A15415F65305F78D892C /* LocationPickerView.swift */; };
822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */; };
8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; };
@@ -143,6 +148,7 @@
8DE9617B5B0CBCA875C299F9 /* WatchVoiceRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */; };
902C6AD49443720ED4E4E43C /* ComposerRecordingGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */; };
9089C00D2A0766A75B1EB93D /* CryptoKitAESGCMEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */; };
+ 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; };
9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; };
939ABD4DED652588957491CF /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; };
94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */; };
@@ -181,6 +187,7 @@
BE4D5853DAC0D44A6132D800 /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; };
BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; };
C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */; };
+ C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; };
C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */; };
@@ -316,11 +323,13 @@
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = ""; };
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = ""; };
37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = ""; };
+ 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; };
3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = ""; };
3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = ""; };
3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = ""; };
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; };
43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; };
+ 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = ""; };
5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = ""; };
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; };
515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; };
@@ -339,6 +348,7 @@
6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplyFallback.swift; sourceTree = ""; };
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicy.swift; sourceTree = ""; };
6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; };
+ 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockVault.swift; sourceTree = ""; };
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaCallEngine.swift; sourceTree = ""; };
758B448979C845F56168A51F /* ConversationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRow.swift; sourceTree = ""; };
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTimeFormatterTests.swift; sourceTree = ""; };
@@ -415,6 +425,7 @@
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; };
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; };
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = ""; };
+ FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicyTests.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -464,6 +475,7 @@
isa = PBXGroup;
children = (
2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */,
+ 47680B7C615374A8C245818F /* AppLockPolicy.swift */,
2F36BB344D0FDF23127DF069 /* AppModel.swift */,
3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */,
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */,
@@ -547,6 +559,7 @@
isa = PBXGroup;
children = (
101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */,
+ FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */,
0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */,
ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */,
D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */,
@@ -590,6 +603,7 @@
50062B606E5AC6EF7E5AE47F /* Security */ = {
isa = PBXGroup;
children = (
+ 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */,
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */,
CCE381301A55B1C4F333B523 /* CredentialVault.swift */,
);
@@ -599,6 +613,7 @@
6BC9FCFF61F5366ED010F59A /* UI */ = {
isa = PBXGroup;
children = (
+ 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */,
78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */,
9B11FED2D58621C071C3AD14 /* CallView.swift */,
60A97545E6E481D45E0BB20F /* ChatView.swift */,
@@ -868,6 +883,9 @@
files = (
00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */,
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */,
+ 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */,
+ 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */,
+ 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */,
B492852FD89322434B0914AE /* AppModel.swift in Sources */,
F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */,
7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */,
@@ -977,6 +995,9 @@
files = (
D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */,
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */,
+ 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */,
+ C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */,
+ 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */,
21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */,
9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */,
AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */,
@@ -1074,6 +1095,7 @@
buildActionMask = 2147483647;
files = (
6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */,
+ 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */,
822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */,
F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */,
C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */,
diff --git a/Scripts/verify.sh b/Scripts/verify.sh
index e79f1e3..5507466 100755
--- a/Scripts/verify.sh
+++ b/Scripts/verify.sh
@@ -85,7 +85,11 @@ required=(
Tests/SaslFailureMessageTests.swift
Tests/MediaPreviewProcessorVideoTests.swift
Tests/SCRAMSHA512Tests.swift
+ Tests/AppLockPolicyTests.swift
Sources/Shared/XMPP/LumaScramSha512Mechanism.swift
+ Sources/Shared/Security/AppLockVault.swift
+ Sources/Shared/Models/AppLockPolicy.swift
+ Sources/Shared/UI/AppLockView.swift
Sources/Shared/XMPP/SASLprep.swift
Sources/Shared/XMPP/LumaSaslFailureModule.swift
Sources/Shared/XMPP/SaslFailureMessage.swift
@@ -331,6 +335,22 @@ grep -q 'addMechanism(LumaScramSha512Mechanism' Sources/Shared/XMPP/XMPPService.
echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms"
exit 1
}
+grep -q 'NSFaceIDUsageDescription' project.yml || {
+ echo "Biometric unlock must declare its usage description"
+ exit 1
+}
+grep -q 'unlockWithBiometrics' Sources/Shared/Models/AppModel.swift || {
+ echo "The app lock must support biometric unlock"
+ exit 1
+}
+grep -q 'AppLockView' Sources/Shared/UI/RootView.swift || {
+ echo "The lock screen must cover every app layer"
+ exit 1
+}
+grep -q 'Блокировка приложения' Sources/Shared/UI/SettingsView.swift || {
+ echo "Settings must offer the app lock toggle"
+ exit 1
+}
grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || {
echo "Group chats must support local deletion"
exit 1
diff --git a/Sources/Shared/Models/AppLockPolicy.swift b/Sources/Shared/Models/AppLockPolicy.swift
new file mode 100644
index 0000000..77ff377
--- /dev/null
+++ b/Sources/Shared/Models/AppLockPolicy.swift
@@ -0,0 +1,12 @@
+import Foundation
+
+/// Pure policy for the app-lock passcode: minimum length and validation.
+/// Kept separate from the Keychain storage so it is unit-testable.
+enum AppLockPolicy {
+ static let minimumLength = 4
+
+ static func isValid(_ passcode: String) -> Bool {
+ passcode.count >= minimumLength
+ }
+}
+
diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift
index 0190fd7..7003b79 100644
--- a/Sources/Shared/Models/AppModel.swift
+++ b/Sources/Shared/Models/AppModel.swift
@@ -1,6 +1,7 @@
import AVFoundation
import Combine
import Foundation
+import LocalAuthentication
import SwiftData
import UniformTypeIdentifiers
import WebRTC
@@ -56,6 +57,9 @@ final class AppModel: ObservableObject {
@Published var errorMessage: String?
@Published var informationalMessage: String?
@Published var previewURL: URL?
+ @Published private(set) var isAppLocked = false
+ @Published private(set) var appLockIsEnabled = false
+ @Published private(set) var appLockBiometricIsEnabled = false
@Published private(set) var mediaViewerItem: MediaViewerItem?
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
@@ -68,6 +72,7 @@ final class AppModel: ObservableObject {
private let xmpp: XMPPService
private let credentials: CredentialVault
private let preferences: AccountPreferences
+ private let appLock = AppLockVault()
private let notifications: NotificationCoordinator
private let watchBridge: PhoneWatchBridge
private let avatarCache: AvatarCache
@@ -129,6 +134,13 @@ final class AppModel: ObservableObject {
self.watchBridge = watchBridge
self.avatarCache = avatarCache
+ // The app lock must be initialised before any view reads it; the
+ // locked state persists only in memory and is re-applied from the
+ // stored preferences on launch.
+ appLockIsEnabled = appLock.isEnabled
+ appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
+ isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests
+
xmpp.eventHandler = { [weak self] event in
self?.consume(event)
}
@@ -454,12 +466,89 @@ final class AppModel: ObservableObject {
if !active {
resetTypingState()
syncWatch(immediate: true)
+ if appLockIsEnabled {
+ isAppLocked = true
+ }
}
if active, case .disconnected(_) = connectionStatus {
Task { await reconnect() }
}
}
+ // MARK: - App lock
+
+ func unlockWithPasscode(_ passcode: String) -> Bool {
+ guard appLock.verify(passcode: passcode) else { return false }
+ isAppLocked = false
+ return true
+ }
+
+ func unlockWithBiometrics() async -> Bool {
+ guard appLockIsEnabled, appLockBiometricIsEnabled else { return false }
+ let context = LAContext()
+ var error: NSError?
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
+ else {
+ return false
+ }
+ do {
+ let granted = try await context.evaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ localizedReason: "Разблокируйте Luma"
+ )
+ guard granted else { return false }
+ isAppLocked = false
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ func enableAppLock(passcode: String) -> Bool {
+ guard AppLockPolicy.isValid(passcode) else { return false }
+ do {
+ try appLock.save(passcode: passcode)
+ appLock.isEnabled = true
+ appLockIsEnabled = true
+ isAppLocked = false
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func disableAppLock(passcode: String) -> Bool {
+ guard appLock.verify(passcode: passcode) else { return false }
+ try? appLock.deletePasscode()
+ appLock.isEnabled = false
+ appLock.biometricUnlockEnabled = false
+ appLockIsEnabled = false
+ appLockBiometricIsEnabled = false
+ isAppLocked = false
+ return true
+ }
+
+ func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool {
+ guard appLock.verify(passcode: passcode) else { return false }
+ appLock.biometricUnlockEnabled = enabled
+ appLockBiometricIsEnabled = enabled
+ return true
+ }
+
+ func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool {
+ guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else {
+ return false
+ }
+ do {
+ try appLock.save(passcode: newPasscode)
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
func setVideoNoteCaptureActive(_ active: Bool) {
videoNoteCaptureIsActive = active
updateArchiveSyncSuspensionForMedia()
diff --git a/Sources/Shared/Security/AppLockVault.swift b/Sources/Shared/Security/AppLockVault.swift
new file mode 100644
index 0000000..3a7ad1f
--- /dev/null
+++ b/Sources/Shared/Security/AppLockVault.swift
@@ -0,0 +1,103 @@
+import Foundation
+import Security
+
+/// Stores the app-lock passcode in the Keychain and the lock preferences in
+/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in
+/// the Keychain and is never mirrored into memory beyond verification.
+final class AppLockVault {
+ private let service = "app.luma.chat.applock"
+ private let account = "applock-passcode"
+ private let enabledKey = "appLockEnabled"
+ private let biometricKey = "appLockBiometricUnlock"
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ var isEnabled: Bool {
+ get { defaults.bool(forKey: enabledKey) }
+ set { defaults.set(newValue, forKey: enabledKey) }
+ }
+
+ var biometricUnlockEnabled: Bool {
+ get { defaults.bool(forKey: biometricKey) }
+ set { defaults.set(newValue, forKey: biometricKey) }
+ }
+
+ func save(passcode: String) throws {
+ let data = Data(passcode.utf8)
+ let baseQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ ]
+ let updateStatus = SecItemUpdate(
+ baseQuery as CFDictionary,
+ [kSecValueData: data] as CFDictionary
+ )
+ if updateStatus == errSecSuccess {
+ return
+ }
+ guard updateStatus == errSecItemNotFound else {
+ throw AppLockVaultError.unhandled(updateStatus)
+ }
+ var insertion = baseQuery
+ insertion[kSecValueData] = data
+#if !os(macOS)
+ insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+#endif
+ let addStatus = SecItemAdd(insertion as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw AppLockVaultError.unhandled(addStatus)
+ }
+ }
+
+ func deletePasscode() throws {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ ]
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw AppLockVaultError.unhandled(status)
+ }
+ }
+
+ func verify(passcode: String) -> Bool {
+ guard let stored = storedPasscode() else { return false }
+ return passcode == stored
+ }
+
+ private func storedPasscode() -> String? {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ guard status == errSecSuccess,
+ let data = item as? Data,
+ let value = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return value
+ }
+}
+
+enum AppLockVaultError: LocalizedError {
+ case unhandled(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .unhandled(let status):
+ let message = SecCopyErrorMessageString(status, nil) as String?
+ return message ?? "Не удалось обратиться к Keychain (\(status))."
+ }
+ }
+}
+
diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift
new file mode 100644
index 0000000..fc7a735
--- /dev/null
+++ b/Sources/Shared/UI/AppLockView.swift
@@ -0,0 +1,314 @@
+import LocalAuthentication
+import SwiftUI
+
+@MainActor
+struct AppLockView: View {
+ @ObservedObject var model: AppModel
+ @State private var passcode = ""
+ @State private var attemptFailed = false
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ ZStack {
+ LinearGradient(
+ colors: [
+ Color(red: 0.08, green: 0.38, blue: 0.78),
+ Color(red: 0.12, green: 0.62, blue: 0.96),
+ Color(red: 0.44, green: 0.84, blue: 0.96),
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ .ignoresSafeArea()
+
+ VStack(spacing: 22) {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 44, weight: .semibold))
+ .foregroundStyle(.white)
+
+ Text("Luma заблокирован")
+ .font(.title2.weight(.bold))
+ .foregroundStyle(.white)
+
+ SecureField("Пароль", text: $passcode)
+ .focused($isFocused)
+ .textContentType(.password)
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ #endif
+ .autocorrectionDisabled()
+ .textFieldStyle(.roundedBorder)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 260)
+ .onSubmit(submit)
+
+ if attemptFailed {
+ Text("Неверный пароль")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.white.opacity(0.95))
+ }
+
+ Button(action: submit) {
+ Text("Разблокировать")
+ .fontWeight(.semibold)
+ .frame(maxWidth: 260)
+ .frame(height: 34)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(passcode.isEmpty)
+
+ if biometricAvailable, model.appLockBiometricIsEnabled {
+ Button {
+ Task { await biometricUnlock() }
+ } label: {
+ Label("Войти по \(biometricName)", systemImage: biometricIcon)
+ .foregroundStyle(.white)
+ }
+ }
+ }
+ .padding(28)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
+ .padding(24)
+ }
+ .onAppear {
+ isFocused = true
+ }
+ .task {
+ // Auto-prompt biometrics once when the lock screen appears.
+ guard biometricAvailable, model.appLockBiometricIsEnabled else { return }
+ await biometricUnlock()
+ }
+ }
+
+ private var biometricContext: LAContext {
+ LAContext()
+ }
+
+ private var biometricAvailable: Bool {
+ var error: NSError?
+ return biometricContext.canEvaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ error: &error
+ )
+ }
+
+ private var biometryType: LABiometryType {
+ biometricContext.biometryType
+ }
+
+ private var biometricIcon: String {
+ biometryType == .faceID ? "faceid" : "touchid"
+ }
+
+ private var biometricName: String {
+ biometryType == .faceID ? "Face ID" : "Touch ID"
+ }
+
+ private func submit() {
+ guard !passcode.isEmpty else { return }
+ if model.unlockWithPasscode(passcode) {
+ attemptFailed = false
+ passcode = ""
+ } else {
+ attemptFailed = true
+ passcode = ""
+ }
+ }
+
+ private func biometricUnlock() async {
+ _ = await model.unlockWithBiometrics()
+ }
+}
+
+/// Configurable passcode sheet: setting up a new passcode, changing it, or
+/// verifying the current one to disable the lock / toggle biometrics.
+@MainActor
+struct AppLockPasscodeSheet: View {
+ enum Mode: Identifiable {
+ case setup
+ case change
+ case verify(VerificationAction)
+
+ enum VerificationAction: String {
+ case disableLock
+ case enableBiometrics
+ case disableBiometrics
+ }
+
+ var id: String {
+ switch self {
+ case .setup: return "setup"
+ case .change: return "change"
+ case .verify(let action): return "verify-\(action.rawValue)"
+ }
+ }
+ }
+
+ let model: AppModel
+ let mode: Mode
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var oldPasscode = ""
+ @State private var passcode = ""
+ @State private var repeatedPasscode = ""
+ @State private var errorText: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ if needsOldPasscode {
+ Section("Текущий пароль") {
+ SecureField("Текущий пароль", text: $oldPasscode)
+ .textContentType(.password)
+ }
+ }
+ Section(title) {
+ SecureField(fieldLabel, text: $passcode)
+ .textContentType(.password)
+ if needsRepeatedPasscode {
+ SecureField("Повторите пароль", text: $repeatedPasscode)
+ .textContentType(.password)
+ }
+ }
+ if let errorText {
+ Section {
+ Text(errorText)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle(navigationTitle)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Отмена") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Готово", action: submit)
+ .disabled(!canSubmit)
+ }
+ }
+ }
+#if os(macOS)
+ .frame(minWidth: 420, minHeight: 320)
+#endif
+ }
+
+ private var needsOldPasscode: Bool {
+ switch mode {
+ case .change, .verify:
+ return true
+ case .setup:
+ return false
+ }
+ }
+
+ private var needsRepeatedPasscode: Bool {
+ switch mode {
+ case .setup, .change:
+ return true
+ case .verify:
+ return false
+ }
+ }
+
+ private var title: String {
+ switch mode {
+ case .setup: return "Новый пароль"
+ case .change: return "Новый пароль"
+ case .verify: return "Пароль"
+ }
+ }
+
+ private var navigationTitle: String {
+ switch mode {
+ case .setup: return "Включить блокировку"
+ case .change: return "Сменить пароль"
+ case .verify(let action):
+ switch action {
+ case .disableLock: return "Выключить блокировку"
+ case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль"
+ }
+ }
+ }
+
+ private var fieldLabel: String {
+ switch mode {
+ case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
+ case .verify: return "Пароль"
+ }
+ }
+
+ private var canSubmit: Bool {
+ switch mode {
+ case .setup:
+ return !passcode.isEmpty && !repeatedPasscode.isEmpty
+ case .change:
+ return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty
+ case .verify:
+ return !oldPasscode.isEmpty
+ }
+ }
+
+ private func submit() {
+ errorText = nil
+ switch mode {
+ case .setup:
+ guard AppLockPolicy.isValid(passcode) else {
+ errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
+ return
+ }
+ guard passcode == repeatedPasscode else {
+ errorText = "Пароли не совпадают."
+ repeatedPasscode = ""
+ return
+ }
+ if model.enableAppLock(passcode: passcode) {
+ dismiss()
+ } else {
+ errorText = model.errorMessage ?? "Не удалось включить блокировку."
+ }
+ case .change:
+ guard AppLockPolicy.isValid(passcode) else {
+ errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
+ return
+ }
+ guard passcode == repeatedPasscode else {
+ errorText = "Пароли не совпадают."
+ repeatedPasscode = ""
+ return
+ }
+ if model.changeAppLockPasscode(from: oldPasscode, to: passcode) {
+ dismiss()
+ } else {
+ errorText = "Неверный текущий пароль."
+ oldPasscode = ""
+ }
+ case .verify(let action):
+ switch action {
+ case .disableLock:
+ if model.disableAppLock(passcode: oldPasscode) {
+ dismiss()
+ } else {
+ errorText = "Неверный пароль."
+ oldPasscode = ""
+ }
+ case .enableBiometrics:
+ if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) {
+ dismiss()
+ } else {
+ errorText = "Неверный пароль."
+ oldPasscode = ""
+ }
+ case .disableBiometrics:
+ if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) {
+ dismiss()
+ } else {
+ errorText = "Неверный пароль."
+ oldPasscode = ""
+ }
+ }
+ }
+ }
+}
+
diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift
index 66fc817..a239c22 100644
--- a/Sources/Shared/UI/RootView.swift
+++ b/Sources/Shared/UI/RootView.swift
@@ -7,25 +7,31 @@ struct RootView: View {
var body: some View {
ZStack {
- Group {
- if model.account == nil {
- LoginView(model: model)
- } else {
- MainChatView(model: model)
- .environment(\.modelContext, model.modelContext)
+ if model.isAppLocked {
+ // The lock covers every layer: chat list, media viewer and
+ // calls stay hidden until the passcode or biometrics succeed.
+ AppLockView(model: model)
+ } else {
+ Group {
+ if model.account == nil {
+ LoginView(model: model)
+ } else {
+ MainChatView(model: model)
+ .environment(\.modelContext, model.modelContext)
+ }
}
- }
- if let item = model.mediaViewerItem {
- MediaViewer(item: item, onClose: model.closeMediaViewer)
- .transition(.opacity.combined(with: .scale(scale: 0.98)))
- .zIndex(100)
- }
+ if let item = model.mediaViewerItem {
+ MediaViewer(item: item, onClose: model.closeMediaViewer)
+ .transition(.opacity.combined(with: .scale(scale: 0.98)))
+ .zIndex(100)
+ }
- if let call = model.activeCall {
- CallView(model: model, call: call)
- .transition(.opacity.combined(with: .scale(scale: 1.015)))
- .zIndex(200)
+ if let call = model.activeCall {
+ CallView(model: model, call: call)
+ .transition(.opacity.combined(with: .scale(scale: 1.015)))
+ .zIndex(200)
+ }
}
}
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift
index 962d654..16c3e69 100644
--- a/Sources/Shared/UI/SettingsView.swift
+++ b/Sources/Shared/UI/SettingsView.swift
@@ -1,3 +1,4 @@
+import LocalAuthentication
import PhotosUI
import SwiftUI
@@ -7,6 +8,7 @@ struct SettingsView: View {
@State private var showingSignOutConfirmation = false
@State private var forgetHistory = false
@State private var avatarItem: PhotosPickerItem?
+ @State private var passcodeSheetMode: AppLockPasscodeSheet.Mode?
var body: some View {
NavigationStack {
@@ -98,6 +100,36 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
+ Section("Блокировка приложения") {
+ Toggle("Заблокировать приложение", isOn: Binding(
+ get: { model.appLockIsEnabled },
+ set: { enabled in
+ if enabled {
+ passcodeSheetMode = .setup
+ } else {
+ passcodeSheetMode = .verify(.disableLock)
+ }
+ }
+ ))
+ if model.appLockIsEnabled {
+ Toggle("Вход по \(biometricName)", isOn: Binding(
+ get: { model.appLockBiometricIsEnabled },
+ set: { enabled in
+ passcodeSheetMode = .verify(
+ enabled ? .enableBiometrics : .disableBiometrics
+ )
+ }
+ ))
+ .disabled(!biometricAvailable)
+ Button("Сменить пароль") {
+ passcodeSheetMode = .change
+ }
+ }
+ Text("При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
Section("Приватность чата") {
Toggle("Показывать статус набора", isOn: Binding(
get: { model.typingIndicatorsEnabled },
@@ -153,12 +185,31 @@ struct SettingsView: View {
}
}
}
+ .sheet(item: $passcodeSheetMode) { mode in
+ AppLockPasscodeSheet(model: model, mode: mode)
+ }
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 560)
#endif
}
+ private var biometricContext: LAContext {
+ LAContext()
+ }
+
+ private var biometricAvailable: Bool {
+ var error: NSError?
+ return biometricContext.canEvaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ error: &error
+ )
+ }
+
+ private var biometricName: String {
+ biometricContext.biometryType == .faceID ? "Face ID" : "Touch ID"
+ }
+
private var connectionTitle: String {
switch model.connectionStatus {
case .connected: return "Подключено"
diff --git a/Tests/AppLockPolicyTests.swift b/Tests/AppLockPolicyTests.swift
new file mode 100644
index 0000000..39d7ed3
--- /dev/null
+++ b/Tests/AppLockPolicyTests.swift
@@ -0,0 +1,21 @@
+import XCTest
+@testable import Luma
+
+final class AppLockPolicyTests: XCTestCase {
+ func testMinimumLengthIsFour() {
+ XCTAssertEqual(AppLockPolicy.minimumLength, 4)
+ }
+
+ func testValidationRejectsShortPasscodes() {
+ XCTAssertFalse(AppLockPolicy.isValid(""))
+ XCTAssertFalse(AppLockPolicy.isValid("1"))
+ XCTAssertFalse(AppLockPolicy.isValid("123"))
+ }
+
+ func testValidationAcceptsFourOrMoreCharacters() {
+ XCTAssertTrue(AppLockPolicy.isValid("1234"))
+ XCTAssertTrue(AppLockPolicy.isValid("пароль"))
+ XCTAssertTrue(AppLockPolicy.isValid("long-passcode"))
+ }
+}
+
diff --git a/project.yml b/project.yml
index a192300..e1a1cae 100644
--- a/project.yml
+++ b/project.yml
@@ -72,6 +72,7 @@ targets:
NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков.
NSLocalNetworkUsageDescription: Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.
NSLocationWhenInUseUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате.
+ NSFaceIDUsageDescription: Luma использует Face ID для разблокировки приложения.
entitlements:
path: Config/Luma.entitlements
properties: {}
@@ -114,6 +115,7 @@ targets:
NSCameraUsageDescription: Luma использует камеру для фотографий, видеосообщений и видеозвонков.
NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков.
NSLocationUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате.
+ NSFaceIDUsageDescription: Luma использует Touch ID для разблокировки приложения.
entitlements:
path: Config/LumaMac.entitlements
properties: