Compare commits
22 Commits
4fead7342c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29dc16413e | |||
| 1bd2d08677 | |||
| a525fe60dd | |||
| 334776da5a | |||
| 556aa527c8 | |||
| 1aebf20019 | |||
| 832e69bfd9 | |||
| fdb40e83f6 | |||
| 53eab47a08 | |||
| 5b13659d9d | |||
| aebc947df7 | |||
| d05f4615a3 | |||
| 1d150acfda | |||
| 40e840ee50 | |||
| 2346ca1b49 | |||
| 857db7501d | |||
| 7019c06994 | |||
| efb18ae420 | |||
| 5abbc8f081 | |||
| a1f0774bb6 | |||
| 77ceb617dd | |||
| 5188619eee |
@@ -6,8 +6,19 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
47D771C32FC2141600C4C002 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 479E819F2DD09F9400B82386 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 479E81A62DD09F9400B82386;
|
||||
remoteInfo = Peered;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
479E81A72DD09F9400B82386 /* Peered.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Peered.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
47D771BF2FC2141600C4C002 /* PeeredTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PeeredTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -29,6 +40,11 @@
|
||||
path = Peered;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
47D771C02FC2141600C4C002 /* PeeredTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PeeredTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -39,6 +55,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
47D771BC2FC2141600C4C002 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -46,6 +69,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
479E81A92DD09F9400B82386 /* Peered */,
|
||||
47D771C02FC2141600C4C002 /* PeeredTests */,
|
||||
479E81A82DD09F9400B82386 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -54,6 +78,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
479E81A72DD09F9400B82386 /* Peered.app */,
|
||||
47D771BF2FC2141600C4C002 /* PeeredTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -83,6 +108,29 @@
|
||||
productReference = 479E81A72DD09F9400B82386 /* Peered.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
47D771BE2FC2141600C4C002 /* PeeredTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 47D771C52FC2141600C4C002 /* Build configuration list for PBXNativeTarget "PeeredTests" */;
|
||||
buildPhases = (
|
||||
47D771BB2FC2141600C4C002 /* Sources */,
|
||||
47D771BC2FC2141600C4C002 /* Frameworks */,
|
||||
47D771BD2FC2141600C4C002 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
47D771C42FC2141600C4C002 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
47D771C02FC2141600C4C002 /* PeeredTests */,
|
||||
);
|
||||
name = PeeredTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PeeredTests;
|
||||
productReference = 47D771BF2FC2141600C4C002 /* PeeredTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -90,12 +138,16 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1630;
|
||||
LastSwiftUpdateCheck = 2650;
|
||||
LastUpgradeCheck = 1640;
|
||||
TargetAttributes = {
|
||||
479E81A62DD09F9400B82386 = {
|
||||
CreatedOnToolsVersion = 16.3;
|
||||
};
|
||||
47D771BE2FC2141600C4C002 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
TestTargetID = 479E81A62DD09F9400B82386;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 479E81A22DD09F9400B82386 /* Build configuration list for PBXProject "Peered" */;
|
||||
@@ -113,6 +165,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
479E81A62DD09F9400B82386 /* Peered */,
|
||||
47D771BE2FC2141600C4C002 /* PeeredTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -125,6 +178,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
47D771BD2FC2141600C4C002 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -135,8 +195,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
47D771BB2FC2141600C4C002 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
47D771C42FC2141600C4C002 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 479E81A62DD09F9400B82386 /* Peered */;
|
||||
targetProxy = 47D771C32FC2141600C4C002 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
479E81C72DD09F9500B82386 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -364,6 +439,58 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
47D771C62FC2141600C4C002 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = LTFJ368N25;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.oschly.PeeredTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Peered.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Peered";
|
||||
XROS_DEPLOYMENT_TARGET = 26.5;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
47D771C72FC2141600C4C002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = LTFJ368N25;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.oschly.PeeredTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Peered.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Peered";
|
||||
XROS_DEPLOYMENT_TARGET = 26.5;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -385,6 +512,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
47D771C52FC2141600C4C002 /* Build configuration list for PBXNativeTarget "PeeredTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
47D771C62FC2141600C4C002 /* Debug */,
|
||||
47D771C72FC2141600C4C002 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 479E819F2DD09F9400B82386 /* Project object */;
|
||||
|
||||
@@ -8,70 +8,70 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AllNotesScreen: View {
|
||||
@AppStorage("peered_username") private var username: String?
|
||||
@State private var notes = [Note]()
|
||||
@State private var notesClient: NoteEditingSessionClient?
|
||||
@State private var ownPeer: OwnPeer?
|
||||
|
||||
var isUsernameValid: Bool {
|
||||
!(username.map(\.isEmpty) ?? true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Your notes") {
|
||||
ForEach(notes) { note in
|
||||
NavigationLink(note.name) {
|
||||
if let ownPeer {
|
||||
NoteEditorScreen(note: note, peer: ownPeer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let notesClient {
|
||||
Section("External notes") {
|
||||
ForEach(notesClient.invitations) { invitation in
|
||||
NavigationLink(invitation.noteName) {
|
||||
SharedNoteEditor(invitation: invitation, noteClient: notesClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Peered")
|
||||
.toolbar {
|
||||
Button("Create note") {
|
||||
NotesStorage().createNote(name: "New Note")
|
||||
notes = NotesStorage().loadNotes()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
notes = NotesStorage().loadNotes()
|
||||
if let username, isUsernameValid {
|
||||
setupSession(username: username)
|
||||
}
|
||||
}
|
||||
.onChange(of: username) { _, newUsername in
|
||||
guard let newUsername, isUsernameValid else { return }
|
||||
setupSession(username: newUsername)
|
||||
}
|
||||
.sheet(isPresented: .constant(!isUsernameValid)) {
|
||||
SetUserNameBottomSheetView(username: $username)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSession(username: String) {
|
||||
notesClient?.stopBrowsingForNotes()
|
||||
let peer = OwnPeer(peer: .init(displayName: username))
|
||||
ownPeer = peer
|
||||
notesClient = .init(peer: peer.peer)
|
||||
notesClient?.startBrowsingForNotes()
|
||||
}
|
||||
@AppStorage("peered_username") private var username: String?
|
||||
@State private var notes = [Note]()
|
||||
@State private var notesClient: NoteEditingSessionClient?
|
||||
@State private var ownPeer: OwnPeer?
|
||||
|
||||
var isUsernameValid: Bool {
|
||||
!(username.map(\.isEmpty) ?? true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Your notes") {
|
||||
ForEach(notes) { note in
|
||||
NavigationLink(note.name) {
|
||||
if let ownPeer {
|
||||
NoteEditorScreen(note: note, peer: ownPeer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let notesClient {
|
||||
Section("External notes") {
|
||||
ForEach(notesClient.invitations) { invitation in
|
||||
NavigationLink(invitation.noteName) {
|
||||
SharedNoteEditor(invitation: invitation, noteClient: notesClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Peered")
|
||||
.toolbar {
|
||||
Button("Create note") {
|
||||
NotesStorage().createNote(name: "New Note")
|
||||
notes = NotesStorage().loadNotes()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
notes = NotesStorage().loadNotes()
|
||||
if let username, isUsernameValid {
|
||||
setupSession(username: username)
|
||||
}
|
||||
}
|
||||
.onChange(of: username) { _, newUsername in
|
||||
guard let newUsername, isUsernameValid else { return }
|
||||
setupSession(username: newUsername)
|
||||
}
|
||||
.sheet(isPresented: .constant(!isUsernameValid)) {
|
||||
SetUserNameBottomSheetView(username: $username)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSession(username: String) {
|
||||
notesClient?.stopBrowsingForNotes()
|
||||
let peer = OwnPeer(peer: .init(displayName: username))
|
||||
ownPeer = peer
|
||||
notesClient = .init(peer: peer.peer)
|
||||
notesClient?.startBrowsingForNotes()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AllNotesScreen()
|
||||
AllNotesScreen()
|
||||
}
|
||||
|
||||
@@ -7,22 +7,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ManageMembersScreen: View {
|
||||
@Bindable var noteAdvertiser: NoteEditingSessionServer
|
||||
let noteTitle: String
|
||||
@Binding var noteContent: String
|
||||
|
||||
var body: some View {
|
||||
List(noteAdvertiser.visiblePeers) { peer in
|
||||
HStack {
|
||||
Text(peer.id)
|
||||
|
||||
Spacer()
|
||||
|
||||
PeerStateButton(peerState: peer.state) {
|
||||
noteAdvertiser.invite(peer: peer, to: .init(title: noteTitle, noteSnapshot: noteContent))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Visible users")
|
||||
}
|
||||
@Bindable var noteAdvertiser: NoteEditingSessionServer
|
||||
let noteTitle: String
|
||||
@Binding var noteContent: String
|
||||
|
||||
var body: some View {
|
||||
List(noteAdvertiser.visiblePeers) { peer in
|
||||
HStack {
|
||||
Text(peer.id)
|
||||
|
||||
Spacer()
|
||||
|
||||
PeerStateButton(peerState: peer.state) {
|
||||
noteAdvertiser.invite(peer: peer, to: .init(title: noteTitle, noteSnapshot: noteContent))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Visible users")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +1,142 @@
|
||||
import MultipeerConnectivity
|
||||
import Foundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import MultipeerConnectivity
|
||||
|
||||
struct OwnPeer {
|
||||
let peer: MCPeerID
|
||||
let peer: MCPeerID
|
||||
}
|
||||
|
||||
struct Peer: Identifiable {
|
||||
enum ConnectionState {
|
||||
case available
|
||||
case joined
|
||||
case rejected
|
||||
case invitationPending
|
||||
}
|
||||
|
||||
var id: String { mcPeer.displayName }
|
||||
let mcPeer: MCPeerID
|
||||
var state: ConnectionState
|
||||
enum ConnectionState {
|
||||
case available
|
||||
case joined
|
||||
case rejected
|
||||
case invitationPending
|
||||
}
|
||||
|
||||
var id: String { mcPeer.displayName }
|
||||
let mcPeer: MCPeerID
|
||||
var state: ConnectionState
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class NoteEditingSessionServer: NSObject {
|
||||
private let session: MCSession
|
||||
private let browser: MCNearbyServiceBrowser
|
||||
private(set) var ownPeer: OwnPeer
|
||||
|
||||
var visiblePeers: [Peer] = []
|
||||
let noteChangesEmitter = PassthroughSubject<NoteMessage, Never>()
|
||||
|
||||
init(peer: OwnPeer) {
|
||||
ownPeer = peer
|
||||
browser = .init(peer: peer.peer, serviceType: "peered")
|
||||
session = .init(peer: peer.peer, securityIdentity: nil, encryptionPreference: .required)
|
||||
super.init()
|
||||
browser.delegate = self
|
||||
session.delegate = self
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
browser.startBrowsingForPeers()
|
||||
}
|
||||
|
||||
func stopServer() {
|
||||
browser.stopBrowsingForPeers()
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
func invite(peer: Peer, to note: NoteInvitation.NoteContent) {
|
||||
guard peer.state == .available, let note = try? JSONEncoder().encode(note) else { return }
|
||||
browser.invitePeer(
|
||||
peer.mcPeer,
|
||||
to: session,
|
||||
withContext: note,
|
||||
timeout: 600
|
||||
)
|
||||
guard let idxToUpdate = visiblePeers.firstIndex(where: { $0.mcPeer == peer.mcPeer }) else { return }
|
||||
visiblePeers[idxToUpdate].state = .invitationPending
|
||||
}
|
||||
|
||||
func send(note: String, to peers: [MCPeerID]) {
|
||||
let message = NoteMessage(senderID: ownPeer.peer.displayName, content: note)
|
||||
guard !peers.isEmpty, let data = try? JSONEncoder().encode(message) else { return }
|
||||
try? session.send(data, toPeers: peers, with: .reliable)
|
||||
}
|
||||
private let session: MCSession
|
||||
private let browser: MCNearbyServiceBrowser
|
||||
private(set) var ownPeer: OwnPeer
|
||||
|
||||
var visiblePeers: [Peer] = []
|
||||
let noteChangesEmitter = PassthroughSubject<NoteMessage, Never>()
|
||||
|
||||
init(peer: OwnPeer) {
|
||||
ownPeer = peer
|
||||
browser = .init(peer: peer.peer, serviceType: "peered")
|
||||
session = .init(peer: peer.peer, securityIdentity: nil, encryptionPreference: .required)
|
||||
super.init()
|
||||
browser.delegate = self
|
||||
session.delegate = self
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
browser.startBrowsingForPeers()
|
||||
}
|
||||
|
||||
func stopServer() {
|
||||
browser.stopBrowsingForPeers()
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
func invite(peer: Peer, to note: NoteInvitation.NoteContent) {
|
||||
guard peer.state == .available, let note = try? JSONEncoder().encode(note) else { return }
|
||||
browser.invitePeer(
|
||||
peer.mcPeer,
|
||||
to: session,
|
||||
withContext: note,
|
||||
timeout: 600
|
||||
)
|
||||
guard let idxToUpdate = visiblePeers.firstIndex(where: { $0.mcPeer == peer.mcPeer }) else {
|
||||
return
|
||||
}
|
||||
visiblePeers[idxToUpdate].state = .invitationPending
|
||||
}
|
||||
|
||||
func send(note: String, to peers: [MCPeerID]) {
|
||||
let message = NoteMessage(senderID: ownPeer.peer.displayName, content: note)
|
||||
guard !peers.isEmpty, let data = try? JSONEncoder().encode(message) else { return }
|
||||
try? session.send(data, toPeers: peers, with: .reliable)
|
||||
}
|
||||
}
|
||||
|
||||
extension NoteEditingSessionServer: MCNearbyServiceBrowserDelegate {
|
||||
func browser(
|
||||
_ browser: MCNearbyServiceBrowser,
|
||||
foundPeer peerID: MCPeerID,
|
||||
withDiscoveryInfo info: [String: String]?
|
||||
) {
|
||||
guard !visiblePeers.contains(where: { $0.mcPeer == peerID }) && peerID.displayName != ownPeer.peer.displayName else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.visiblePeers.append(Peer(mcPeer: peerID, state: .available))
|
||||
}
|
||||
}
|
||||
|
||||
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
|
||||
DispatchQueue.main.async {
|
||||
guard let peerIdx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
self.visiblePeers.remove(at: peerIdx)
|
||||
}
|
||||
}
|
||||
func browser(
|
||||
_ browser: MCNearbyServiceBrowser,
|
||||
foundPeer peerID: MCPeerID,
|
||||
withDiscoveryInfo info: [String: String]?
|
||||
) {
|
||||
guard
|
||||
!visiblePeers.contains(where: { $0.mcPeer == peerID })
|
||||
&& peerID.displayName != ownPeer.peer.displayName
|
||||
else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.visiblePeers.append(Peer(mcPeer: peerID, state: .available))
|
||||
}
|
||||
}
|
||||
|
||||
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
|
||||
DispatchQueue.main.async {
|
||||
guard let peerIdx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else {
|
||||
return
|
||||
}
|
||||
self.visiblePeers.remove(at: peerIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NoteEditingSessionServer: MCSessionDelegate {
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
peer peerID: MCPeerID,
|
||||
didChange state: MCSessionState
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
guard let idx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
switch state {
|
||||
case .connected:
|
||||
self.visiblePeers[idx].state = .joined
|
||||
case .notConnected:
|
||||
let currentState = self.visiblePeers[idx].state
|
||||
if currentState == .invitationPending || currentState == .joined {
|
||||
self.visiblePeers[idx].state = .rejected
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
|
||||
guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return }
|
||||
|
||||
let otherPeers = session.connectedPeers.filter { $0 != peerID }
|
||||
if !otherPeers.isEmpty {
|
||||
try? session.send(data, toPeers: otherPeers, with: .reliable)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.noteChangesEmitter.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {}
|
||||
func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {}
|
||||
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) {}
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
peer peerID: MCPeerID,
|
||||
didChange state: MCSessionState
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
guard let idx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
switch state {
|
||||
case .connected:
|
||||
self.visiblePeers[idx].state = .joined
|
||||
case .notConnected:
|
||||
let currentState = self.visiblePeers[idx].state
|
||||
if currentState == .invitationPending || currentState == .joined {
|
||||
self.visiblePeers[idx].state = .rejected
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
|
||||
guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return }
|
||||
|
||||
let otherPeers = session.connectedPeers.filter { $0 != peerID }
|
||||
if !otherPeers.isEmpty {
|
||||
try? session.send(data, toPeers: otherPeers, with: .reliable)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.noteChangesEmitter.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: MCSession, didReceive stream: InputStream, withName streamName: String,
|
||||
fromPeer peerID: MCPeerID
|
||||
) {}
|
||||
func session(
|
||||
_ session: MCSession, didStartReceivingResourceWithName resourceName: String,
|
||||
fromPeer peerID: MCPeerID, with progress: Progress
|
||||
) {}
|
||||
func session(
|
||||
_ session: MCSession, didFinishReceivingResourceWithName resourceName: String,
|
||||
fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -5,69 +5,69 @@
|
||||
// Created by Oskar Chybowski on 25/09/2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MultipeerConnectivity
|
||||
import SwiftUI
|
||||
|
||||
struct NoteEditorScreen: View {
|
||||
let note: Note
|
||||
@State private var noteAdvertiser: NoteEditingSessionServer
|
||||
@State private var noteContent: String = ""
|
||||
@State private var remoteNoteContent: String? = nil
|
||||
@State private var timer = Timer.publish(every: 5, on: .current, in: .common)
|
||||
.autoconnect()
|
||||
@State private var showManageMembers = false
|
||||
let note: Note
|
||||
@State private var noteAdvertiser: NoteEditingSessionServer
|
||||
@State private var noteContent: String = ""
|
||||
@State private var remoteNoteContent: String? = nil
|
||||
@State private var timer = Timer.publish(every: 5, on: .current, in: .common)
|
||||
.autoconnect()
|
||||
@State private var showManageMembers = false
|
||||
|
||||
init(note: Note, peer: OwnPeer) {
|
||||
self.note = note
|
||||
self._noteAdvertiser = .init(initialValue: .init(peer: peer))
|
||||
}
|
||||
init(note: Note, peer: OwnPeer) {
|
||||
self.note = note
|
||||
self._noteAdvertiser = .init(initialValue: .init(peer: peer))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NoteTextEditor(text: $noteContent, remoteText: remoteNoteContent)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Manage members") {
|
||||
showManageMembers = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showManageMembers) {
|
||||
NavigationStack {
|
||||
ManageMembersScreen(
|
||||
noteAdvertiser: noteAdvertiser,
|
||||
noteTitle: note.name,
|
||||
noteContent: $noteContent
|
||||
)
|
||||
}
|
||||
}
|
||||
.onReceive(noteAdvertiser.noteChangesEmitter) { message in
|
||||
guard message.senderID != noteAdvertiser.ownPeer.peer.displayName else { return }
|
||||
remoteNoteContent = message.content
|
||||
}
|
||||
.task(id: noteContent) {
|
||||
if noteContent == remoteNoteContent { return }
|
||||
let connectedPeers = noteAdvertiser.visiblePeers
|
||||
.filter { $0.state == .joined }
|
||||
.map(\.mcPeer)
|
||||
guard !connectedPeers.isEmpty else { return }
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
noteAdvertiser.send(note: noteContent, to: connectedPeers)
|
||||
}
|
||||
.onAppear {
|
||||
noteContent = (try? String(contentsOf: note.path, encoding: .utf8)) ?? ""
|
||||
noteAdvertiser.startServer()
|
||||
}
|
||||
.onDisappear {
|
||||
noteAdvertiser.stopServer()
|
||||
saveNote()
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
saveNote()
|
||||
}
|
||||
}
|
||||
var body: some View {
|
||||
NoteTextEditor(text: $noteContent, remoteText: remoteNoteContent)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Manage members") {
|
||||
showManageMembers = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showManageMembers) {
|
||||
NavigationStack {
|
||||
ManageMembersScreen(
|
||||
noteAdvertiser: noteAdvertiser,
|
||||
noteTitle: note.name,
|
||||
noteContent: $noteContent
|
||||
)
|
||||
}
|
||||
}
|
||||
.onReceive(noteAdvertiser.noteChangesEmitter) { message in
|
||||
guard message.senderID != noteAdvertiser.ownPeer.peer.displayName else { return }
|
||||
remoteNoteContent = message.content
|
||||
}
|
||||
.task(id: noteContent) {
|
||||
if noteContent == remoteNoteContent { return }
|
||||
let connectedPeers = noteAdvertiser.visiblePeers
|
||||
.filter { $0.state == .joined }
|
||||
.map(\.mcPeer)
|
||||
guard !connectedPeers.isEmpty else { return }
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
noteAdvertiser.send(note: noteContent, to: connectedPeers)
|
||||
}
|
||||
.onAppear {
|
||||
noteContent = (try? String(contentsOf: note.path, encoding: .utf8)) ?? ""
|
||||
noteAdvertiser.startServer()
|
||||
}
|
||||
.onDisappear {
|
||||
noteAdvertiser.stopServer()
|
||||
saveNote()
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
saveNote()
|
||||
}
|
||||
}
|
||||
|
||||
func saveNote() {
|
||||
try? noteContent.write(to: note.path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
func saveNote() {
|
||||
try? noteContent.write(to: note.path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoteTextEditor: View {
|
||||
@Binding var text: String
|
||||
let remoteText: String?
|
||||
@State private var selection: TextSelection? = nil
|
||||
|
||||
var body: some View {
|
||||
TextEditor(text: $text, selection: $selection)
|
||||
.onChange(of: remoteText) { oldValue, newValue in
|
||||
guard let newValue, newValue != text else { return }
|
||||
// Apply remote text first
|
||||
text = newValue
|
||||
}
|
||||
}
|
||||
@Binding var text: String
|
||||
let remoteText: String?
|
||||
@State private var selection: TextSelection? = nil
|
||||
|
||||
var body: some View {
|
||||
TextEditor(text: $text, selection: $selection)
|
||||
.onChange(of: remoteText) { oldValue, newValue in
|
||||
guard let newValue, newValue != text else { return }
|
||||
// Apply remote text first
|
||||
text = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PeerStateButton: View {
|
||||
let peerState: Peer.ConnectionState
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch peerState {
|
||||
case .available:
|
||||
Button("Invite", action: onTap)
|
||||
case .joined:
|
||||
Text("Joined")
|
||||
case .rejected:
|
||||
Text("Rejected")
|
||||
case .invitationPending:
|
||||
Text("Invitation pending")
|
||||
}
|
||||
}
|
||||
let peerState: Peer.ConnectionState
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch peerState {
|
||||
case .available:
|
||||
Button("Invite", action: onTap)
|
||||
case .joined:
|
||||
Text("Joined")
|
||||
case .rejected:
|
||||
Text("Rejected")
|
||||
case .invitationPending:
|
||||
Text("Invitation pending")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,41 +7,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SharedNoteEditor: View {
|
||||
@State var note: String?
|
||||
@State var remoteNote: String? = nil
|
||||
@State var invitation: NoteInvitation
|
||||
@Bindable var noteClient: NoteEditingSessionClient
|
||||
@State var note: String?
|
||||
@State var remoteNote: String? = nil
|
||||
@State var invitation: NoteInvitation
|
||||
@Bindable var noteClient: NoteEditingSessionClient
|
||||
|
||||
init(
|
||||
invitation: NoteInvitation,
|
||||
noteClient: NoteEditingSessionClient
|
||||
) {
|
||||
self._invitation = .init(initialValue: invitation)
|
||||
self._noteClient = .init(noteClient)
|
||||
}
|
||||
init(
|
||||
invitation: NoteInvitation,
|
||||
noteClient: NoteEditingSessionClient
|
||||
) {
|
||||
self._invitation = .init(initialValue: invitation)
|
||||
self._noteClient = .init(noteClient)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let note = Binding($note) {
|
||||
NoteTextEditor(text: note, remoteText: remoteNote)
|
||||
} else {
|
||||
ProgressView {
|
||||
Text("Fetching note...")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(noteClient.noteChangesEmitter) { message in
|
||||
guard message.senderID != noteClient.ownPeer.displayName else { return }
|
||||
remoteNote = message.content
|
||||
}
|
||||
.task(id: note) {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard !Task.isCancelled, let note else { return }
|
||||
noteClient.send(note: note, to: invitation.invitatorID)
|
||||
}
|
||||
.onAppear {
|
||||
invitation.accept()
|
||||
note = invitation.note.noteSnapshot
|
||||
}
|
||||
}
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let note = Binding($note) {
|
||||
NoteTextEditor(text: note, remoteText: remoteNote)
|
||||
} else {
|
||||
ProgressView {
|
||||
Text("Fetching note...")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(noteClient.noteChangesEmitter) { message in
|
||||
guard message.senderID != noteClient.ownPeer.displayName else { return }
|
||||
remoteNote = message.content
|
||||
}
|
||||
.task(id: note) {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard !Task.isCancelled, let note else { return }
|
||||
noteClient.send(note: note, to: invitation.invitatorID)
|
||||
}
|
||||
.onAppear {
|
||||
invitation.accept()
|
||||
note = invitation.note.noteSnapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import Foundation
|
||||
|
||||
struct Note: Identifiable {
|
||||
var id: URL { path }
|
||||
var id: URL { path }
|
||||
|
||||
let name: String
|
||||
let path: URL
|
||||
let name: String
|
||||
let path: URL
|
||||
}
|
||||
|
||||
@@ -1,131 +1,140 @@
|
||||
import MultipeerConnectivity
|
||||
import Foundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import MultipeerConnectivity
|
||||
|
||||
struct NoteInvitation: Identifiable {
|
||||
struct NoteContent: Codable {
|
||||
let title: String
|
||||
let noteSnapshot: String
|
||||
}
|
||||
|
||||
var id: MCPeerID { invitatorID }
|
||||
var noteName: String { note.title }
|
||||
let invitatorID: MCPeerID
|
||||
let note: NoteContent
|
||||
private var invitationHandler: ((Bool) -> Void)?
|
||||
|
||||
init(
|
||||
invitatorID: MCPeerID,
|
||||
note: NoteContent,
|
||||
invitationHandler: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
self.invitatorID = invitatorID
|
||||
self.note = note
|
||||
self.invitationHandler = invitationHandler
|
||||
}
|
||||
|
||||
mutating func accept() {
|
||||
invitationHandler?(true)
|
||||
invitationHandler = nil
|
||||
}
|
||||
|
||||
mutating func decline() {
|
||||
invitationHandler?(false)
|
||||
invitationHandler = nil
|
||||
}
|
||||
struct NoteContent: Codable {
|
||||
let title: String
|
||||
let noteSnapshot: String
|
||||
}
|
||||
|
||||
var id: MCPeerID { invitatorID }
|
||||
var noteName: String { note.title }
|
||||
let invitatorID: MCPeerID
|
||||
let note: NoteContent
|
||||
private var invitationHandler: ((Bool) -> Void)?
|
||||
|
||||
init(
|
||||
invitatorID: MCPeerID,
|
||||
note: NoteContent,
|
||||
invitationHandler: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
self.invitatorID = invitatorID
|
||||
self.note = note
|
||||
self.invitationHandler = invitationHandler
|
||||
}
|
||||
|
||||
mutating func accept() {
|
||||
invitationHandler?(true)
|
||||
invitationHandler = nil
|
||||
}
|
||||
|
||||
mutating func decline() {
|
||||
invitationHandler?(false)
|
||||
invitationHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class NoteEditingSessionClient: NSObject {
|
||||
private let session: MCSession
|
||||
private let advertiser: MCNearbyServiceAdvertiser
|
||||
private(set) var ownPeer: MCPeerID
|
||||
|
||||
var invitations: [NoteInvitation] = []
|
||||
let noteChangesEmitter = PassthroughSubject<NoteMessage, Never>()
|
||||
private let session: MCSession
|
||||
private let advertiser: MCNearbyServiceAdvertiser
|
||||
private(set) var ownPeer: MCPeerID
|
||||
|
||||
init(peer: MCPeerID) {
|
||||
ownPeer = peer
|
||||
session = MCSession(
|
||||
peer: peer,
|
||||
securityIdentity: nil,
|
||||
encryptionPreference: .required
|
||||
)
|
||||
advertiser = MCNearbyServiceAdvertiser(
|
||||
peer: peer,
|
||||
discoveryInfo: [:],
|
||||
serviceType: "peered"
|
||||
)
|
||||
super.init()
|
||||
advertiser.delegate = self
|
||||
session.delegate = self
|
||||
}
|
||||
|
||||
func startBrowsingForNotes() {
|
||||
advertiser.startAdvertisingPeer()
|
||||
}
|
||||
|
||||
func stopBrowsingForNotes() {
|
||||
advertiser.stopAdvertisingPeer()
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
func send(note: String, to peer: MCPeerID) {
|
||||
let message = NoteMessage(senderID: ownPeer.displayName, content: note)
|
||||
guard let data = try? JSONEncoder().encode(message) else { return }
|
||||
try? session.send(data, toPeers: [peer], with: .reliable)
|
||||
}
|
||||
var invitations: [NoteInvitation] = []
|
||||
let noteChangesEmitter = PassthroughSubject<NoteMessage, Never>()
|
||||
|
||||
init(peer: MCPeerID) {
|
||||
ownPeer = peer
|
||||
session = MCSession(
|
||||
peer: peer,
|
||||
securityIdentity: nil,
|
||||
encryptionPreference: .required
|
||||
)
|
||||
advertiser = MCNearbyServiceAdvertiser(
|
||||
peer: peer,
|
||||
discoveryInfo: [:],
|
||||
serviceType: "peered"
|
||||
)
|
||||
super.init()
|
||||
advertiser.delegate = self
|
||||
session.delegate = self
|
||||
}
|
||||
|
||||
func startBrowsingForNotes() {
|
||||
advertiser.startAdvertisingPeer()
|
||||
}
|
||||
|
||||
func stopBrowsingForNotes() {
|
||||
advertiser.stopAdvertisingPeer()
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
func send(note: String, to peer: MCPeerID) {
|
||||
let message = NoteMessage(senderID: ownPeer.displayName, content: note)
|
||||
guard let data = try? JSONEncoder().encode(message) else { return }
|
||||
try? session.send(data, toPeers: [peer], with: .reliable)
|
||||
}
|
||||
}
|
||||
|
||||
extension NoteEditingSessionClient: MCNearbyServiceAdvertiserDelegate {
|
||||
func advertiser(
|
||||
_ advertiser: MCNearbyServiceAdvertiser,
|
||||
didReceiveInvitationFromPeer peerID: MCPeerID,
|
||||
withContext context: Data?,
|
||||
invitationHandler: @escaping (Bool, MCSession?) -> Void
|
||||
) {
|
||||
guard
|
||||
let context,
|
||||
let noteContent = try? JSONDecoder().decode(NoteInvitation.NoteContent.self, from: context)
|
||||
else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.append(
|
||||
.init(
|
||||
invitatorID: peerID,
|
||||
note: noteContent,
|
||||
invitationHandler: { [weak self, invitationHandler] accepted in
|
||||
guard let self else { return }
|
||||
invitationHandler(accepted, self.session)
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.removeAll { $0.id == peerID }
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
func advertiser(
|
||||
_ advertiser: MCNearbyServiceAdvertiser,
|
||||
didReceiveInvitationFromPeer peerID: MCPeerID,
|
||||
withContext context: Data?,
|
||||
invitationHandler: @escaping (Bool, MCSession?) -> Void
|
||||
) {
|
||||
guard
|
||||
let context,
|
||||
let noteContent = try? JSONDecoder().decode(NoteInvitation.NoteContent.self, from: context)
|
||||
else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.append(
|
||||
.init(
|
||||
invitatorID: peerID,
|
||||
note: noteContent,
|
||||
invitationHandler: { [weak self, invitationHandler] accepted in
|
||||
guard let self else { return }
|
||||
invitationHandler(accepted, self.session)
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.removeAll { $0.id == peerID }
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NoteEditingSessionClient: MCSessionDelegate {
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
peer peerID: MCPeerID,
|
||||
didChange state: MCSessionState
|
||||
) {}
|
||||
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
didReceive data: Data,
|
||||
fromPeer peerID: MCPeerID
|
||||
) {
|
||||
guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.noteChangesEmitter.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {}
|
||||
func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {}
|
||||
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) {}
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
peer peerID: MCPeerID,
|
||||
didChange state: MCSessionState
|
||||
) {}
|
||||
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
didReceive data: Data,
|
||||
fromPeer peerID: MCPeerID
|
||||
) {
|
||||
guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.noteChangesEmitter.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: MCSession, didReceive stream: InputStream, withName streamName: String,
|
||||
fromPeer peerID: MCPeerID
|
||||
) {}
|
||||
func session(
|
||||
_ session: MCSession, didStartReceivingResourceWithName resourceName: String,
|
||||
fromPeer peerID: MCPeerID, with progress: Progress
|
||||
) {}
|
||||
func session(
|
||||
_ session: MCSession, didFinishReceivingResourceWithName resourceName: String,
|
||||
fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoteInvitationView: View {
|
||||
@Binding var invitation: NoteInvitation
|
||||
let joinTapped: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(invitation.noteName)
|
||||
Spacer()
|
||||
Button("Join", action: joinTapped)
|
||||
}
|
||||
}
|
||||
@Binding var invitation: NoteInvitation
|
||||
let joinTapped: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(invitation.noteName)
|
||||
Spacer()
|
||||
Button("Join", action: joinTapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
struct NoteMessage: Codable {
|
||||
let senderID: String
|
||||
let content: String
|
||||
let senderID: String
|
||||
let content: String
|
||||
}
|
||||
|
||||
@@ -7,48 +7,77 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotesStorage {
|
||||
let storageProvider: FileManager = FileManager.default
|
||||
|
||||
func loadNotes() -> [Note] {
|
||||
let files = try! storageProvider
|
||||
.contentsOfDirectory(atPath: URL.documentsDirectory.path)
|
||||
var notes = [Note]()
|
||||
|
||||
for file in files.compactMap({
|
||||
URL(
|
||||
filePath: $0,
|
||||
directoryHint: .notDirectory,
|
||||
relativeTo: URL.documentsDirectory
|
||||
)
|
||||
}) {
|
||||
let name = file.lastPathComponent
|
||||
let note = Note(
|
||||
name: name,
|
||||
path: file
|
||||
)
|
||||
|
||||
notes.append(note)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
func createNote(name: String) {
|
||||
let currentNotes = loadNotes()
|
||||
var index: Int? = nil
|
||||
var proposedName: String {
|
||||
index.map { name + " \($0)" } ?? name
|
||||
}
|
||||
|
||||
while currentNotes.contains(where: { $0.name == proposedName }) {
|
||||
if let _index = index {
|
||||
index = _index + 1
|
||||
} else {
|
||||
index = 1
|
||||
}
|
||||
}
|
||||
let pathToWrite = URL.documentsDirectory.appendingPathComponent(proposedName).appendingPathExtension(for: .text)
|
||||
try! String().write(to: pathToWrite, atomically: true, encoding: .utf8)
|
||||
}
|
||||
protocol StorageProvider {
|
||||
func contentsOfDirectory(atPath path: String) throws -> [String]
|
||||
|
||||
@discardableResult
|
||||
func createFile(
|
||||
atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey: Any]?
|
||||
) -> Bool
|
||||
}
|
||||
|
||||
extension FileManager: StorageProvider {}
|
||||
|
||||
struct NotesStorage {
|
||||
let storageProvider: StorageProvider
|
||||
let rootDirectory: URL
|
||||
|
||||
init(
|
||||
storageProvider: StorageProvider = FileManager.default,
|
||||
rootDirectory: URL = .documentsDirectory
|
||||
) {
|
||||
self.storageProvider = storageProvider
|
||||
self.rootDirectory = rootDirectory
|
||||
}
|
||||
|
||||
func loadNotes() -> [Note] {
|
||||
let files =
|
||||
try! storageProvider
|
||||
.contentsOfDirectory(atPath: rootDirectory.path)
|
||||
var notes = [Note]()
|
||||
|
||||
for file in files.compactMap({
|
||||
URL(
|
||||
filePath: $0,
|
||||
directoryHint: .notDirectory,
|
||||
relativeTo: rootDirectory
|
||||
)
|
||||
}) {
|
||||
let name = file.lastPathComponent
|
||||
let note = Note(
|
||||
name: name,
|
||||
path: file
|
||||
)
|
||||
notes.append(note)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
func createNote(name: String) {
|
||||
let currentNotes = loadNotes()
|
||||
var index: Int? = nil
|
||||
var proposedName: String {
|
||||
index.map { name + " \($0)" } ?? name
|
||||
}
|
||||
|
||||
while currentNotes.contains(where: { $0.name == proposedName }) {
|
||||
if let _index = index {
|
||||
index = _index + 1
|
||||
} else {
|
||||
index = 1
|
||||
}
|
||||
}
|
||||
|
||||
let pathToWrite =
|
||||
rootDirectory
|
||||
.appendingPathComponent(proposedName)
|
||||
.appendingPathExtension(for: .text)
|
||||
|
||||
storageProvider.createFile(
|
||||
atPath: pathToWrite.path,
|
||||
contents: Data(),
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
@testable import Peered
|
||||
|
||||
final class InMemoryStorageProvider: StorageProvider {
|
||||
private(set) var files: [String: Data] = [:]
|
||||
|
||||
func contentsOfDirectory(atPath path: String) throws -> [String] {
|
||||
return files.keys.compactMap { filePath -> String? in
|
||||
guard filePath.hasPrefix(path) else { return nil }
|
||||
let remainder = String(filePath.dropFirst(path.count))
|
||||
let stripped = remainder.hasPrefix("/") ? String(remainder.dropFirst()) : remainder
|
||||
guard !stripped.isEmpty, !stripped.contains("/") else { return nil }
|
||||
return stripped
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createFile(
|
||||
atPath path: String,
|
||||
contents data: Data?,
|
||||
attributes attr: [FileAttributeKey: Any]?
|
||||
) -> Bool {
|
||||
files[path] = data ?? Data()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Peered
|
||||
|
||||
@Suite
|
||||
struct NoteContentCodableTests {
|
||||
@Test("encode → decode")
|
||||
func coding() throws {
|
||||
let original = NoteInvitation.NoteContent(
|
||||
title: "My note", noteSnapshot: "Note Sample Snapshot")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(NoteInvitation.NoteContent.self, from: data)
|
||||
|
||||
#expect(decoded.title == original.title)
|
||||
#expect(decoded.noteSnapshot == original.noteSnapshot)
|
||||
}
|
||||
|
||||
@Test
|
||||
func containsExpectedKeys() throws {
|
||||
let content = NoteInvitation.NoteContent(title: "Titlee", noteSnapshot: "Snapshott")
|
||||
let data = try JSONEncoder().encode(content)
|
||||
let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String])
|
||||
|
||||
#expect(json["title"] == "Titlee")
|
||||
#expect(json["noteSnapshot"] == "Snapshott")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import MultipeerConnectivity
|
||||
import Testing
|
||||
|
||||
@testable import Peered
|
||||
|
||||
extension NoteInvitation {
|
||||
fileprivate init(handler: @escaping (Bool) -> Void) {
|
||||
self.init(
|
||||
invitatorID: .init(
|
||||
displayName: "host"
|
||||
),
|
||||
note: .init(title: "Shared note", noteSnapshot: "Snapshot"),
|
||||
invitationHandler: handler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite
|
||||
struct NoteInvitationTests {
|
||||
@Test
|
||||
func acceptCallsHandlerWithTrue() {
|
||||
var received: Bool? = nil
|
||||
var invitation = NoteInvitation { received = $0 }
|
||||
|
||||
invitation.accept()
|
||||
|
||||
#expect(received == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func declineCallsHandlerWithFalse() {
|
||||
var received: Bool? = nil
|
||||
var invitation = NoteInvitation { received = $0 }
|
||||
|
||||
invitation.decline()
|
||||
|
||||
#expect(received == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptIsIdempotent() {
|
||||
var callCount = 0
|
||||
var invitation = NoteInvitation { _ in callCount += 1 }
|
||||
|
||||
invitation.accept()
|
||||
invitation.accept()
|
||||
|
||||
#expect(callCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func declineIsIdempotent() {
|
||||
var callCount = 0
|
||||
var invitation = NoteInvitation { _ in callCount += 1 }
|
||||
|
||||
invitation.decline()
|
||||
invitation.decline()
|
||||
|
||||
#expect(callCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func declineAfterAcceptIsNoOp() {
|
||||
var callCount = 0
|
||||
var invitation = NoteInvitation { _ in callCount += 1 }
|
||||
|
||||
invitation.accept()
|
||||
invitation.decline()
|
||||
|
||||
#expect(callCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func noteNameReturnsTitle() {
|
||||
let invitation = NoteInvitation { _ in }
|
||||
#expect(invitation.noteName == "Shared note")
|
||||
}
|
||||
|
||||
@Test
|
||||
func idIsInvitatorPeerID() {
|
||||
let peerID = MCPeerID(displayName: "host")
|
||||
let invitation = NoteInvitation(
|
||||
invitatorID: peerID,
|
||||
note: .init(title: "T", noteSnapshot: "S")
|
||||
)
|
||||
#expect(invitation.id == peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Peered
|
||||
|
||||
@Suite
|
||||
struct NoteMessageCodableTests {
|
||||
@Test
|
||||
func encodesToJSON() throws {
|
||||
let message = NoteMessage(senderID: "host", content: "Content")
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String])
|
||||
|
||||
#expect(json["senderID"] == "host")
|
||||
#expect(json["content"] == "Content")
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodesFromJSON() throws {
|
||||
let json = #"{"senderID":"host","content":"contentt"}"#
|
||||
let data = try #require(json.data(using: .utf8))
|
||||
let message = try JSONDecoder().decode(NoteMessage.self, from: data)
|
||||
|
||||
#expect(message.senderID == "host")
|
||||
#expect(message.content == "contentt")
|
||||
}
|
||||
|
||||
@Test("encode → decode")
|
||||
func coding() throws {
|
||||
let original = NoteMessage(senderID: "host", content: "coding test")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(NoteMessage.self, from: data)
|
||||
|
||||
#expect(decoded.senderID == original.senderID)
|
||||
#expect(decoded.content == original.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Peered
|
||||
|
||||
@Suite
|
||||
struct NotesStorageTests {
|
||||
private func makeStorage(root: URL = URL(filePath: "/test")) -> (
|
||||
NotesStorage, InMemoryStorageProvider
|
||||
) {
|
||||
let provider = InMemoryStorageProvider()
|
||||
let storage = NotesStorage(storageProvider: provider, rootDirectory: root)
|
||||
return (storage, provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadNotesReturnsEmptyArrayWhenNoFiles() {
|
||||
let (storage, _) = makeStorage()
|
||||
let notes = storage.loadNotes()
|
||||
#expect(notes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func createNoteCreatesFile() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, provider) = makeStorage(root: root)
|
||||
|
||||
storage.createNote(name: "My note")
|
||||
|
||||
#expect(!provider.files.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadNotesReturnCreatedNote() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, provider) = makeStorage(root: root)
|
||||
|
||||
let filePath = root.appendingPathComponent("Note").path
|
||||
provider.createFile(atPath: filePath, contents: Data(), attributes: nil)
|
||||
|
||||
let notes = storage.loadNotes()
|
||||
|
||||
#expect(notes.count == 1)
|
||||
#expect(notes.first?.name == "Note")
|
||||
}
|
||||
|
||||
@Test
|
||||
func createNoteDeduplicatesNames() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, provider) = makeStorage(root: root)
|
||||
|
||||
let existingPath = root.appendingPathComponent("Note").path
|
||||
provider.createFile(atPath: existingPath, contents: Data(), attributes: nil)
|
||||
|
||||
storage.createNote(name: "Note")
|
||||
|
||||
#expect(provider.files.count == 2)
|
||||
|
||||
let paths = provider.files.keys
|
||||
#expect(paths.contains(where: { $0.contains("Note 1") }))
|
||||
}
|
||||
|
||||
@Test
|
||||
func createNoteTwiceCreatesTwoFiles() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, _) = makeStorage(root: root)
|
||||
|
||||
storage.createNote(name: "Note")
|
||||
storage.createNote(name: "Note")
|
||||
|
||||
let notes = storage.loadNotes()
|
||||
#expect(notes.count == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#import "style.typ": abstract
|
||||
|
||||
#let pl_body = "Niniejsza praca dotyczy implementacji mobilnej aplikacji do współtworzenia notatek tekstowych w architekturze Peer-To-Peer, przeznaczonej na urządzenia z systemem iOS. Główną motywacją podjętego tematu jest ograniczenie współczesnych narzędzi do wspólnej edycji dokumentów, takich jak Google Docs, które wymagają stałego dostępu do Internetu oraz pośrednictwa scentralizowanych serwerów - nawet gdy wszyscy współpracownicy znajdują się w tej samej sieci lokalnej. Zaproponowane rozwiązanie umożliwia bezpośrednią komunikację między urządzeniami bez udziału zewnętrznej infrastruktury sieciowej. Aplikacja została zbudowana w języku Swift z wykorzystaniem frameworka SwiftUI oraz Multipeer Connectivity. W pracy omówiono teoretyczne podstawy systemów synchronizacji danych w czasie rzeczywistym, a także możliwości komunikacji P2P dostępne na platformach firmy Apple."
|
||||
|
||||
#let en_body = "This thesis concerns the implementation of a mobile peer-to-peer collaborative note-taking application for devices running iOS. The primary motivation is to address a fundamental limitation of contemporary collaborative editing tools such as Google Docs, which require continuous Internet access and rely on centralized servers to mediate all data exchange - even when all collaborating users are present on the same local network. The proposed solution enables direct device-to-device communication, eliminating the need for any external network infrastructure. The application was built in Swift using the SwiftUI framework and Multipeer Connectivity framework. The thesis covers the theoretical foundations of real-time data synchronization systems, as well as the peer-to-peer communication capabilities available within the Apple's systems."
|
||||
|
||||
#let pl_keywords = "Peer-To-Peer, współtworzenie dokumentów, iOS, Multipeer Connectivity, Swift, SwiftUI, ewentualna zbieżność"
|
||||
|
||||
#let en_keywords = "Peer-To-Peer, collaborative editing, iOS, Multipeer Connectivity, Swift, SwiftUI, eventual consistency"
|
||||
|
||||
#abstract(
|
||||
pl-body: pl_body,
|
||||
pl-keywords: pl_keywords,
|
||||
en-body: [
|
||||
This thesis concerns the implementation of a mobile peer-to-peer collaborative note-taking application for devices running iOS. The primary motivation is to address a fundamental limitation of contemporary collaborative editing tools such as Google Docs, which require continuous Internet access and rely on centralized servers to mediate all data exchange - even when all collaborating users are present on the same local network. The proposed solution enables direct device-to-device communication, eliminating the need for any external network infrastructure. The application was built in Swift using the SwiftUI framework and Multipeer Connectivity framework. The thesis covers the theoretical foundations of real-time data synchronization systems, as well as the peer-to-peer communication capabilities available within the Apple's systems.
|
||||
],
|
||||
en-keywords: en_keywords,
|
||||
)
|
||||
@@ -43,7 +43,7 @@
|
||||
#let render-bib() = {
|
||||
load-bibliography("Bibliography.bib", prefix: "cite:", full: true, style: "ieee")
|
||||
|
||||
heading(level: 1)[Spis literatury]
|
||||
heading(level: 1, outlined: true, numbering: none)[Spis literatury]
|
||||
v(1em)
|
||||
|
||||
context {
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
let render-group(title, keys, start-idx) = {
|
||||
if keys.len() > 0 [
|
||||
== #title
|
||||
#heading(level: 2, title, outlined: true, numbering: none)
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 0.65em,
|
||||
@@ -191,7 +191,6 @@
|
||||
let entry = get-entry(k)
|
||||
if entry == none { return () }
|
||||
let num = start-idx + i + 1
|
||||
// Pamiętaj: musisz użyć format-custom, który wkleiłeś wyżej!
|
||||
([\[#num\]], [#format-custom(entry)#label("cite:" + k)])
|
||||
})
|
||||
.flatten()
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
#set heading(numbering: none)
|
||||
|
||||
= Wstęp <wstep>
|
||||
Tu będzie dopiero treść :)
|
||||
|
||||
Współczesne narzędzia do wspólnego tworzenia dokumentów, takie jak Google Docs czy Microsoft Word Online, zrewolucjonizowały sposób, w jaki zespoły pracują nad treścią. Umożliwiają one wielu użytkownikom jednoczesną edycję tego samego dokumentu z wielu urządzeń. Ich powszechność skrywa jednak ważne ograniczenie architektoniczne - wszystkie one opierają się na scentralizowanym serwerze, który pośredniczy w każdej wymianie danych. Oznacza to, że gdy dwóch użytkowników pracuje nad wspólnym dokumentem w tym samym pomieszczeniu, każda wprowadzona zmiana musi najpierw dotrzeć do serwerów dostawcy usługi, zanim zostanie przekazana drugiemu uczestnikowi sesji. W konsekwencji brak dostępu do Internetu uniemożliwia dalszą współpracę, a uzależnienie od zewnętrznej infrastruktury tworzy pytania o prywatność danych oraz ciągłość usługi.
|
||||
|
||||
Niniejsza praca inżynierska podejmuje próbę odpowiedzi na powyższe ograniczenia poprzez zaprojektowanie i implementację mobilnej aplikacji do współtworzenia notatek tekstowych działającej w architekturze Peer-To-Peer. Zaproponowane rozwiązanie pozwala urządzeniom komunikować się bezpośrednio ze sobą za pośrednictwem Bluetooth lub Wi-Fi, bez udziału centralnego serwera i bez konieczności posiadania dostępu do Internetu. Aplikacja została zbudowana z myślą o urządzeniach firmy Apple z systemem iOS oraz iPadOS, z wykorzystaniem języka programowania Swift oraz deklaratywnego frameworka SwiftUI. Do komunikacji Peer-To-Peer zastosowano dostarczany przez Apple framework Multipeer Connectivity.
|
||||
|
||||
Cele pracy obejmują:
|
||||
- Przegląd i analizę istniejących algorytmów zapewniających zbieżność danych w systemach rozproszonych — w szczególności Operational Transformation oraz bezkonfliktowych replikowanych typów danych,
|
||||
- Omówienie możliwości komunikacji Peer-To-Peer dostępnych na platformach firmy Apple,
|
||||
- Zdefiniowanie wymagań funkcjonalnych i niefunkcjonalnych dla projektowanej aplikacji,
|
||||
- Implementację aplikacji realizującej zdefiniowane wymagania, w tym mechanizm odkrywania urządzeń, synchronizacji notatek oraz rozwiązywania konfliktów metodą Last-Write-Wins,
|
||||
- Weryfikację poprawności działania systemu za pomocą testów jednostkowych oraz manualnych.
|
||||
|
||||
Praca składa się z czterech rozdziałów. Rozdział pierwszy przedstawia zakres teoretyczny — omawia modele zbieżności danych, algorytmy synchronizacji stosowane w systemach do współpracy w czasie rzeczywistym, a także możliwości komunikacji P2P dostępne w systemach iOS i iPadOS. Przybliżone zostają istniejące rozwiązania rynkowe oraz ich ograniczenia. Rozdział drugi zawiera specyfikację wymagań systemowych, zarówno funkcjonalnych, jak i niefunkcjonalnych, które wyznaczają zakres realizowanej implementacji; opisuje architekturę aplikacji, model danych, warstwę sieciową, algorytm rozwiązywania konfliktów oraz napotkane wyzwania implementacyjne. Rozdział trzeci prezentuje przeprowadzone testy i ich wyniki. Rozdział czwarty stanowi podsumowanie osiągniętych celów oraz propozycje kierunków dalszego rozwoju aplikacji.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
== Systemy współdzielenia dokumentów w czasie rzeczywistym
|
||||
Budując rozwiązania związane z równoczesnym tworzeniem i modyfikacją tekstu przez więcej niż jednego użytkownika, musimy rozważyć wyzwania napotykane w aktualizacji tworzonego dokumentu, gdzie każdy klient posiada lokalną kopię i nanosi na nie własne zmiany, ale też w międzyczasie musimy nanieść zmiany od pozostałych klientów. W takim systemie mówimy wtedy o zbieżności danych@cite:eventually_consistent - czyli zapewnieniu tego samego stanu między każdym klientem. W przypadku edycji tekstu skupię się na ewentualnej zbieżności, która uwzględnia posiadanie rozbieżnych kopii tego samego źródła danych u każdego z klientów przez pewien czas. Dopiero gdy zostanie zakończona edycja tekstu, zmiany zostają propagowane i nanoszone do pozostałych klientów. Finalnie każdy klient po czasie posiada identyczną kopię dokumentu. Ze strony doświadczeń użytkowania jest to skuteczna strategia ze względu na możliwość zapewnienia płynności interfejsu graficznego oraz z pomocą złożonych mechanizmów umożliwia rozwiązywanie konfliktów między kopiami.
|
||||
|
||||
Wspomniany model nie jest bez wad. Największym problemem jest istnienie konfliktów, których rozwiązanie klienci muszą ustalić za pomocą dodatkowych strategii. Najeczęściej wykorzystywaną jest Last-Write-Wins (LWW). Rozstrzyga ona konflikty poprzez nanoszenie tylko tej zmiany, która jest uznawana jako ostatnia w kolejności zbioru konfliktujących operacji. Ustalanie kolejności nie jest jasno tutaj zdefiniowane. W systemach baz danych takich jak Cassandra@cite:apache_cassandra_documentation oraz SQL Server P2P@cite:microsoft_sql_server_p2p_replication_documentation każdy zapis otrzymuje własny znacznik czasowy, na podstawie którego wybierany jest najmłodszy wpis i nim nadpisywane są zmiany w źródle danych. Zmiany ze starszymi znacznikami są porzucane. Zauważalną wadą LWW jest wysokie ryzyko utraty danych w czasie nanoszenia zmian, ponieważ wszystkie konfliktujące starsze zmiany nie są brane pod uwagę.
|
||||
Wspomniany model nie jest bez wad. Największym problemem jest istnienie konfliktów, których rozwiązanie klienci muszą ustalić za pomocą dodatkowych strategii. Najczęściej wykorzystywaną jest Last-Write-Wins (LWW). Rozstrzyga ona konflikty poprzez nanoszenie tylko tej zmiany, która jest uznawana jako ostatnia w kolejności zbioru konfliktujących operacji. Ustalanie kolejności nie jest jasno tutaj zdefiniowane. W systemach baz danych takich jak Cassandra@cite:apache_cassandra_documentation oraz SQL Server P2P@cite:microsoft_sql_server_p2p_replication_documentation każdy zapis otrzymuje własny znacznik czasowy, na podstawie którego wybierany jest najmłodszy wpis i nim nadpisywane są zmiany w źródle danych. Zmiany ze starszymi znacznikami są porzucane. Zauważalną wadą LWW jest wysokie ryzyko utraty danych w czasie nanoszenia zmian, ponieważ wszystkie konfliktujące starsze zmiany nie są brane pod uwagę.
|
||||
|
||||
W przypadku tekstu jako typu danych, istnieje specjalny wariant ewentualnej zbieżności - silna ewentualna zbieżność. Ten model wykorzystuje specjalne struktury danych, które zapewniają bezkonfliktowe nanoszenie zmian, a ich skuteczność opiera się na matematycznych dowodach@cite:verifying_strong_eventual_consistency.
|
||||
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
#import "../style.typ"
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Implementacja <implementacja>
|
||||
|
||||
W poniższym rozdziale opiszę specyfikację wymagań dla projektowanej aplikacji do współtworzenia notatek w architekturze peer to peer oraz jej pełną implementację. Celem wymagań jest określenie zakresu funkcjonalności oraz ograniczeń technologicznych, których będę się trzymać w ramach proponowanej implementacji. Wyodrębnię dwóch aktorów:
|
||||
|
||||
- Użytkownika - osoba zarządzająca notatkami przez interfejs graficzny zaimplementowany w ramach implementacji,
|
||||
- Klient - instancja implementowanej aplikacji na urządzeniu fizycznym.
|
||||
|
||||
Same wymagania zostały podzielone odpowiednio na wymagania funkcjonalne i niefunkcjonalne.
|
||||
|
||||
== Wymagania funkcjonalne
|
||||
- System musi umożliwiać rozgłaszanie swojej obecności w sieci lokalnej, by móc zostać wykrytym przez innych klientów.
|
||||
- System musi umożliwiać wyszukiwanie innych aktywnych klientów znajdujących się w zakresie lokalnego otoczenia oraz lokalnej sieci.
|
||||
- System musi pozwalać na wysyłanie zaproszeń do połączenia się z wykrytymi klientami.
|
||||
- System musi na bieżąco wyświetlać listę aktualnie połączonych klientów.
|
||||
|
||||
- System musi umożliwiać utworzenie nowej notatki tekstowej.
|
||||
- System musi umożliwiać edycję treści istniejącej notatki.
|
||||
- System musi umożliwiać trwałe usunięcie notatki.
|
||||
- System musi automatycznie tworzyć unikalny identyfikator oraz znacznik czasowy dla nowych notatek.
|
||||
- System musi pozwalać na zdefiniowanie tytułu notatki, będącego niezależnym parametrem od treści notatki.
|
||||
|
||||
- System musi enkodować strukturę danych notatki do formatu umożliwiającego przesył notatki do połączonych klientów.
|
||||
- System musi dekodować otrzymane zakodowane dane o notatce i przekształcić je w natywny obiekt reprezentujący notatkę w systemie.
|
||||
- System musi automatycznie rozsyłać zaktualizowaną treść notatki do wszystkich aktualnie połączonych klientów w jak najkrótszym czasie od wykrycia zmian.
|
||||
- System musi nadpisać istniejącą notatkę nowo otrzymaną kopią, gdy obie posiadają ten sam identyfikator, ale otrzymana kopia zawiera nowszy znacznik czasowy.
|
||||
|
||||
- System musi zapisywać wszystkie notatki w trwałej pamięci urządzenia.
|
||||
- System musi odświeżać interfejs z listą notatek w czasie rzeczywistym.
|
||||
|
||||
== Wymagania niefunkcjonalne
|
||||
- System musi wspierać urządzenia mobilne firmy Apple z zainstalowanymi systemami operacyjnymi iOS lub iPadOS w wersji 18.0 lub wyższej.
|
||||
- Kod źródłowy powinien być napisany w języku Swift z wykorzystaniem deklaratywnego frameworka do budowy interfejsów graficznych - SwiftUI.
|
||||
- Operacje zapisu do plików oraz procesy sieciowe muszą być wykonywane poza głównym wątkiem - wątkiem obsługującym interfejs graficzny aplikacji.
|
||||
- Czas propagacji zmian w notatce do innego połączonego klienta nie powinien wynosić więcej niż 1 sekunda.
|
||||
- System musi obsłużyć przypadek zerwania połączenia, bez uszkodzenia notatki oraz rzucania wyjątków uniemożliwiających dalsze funkcjonowanie systemu.
|
||||
- System musi zapewnić szyfrowaną komunikację między klientami.
|
||||
- Interfejs użytkownika musi być responsywny i dostosowywać się do różnych rozmiarów ekranów i ich orientacji w zakresie urządzeń tworzonych przez Apple.
|
||||
- Wygląd aplikacji powinien spełniać oficjalne wytyczne projektowe Apple Human Interface Guideliens.
|
||||
- System powinien automatycznie dostosowywać paletę kolorów interfejsu graficznego do aktualnie ustawionego motywu systemowego.
|
||||
|
||||
== Projekt architektury systemu <projekt>
|
||||
Przygotowana implementacja bazuje na architekturze Model-View. Jest to podejście, gdzie cała logika biznesowa jest zawarta w modelach, które bezpośrednio są przekazywane do warstwy prezentacji (View). Na tej warstwie jest wykonywane odpowiednie formatowanie danych, gdzie też brane pod uwagę są preferencje zapisu i językowe użytkownika. Model-View to uproszczony wariant popularnej w aplikacjach mobilnych architektury Model-View-ViewModel (MVVM), gdzie ViewModel jest warstwą zajmującą się przekształcaniem modelów biznesowych na gotowe do prezentacji obiekty. Warstwa View wtedy zajmuje się przede wszystkim definiowaniem struktury interfejsu użytkownika oraz logiką związaną z dostępnością (wsparcie dla funkcjonalności czytników ekranów, skalowaniem interfejsu). Przygotowana implementacja nie zawiera złożonej logiki prezentacji ani rozbudowanego graficznego interfejsu użytkownika, więc w celu uproszczenia kodu zdecydowałem się na porzucenie użycia warstwy ViewModelu.
|
||||
|
||||
Warstwa prezentacji aplikacji opisująca początkowy interfejs użytkownika jest zaimplementowana w `AllNotesScreen`. Ekran jest widoczny dla użytkownika, gdy ustawił swoją nazwę użytkownika w sieci peer-to-peer. Składa się z listy podzielonej na dwie sekcje - notatek których użytkownik jest autorem, oraz notatek do których użytkownik został zaproszony. Nad listą znajduje się przycisk stworzenia nowej notatki. Naciśnięcie na którąkolwiek z istniejących notatek prowadzi do otwarcia jej zawartości. W zależności od tego, czy użytkownik jest właścicielem danej notatki czy współtwórcą, interfejsem do edycji tej notatki są odpowiednio obiekty `NoteEditorScreen` oraz `SharedNoteEditor`. Z ekranu `NoteEditorScreen` możemy przejść do `ManageMembersScreen`, który jest listą użytkowników zaproszonych i możliwych do zaproszenia do edytowania aktualnej notatki.
|
||||
|
||||
== Model danych
|
||||
Program 3.1 przedstawia podstawowy obiekt reprezentujący notatkę w systemie plików - `Note`. Składa się on z URL (Universal Resource Locator), czyli ścieżki prowadzącej do pełnej zawartości notatki użytkownika, która jest przechowywana w pliku tekstowym o rozszerzeniu `txt`. Dodatkowo przechowujemy w tym obiekcie jeszcze `name`, które pełni funkcję ułatwionego dostępu do przyjaznej nazwy, jednocześnie będąc nazwą pliku w lokalnym systemie plików oraz przyjazną nazwą prezentowaną w liście notatek do których użytkownik został zaproszony. Obiekt implementuje protokół `Identifiable`, który gwarantuje możliwość identyfikacji obiektu w zbiorze zawierającym wiele jego instancji, np. w tablicy. Ta cecha jest wymagana i wykorzystywana przez SwiftUI, by móc rozróżniać rodzaje zmian na komponentach interfejsów graficznych zawierających wiele kopii takiego obiektu, np. `List` lub `ForEach`. Umożliwia to dokonanie decyzji co do sposobu animacji zmian na ekranie użytkownika, ponieważ framework będzie mógł rozróżnić usunięcie i wstawienie nowego obiektu, od zmiany parametrów tej samej instancji.
|
||||
|
||||
#let note_struct = [```swift
|
||||
struct Note: Identifiable {
|
||||
var id: URL { path }
|
||||
|
||||
let name: String
|
||||
let path: URL
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_struct,
|
||||
kind: raw,
|
||||
caption: [Definicja obiektu notatki],
|
||||
)
|
||||
|
||||
Program 3.2 przedstawia `NoteMessage` - obiekt zawierający zawartość notatki, którą wysyłamy do użytkowników, którzy przyjęli zaproszenie do edycji notatki. Znajdziemy w niej pola `SenderID`, które jest identyfikatorem właściciela notatki oraz `content`, czyli właściwą zawartość notatki. Jest ona również używana do wysyłania każdej aktualizacji do wszystkich użytkowników. Obiekt implementuje protokół `Codable`, który jest uniwersalnym interfejsem kodowania danych. Daje to nam wbudowane wsparcie kodowowania do formatów JSON i XML. Mamy możliwość również pisania własnych koderów, które umożliwią zamianę obiektów implementujących `Codable` do wybranych przez nas, innych formatów danych.
|
||||
|
||||
#let note_message_struct = [```swift
|
||||
struct NoteMessage: Codable {
|
||||
let senderID: String
|
||||
let content: String
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_message_struct,
|
||||
kind: raw,
|
||||
caption: [Definicja obiektu notatki wysyłanego między użytkownikami],
|
||||
)
|
||||
|
||||
Do reprezentacji zaproszeń stworzyłem obiekt `NoteInvitation` przedstawiony w programie 3.3. Przechowuje on informacje potrzebne do obsługi całego procesu zaproszeń między użytkownikami systemu. Składa się z `invitatorID`, który jest wyspecjalizowanym obiektem frameworku `MultipeerConnectivity` i umożliwia identyfikację użytkownika w czasie komunikacji peer to peer. `note` to obiekt reprezentujący notatkę, którą otrzymujemy w zaproszeniu. Składa się on z tytułu i treści, które były aktualne w momencie zapraszania użytkownika. Ostatnim i jednocześnie prywatnym parametrem jest `invitationHandler`, który jest typem funkcji przyjmującej wartość boolowską (prawda/fałsz). Jest on wykorzystywany do wstrzyknięcia logiki akceptacji notatki, która wymaga kilku operacji w logice klasy obsługującej przyjmowanie zaproszeń. Dzięki takiemu podejściu tworzymy luźną zależność do skomplikowanego obiektu na dalszych etapach systemu. `NoteInvitation` zawiera również metody `accept()` oraz `decline()`, które odpowiadają za wykonanie akcji zaproszenia, które pod spodem odpowiednio używają parametru `invitationHandler`, który można wykonać tylko raz, ze względu na specyfikę `Multipeer Connectivity`, a następnie usuwamy go z pamięci.
|
||||
|
||||
#let note_invitation_struct = [```swift
|
||||
struct NoteInvitation: Identifiable {
|
||||
struct NoteContent: Codable {
|
||||
let title: String
|
||||
let noteSnapshot: String
|
||||
}
|
||||
|
||||
var id: MCPeerID { invitatorID }
|
||||
var noteName: String { note.title }
|
||||
let invitatorID: MCPeerID
|
||||
let note: NoteContent
|
||||
private var invitationHandler: ((Bool) -> Void)?
|
||||
|
||||
init(
|
||||
invitatorID: MCPeerID,
|
||||
note: NoteContent,
|
||||
invitationHandler: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
self.invitatorID = invitatorID
|
||||
self.note = note
|
||||
self.invitationHandler = invitationHandler
|
||||
}
|
||||
|
||||
mutating func accept() {
|
||||
invitationHandler?(true)
|
||||
invitationHandler = nil
|
||||
}
|
||||
|
||||
mutating func decline() {
|
||||
invitationHandler?(false)
|
||||
invitationHandler = nil
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_invitation_struct,
|
||||
kind: raw,
|
||||
caption: [Definicja obiektu reprezentującego zaproszenie do edycji notatki],
|
||||
)
|
||||
|
||||
Ostatnim obiektem jest struktura `Peer` przedstawiona w programie 3.4, która jest opisem aktualnego stanu połączenia wykrytego innej instancji systemu w pobliżu użytkownika. Składa się z wartości enumerowanej opisującej stan połączenia wraz z identyfikatorem użytkownika w sieci peer to peer. Implementuje ona protokół `Identifiable`, by móc zostać poprawnie użyta do rysowania listy dostępnych klientów w pobliżu użytkownika.
|
||||
|
||||
#let peer_struct = [```swift
|
||||
struct Peer: Identifiable {
|
||||
enum ConnectionState {
|
||||
case available
|
||||
case joined
|
||||
case rejected
|
||||
case invitationPending
|
||||
}
|
||||
|
||||
var id: String { mcPeer.displayName }
|
||||
let mcPeer: MCPeerID
|
||||
var state: ConnectionState
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
peer_struct,
|
||||
kind: raw,
|
||||
caption: [Definicja obiektu reprezentującego widocznego klienta],
|
||||
)
|
||||
|
||||
== Warstwa sieciowa i komunikacja P2P
|
||||
Całość komunikacji między urządzeniami odbywa się z wykorzystaniem frameworka Multipeer Connectivity. Klient twórcy notatki pełni rolę serwera, a pozostali użytkownicy, po uprzednim zaproszeniu, mogą dołączyć do edycji notatki, wysyłać swoje zmiany jak i odbierać zmiany, które dystrybuuje serwer.
|
||||
|
||||
== Odkrywanie innych urządzeń
|
||||
Obiekt reprezentujący serwer został nazwany `NoteEditingSessionServer`, który dziedziczy właściowści po klasie `NSObject`, która jest uniwersalną implementacją wielu zachowań, które są wymagane od frameworków udostępnianych przez Apple, które zostały napisane w języku Objective-C. Jego konstruktor, przedstawiony w programie 3.5, w przyjmowanych argumentach oczekuje tylko obiektu `OwnPeer`, który będzie wykorzystywany do identyfikacji instancji aplikacji u innych klientów. Sama implementacja konstruktora tworzy nową sesję `MCSession`; obiekt `MCNearbyServiceBrowser`, który odpowiada za wykrywanie pobliskich klientów. Finalnie przypisuje referencję do samego siebie jako parametr `delegate` dla utworzonych `MCSession` i `MCNearbyServiceBrowser`. Pozwala nam to zaimplementować metody, które będą wykorzystywane wewnątrz tych obiektów do komunikacji z innymi użytkownikami. Protokoły delegujące dla wspomnianych obiektów nazywają się odpowiednio `MCSessionDelegate` oraz `MCNearbyServiceBrowserDelegate`. Moja implementacja tych protokołów zostanie przedstawiona w dalszej części pracy.
|
||||
|
||||
#let note_editing_session_server_init = [```swift
|
||||
init(peer: OwnPeer) {
|
||||
ownPeer = peer
|
||||
browser = .init(peer: peer.peer, serviceType: "peered")
|
||||
session = .init(peer: peer.peer, securityIdentity: nil, encryptionPreference: .required)
|
||||
super.init() // wykonuje pozostałą część
|
||||
browser.delegate = self //
|
||||
session.delegate = self
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_server_init,
|
||||
kind: raw,
|
||||
caption: [Implementacja konstruktora obiektu NoteEditingSessionServer],
|
||||
)
|
||||
|
||||
W momencie, gdy autor notatki otworzy ekran edycji, wykonuje się metoda `startServer()`, która wywołuje metodę `startBrowsingForPeers()` obiektu `MCNearbyServiceBrowser`. Opuszczenie ekranu edycji wywołuje metodę `stopServer()`, która wywołuje analogiczną metodę `stopBrowsingForPeers()` oraz zatrzymuje sesję poprzez wywołanie metody `disconnect()` obiektu `MCSession`. Obie metody zostały przedstawione w programie 3.6.
|
||||
|
||||
#let note_editing_session_server_edge_lifecycle = [```swift
|
||||
func startServer() {
|
||||
browser.startBrowsingForPeers()
|
||||
}
|
||||
|
||||
func stopServer() {
|
||||
browser.stopBrowsingForPeers()
|
||||
session.disconnect()
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_server_edge_lifecycle,
|
||||
kind: raw,
|
||||
caption: [Implementacja metod odpowiedzialnych za nasłuchiwanie na dostępnych klientów],
|
||||
)
|
||||
|
||||
Obiekt `browser` w momencie wykrycia nowego użytkownika w pobliżu, wywołuje naszą metodę o nazwie `browser()`, która przyjmuje wszystkie potrzebne informacje o znalezionym użytkowniku. Implementacja mojego systemu, jak zostało to przedstawione w programie 3.7, następnie upewnia się czy odkryty użytkownik nie jest jednocześnie autorem notatki, co jest znanym błędem w Multipeer Connectivity. Następnie po udanej weryfikacji dodajemy nowy obiekt dostępnego użytkownika do tablicy na podstawie której jest budowany interfejs z listą dostępnych użytkowników.
|
||||
|
||||
#let note_editing_session_server_browser_found_peer = [```swift
|
||||
func browser(
|
||||
_ browser: MCNearbyServiceBrowser,
|
||||
foundPeer peerID: MCPeerID,
|
||||
withDiscoveryInfo info: [String: String]?
|
||||
) {
|
||||
guard !visiblePeers.contains(where: { $0.mcPeer == peerID }) && peerID.displayName != ownPeer.peer.displayName else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.visiblePeers.append(Peer(mcPeer: peerID, state: .available))
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_server_browser_found_peer,
|
||||
kind: raw,
|
||||
caption: [Implementacja metody browser wywoływanej w przypadku wykrycia innego klienta],
|
||||
)
|
||||
|
||||
W sytuacji gdy użytkownik straci połączenie z połączonym klientem, następuje usunięcie jego identyfikatora z listy, poprzez wywołanie metody o takiej samej nazwie, ale z parametrami wskazującymi na scenariusz zgubienia klienta, tak jak zostało to zapisane w programie 3.8.
|
||||
|
||||
#let note_editing_session_server_browser_lost_peer = [```swift
|
||||
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
|
||||
DispatchQueue.main.async {
|
||||
guard let peerIdx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
self.visiblePeers.remove(at: peerIdx)
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_server_browser_lost_peer,
|
||||
kind: raw,
|
||||
caption: [Implementacja metody browser wywoływanej gdy aplikacja utraci połączenie z wykrytym klientem],
|
||||
)
|
||||
|
||||
Każda modyfikacja obiektów klasy w tym wypadku musi zostać wywołana na tym samym wątku, ponieważ Multipeer Connectivity nie gwarantuje, że kod będzie się wykonał zawsze na tym samym wątku. Wybrałem wątek główny, ze względu na bezpośrednie użycie właściwości klasy wewnątrz obiektów odpowiedzialnych za budowę interfejsu użytkownika.
|
||||
|
||||
Program 3.9 przedstawia część systemu, gdzie o stanie połączenia z innymi klientami system jest informowany przez wykonanie metody `session` z parametrami zawierającymi informację o stanie, który jest reprezentowany przez typ enumeracji, obiekt sesji oraz identyfikator klienta, których ten stan połączenia dotyczy. Na podstawie tych argumentów, aplikacja aktualizuje tablicę `visiblePeers`.
|
||||
|
||||
#let note_editing_session_server_session_peer_did_change_state = [```swift
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
peer peerID: MCPeerID,
|
||||
didChange state: MCSessionState
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
guard let idx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
switch state {
|
||||
case .connected:
|
||||
self.visiblePeers[idx].state = .joined
|
||||
case .notConnected:
|
||||
let currentState = self.visiblePeers[idx].state
|
||||
if currentState == .invitationPending || currentState == .joined {
|
||||
self.visiblePeers[idx].state = .rejected
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_server_session_peer_did_change_state,
|
||||
kind: raw,
|
||||
caption: [Implementacja metody session wywoływanej w przypadku zmiany stanu połączenia z konkretnym klientem],
|
||||
)
|
||||
|
||||
Przechodząc do implementacji klienta, całość jest reprezentowana przez obiekt `NoteEditingSessionClient`. Jego konstruktor, obecny w programie 3.10, przyjmuje tylko identyfikator użytkownika, który jest typem `MCPeerID`, a implementacja obejmuje również stworzenie instancji `MCSession` do wykorzystania w trakcie połączenia z serwerem oraz instancji `MCNearbyServiceAdvertiser`, która propaguje informacje o kliencie do wszystkich innych klientów w pobliżu. Do obu obiektów przypisujemy obiekt delegujący, który będzie właśnie utworzoną instancją `NoteEditingSessionClient`. Dla `MCNearbyServiceAdvertiser` obiekt delegującego musi implementować protokół `MCNearbyServiceAdvertiserDelegate`.
|
||||
|
||||
#let note_editing_session_client_init = [```swift
|
||||
init(peer: MCPeerID) {
|
||||
ownPeer = peer
|
||||
session = MCSession(
|
||||
peer: peer,
|
||||
securityIdentity: nil,
|
||||
encryptionPreference: .required
|
||||
)
|
||||
advertiser = MCNearbyServiceAdvertiser(
|
||||
peer: peer,
|
||||
discoveryInfo: [:],
|
||||
serviceType: "peered"
|
||||
)
|
||||
super.init()
|
||||
advertiser.delegate = self
|
||||
session.delegate = self
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_client_init,
|
||||
kind: raw,
|
||||
caption: [Implementacja konstruktora obiektu NoteEditingSessionClient],
|
||||
)
|
||||
|
||||
Instancja `NoteEditingSessionClient` jest tworzona już przy pierwszym uruchomieniu aplikacji. Po utworzeniu przez użytkownika przyjaznej nazwy, która będzie używana do identyfikacji, w tle wywoływana jest metoda `startBrowsingForNotes()`, która wywołuje `startAdvertisingPeer()` obiektu `advertiser`. Przed rozpoczęciem nasłuchiwania aplikacja wywołuje również `stopBrowsingForNotes()`, by zatrzymać nasłuchiwanie, jeśli wcześniej było ono rozpoczęte, oraz zatrzymuje działanie obiektu `MCSession`, by zamknąć ewentualnie istniejącą sesję edycji notatki. Obie metody zostały przedstawione w programie 3.11.
|
||||
|
||||
#let note_editing_session_client_edge_lifecycle = [```swift
|
||||
func startBrowsingForNotes() {
|
||||
advertiser.startAdvertisingPeer()
|
||||
}
|
||||
|
||||
func stopBrowsingForNotes() {
|
||||
advertiser.stopAdvertisingPeer()
|
||||
session.disconnect()
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_client_edge_lifecycle,
|
||||
kind: raw,
|
||||
caption: [Implementacja metod odpowiedzialnych za nasłuchiwanie na dostępne sesje edycji notatek],
|
||||
)
|
||||
|
||||
Program 3.12 przedstawia jedyną metodę wymaganą przez protokół `MCNearbyServiceAdvertiserDelegate` - nazywa się `advertiser()` i w argumentach przyjmuje informację o otrzymanym zaproszeniu i metadanych jakie to zaproszenie zawierało. Aplikacja próbuje zdekodować migawkę notatki, a następnie konstruuje obiekt `NoteInvitation` i umieszcza go w tablicy `invitations`. Umieszczenie w tablicy trzeba wykonać na głównym wątku, ponieważ, analogicznie jak w wypadku implementacji `MCNearbyServiceBrowserDelegate`, nie mamy gwarancji na jakim wątku będzie wykonywała się ta metoda.
|
||||
|
||||
#let note_editing_client_advertiser_did_receive_invitation = [```swift
|
||||
func advertiser(
|
||||
_ advertiser: MCNearbyServiceAdvertiser,
|
||||
didReceiveInvitationFromPeer peerID: MCPeerID,
|
||||
withContext context: Data?,
|
||||
invitationHandler: @escaping (Bool, MCSession?) -> Void
|
||||
) {
|
||||
guard
|
||||
let context,
|
||||
let noteContent = try? JSONDecoder().decode(NoteInvitation.NoteContent.self, from: context)
|
||||
else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.append(
|
||||
.init(
|
||||
invitatorID: peerID,
|
||||
note: noteContent,
|
||||
invitationHandler: { [weak self, invitationHandler] accepted in
|
||||
guard let self else { return }
|
||||
invitationHandler(accepted, self.session)
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.removeAll { $0.id == peerID }
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_client_advertiser_did_receive_invitation,
|
||||
kind: raw,
|
||||
caption: [Implementacja metody advertiser wywoływanej podczas otrzymania zaproszenia do sesji],
|
||||
)
|
||||
|
||||
== Transportowanie danych
|
||||
|
||||
W wysyłanym zaproszeniu wysyłamy w metadanych migawkę notatki zawierającą jej tytuł oraz ostatnio dostępną treść. Migawka jest kodowana w formacie JSON bazując na strukturze `NoteContent`. Przykładowym poprawnym zapisem JSON tej struktury jest przykład w programie 3.13.
|
||||
|
||||
#let note_content_json_representation = [```json
|
||||
{
|
||||
"title": "My new note",
|
||||
"noteSnapshot": "Lorem Ipsum."
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_content_json_representation,
|
||||
kind: raw,
|
||||
caption: [Reprezentacja obiektu NoteContent w zapisie JSON],
|
||||
)
|
||||
|
||||
Po stronie klienta, każda zmiana jest ogłaszana serwerowi poprzez wywołanie metody `send()` obiektu `NoteEditingSessionClient`, zapisaną w programie 3.14, która przyjmuje identyfikator użytkownika do które ma wiadomość trafić oraz całą zawartość notatki. Jej implementacja zamienia notatkę wraz z identyfikatorem w typ `NoteMessage`, następnie koduje ją do formatu JSON, finalnie próbuje ją wysłać do określonego użytkownika.
|
||||
|
||||
#let note_editing_session_client_send_note = [```swift
|
||||
func send(note: String, to peer: MCPeerID) {
|
||||
let message = NoteMessage(senderID: ownPeer.displayName, content: note)
|
||||
guard let data = try? JSONEncoder().encode(message) else { return }
|
||||
try? session.send(data, toPeers: [peer], with: .reliable)
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_editing_session_client_send_note,
|
||||
kind: raw,
|
||||
caption: [Implementacja metody send wysyłającej kopię notatki do serwera],
|
||||
)
|
||||
|
||||
Przykładowy zapis instancji obiektu `NoteMessage` wygląda tak jak w programie 3.15:
|
||||
|
||||
#let note_message_json_representation = [```json
|
||||
{
|
||||
"senderID": "User2",
|
||||
"content": "Lorem Ipsum Test"
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_message_json_representation,
|
||||
kind: raw,
|
||||
caption: [Reprezentacja obiektu NoteMessage w zapisie JSON],
|
||||
)
|
||||
|
||||
Po tym jak serwer odbierze wysłaną wiadomość, wywoływana jest metoda `session`, widoczna w programie 3.16, która w argumentach przekazuje zakodowane dane, sesję serwera oraz identyfikator użytkownika, który wysłał załączone dane. Po udanym zdekodowaniu danych, wybieramy wszystkich użytkowników, którzy dołączyli do sesji edycji notatki i wysyłamy do nich kopię otrzymanej wiadomości, a serwer dodatkowo wysyła identyczną kopię do warstwy prezentacji.
|
||||
|
||||
|
||||
#let code_session_did_receive_data_server = [```swift
|
||||
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
|
||||
guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return }
|
||||
let otherPeers = session.connectedPeers.filter { $0 != peerID }
|
||||
|
||||
if !otherPeers.isEmpty {
|
||||
try? session.send(data, toPeers: otherPeers, with: .reliable)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.noteChangesEmitter.send(message)
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
code_session_did_receive_data_server,
|
||||
kind: raw,
|
||||
caption: [Implementacja metody session do otrzymywania danych od innych klientów],
|
||||
)
|
||||
|
||||
== Algorytm rozwiązywania konfliktów
|
||||
|
||||
Rozwiązywanie konfliktów jest dokonywane na podstawie strategii Last Writer Wins, gdzie ostatnio otrzymana wiadomość nadpisuje zawartość pozostałych kopii. W przypadku nanoszenia zmian na interfejs użytkownika, wykorzystałem natywną obsługę zmiany tekstu przez systemowy kompoent `TextEditor`, który w większości wypadków potrafi sobie poradzić czy prostych podmianach zawartości tekstu.
|
||||
|
||||
Zrezygnowałem z implementacji struktur danych CRDT ze względu na bardzo wysoki koszt implementacji, ponieważ oprócz samych struktur, musiałbym napisać własną implementację obsługi podmiany tekstu i aktualizacji kursora w edytorze tekstowym, co znacznie wykracza poza zakres tej pracy.
|
||||
|
||||
== Środowisko developerskie i stack technologiczny
|
||||
|
||||
Uruchomienie projektu wymaga posiadania komputera Macintosh z systemem operacyjnym macOS w wersji 26.0 (Tahoe) oraz środowisko programistyczne Xcode w wersji 26.0. Do uruchomienia aplikacji potrzebne jest założenie konta Apple ID i zaakceptowania warunków użytkowania konta deweloperskiego, dzięki któremu można wygenerować certyfikat, którego potrzebuje Xcode do zbudowania aplikacji.
|
||||
|
||||
Aplikacja wykorzystuje framework SwiftUI do budowania warstwy prezentacji. Jest to deklaratywny, wieloplatformowy framework między innymi dostarczający mechanizmy, które zostały wykorzystane w ramach tej pracy:
|
||||
- automatycznego odświeżania interfejsu na podstawie nasłuchiwania na zmiany modelów danych
|
||||
- automatycznego zapisu prymitywnych danych na dysku
|
||||
- wstrzykiwania zależności wgłąb hierarchii interfejsu
|
||||
- zaawansowanych wzorców stosowanych w tworzeniu dobrych doświadczeń użytkownika - np. wysuwalne panele (bottom sheets).
|
||||
|
||||
Kluczowym frameworkiem w budowaniu tej aplikacji jest Multipeer Connectivity, który zapewniał ułatwioną konfigurację całej komunikacji między pobliskimi użytkownikami. Pomocniczymi bibliotekami są `Foundation` oraz `Combine`.
|
||||
|
||||
`Foundation` daje dostęp do złożonych, ale bardzo często wykorzystywanych typów i funkcji w Swifcie - `URL`, `FileManager`, `JSONDecoder`, `JSONEncoder`.
|
||||
|
||||
`Combine` jest biblioteką dodającą programowanie reaktywne do Swifta, ale również jako pierwsza zapewniła mechanizmy komunikowania zmian w modelach danych do widoków implementowanych w SwiftUI. Moja praca wykorzystywała przede wszystkim obiekty `PassthroughSubjects`, które służyły za obiekty emitujące każde nowo otrzymane dane od innych użytkowników.
|
||||
|
||||
== Interfejs użytkownika
|
||||
|
||||
Aplikacja po uruchomieniu przez użytkownika który już ustawił swoją nazwę, pokazuje ekran główny, zaprezentowany na rysunku 3.1, który pogrubioną czcionką o dużym rozmiarze pokazuje nazwę aplikacji - "Peered". Na prawo do góry od napisu znajdziemy przycisk "Create note", który po naciśnięciu dodaje nową notatkę do listy "Your notes". Główną zawartością ekranu jest lista notatek podzielona na dwie sekcje - "Your notes" zawierająca notatki przechowywane w pamięci trwałej urządzenia, oraz "External notes" - notatki które aktualnie udostępnia inny klient w pobliżu urządzenia użytkownika. Po naciśnięciu na wiersz z nazwą notatki z sekcji "Your notes", przejdziemy do ekranu edycji własnej notatki przedstawionego na rysunku 3.2. Ten ekran, oprócz edytora tekstu, na samej górze posiada przycisk "Manage members", który pokazuje ekran zarządzania użytkownikami notatki, widoczny na rysunku 3.3. Możemy na nim zobaczyć listę widocznych użytkowników w pobliżu oraz możliwą akcję do wykonania. Jeśli użytkownik jest widoczny, ale nie został zaproszony, możemy zobaczyć przycisk "Invite". W pozostałych wypadkach widzimy teksty zależne od stanu połączenia - użytkownik, który dołączył będzie miał "Joined". Oczekujący na akceptację będą mieli "Invitation pending", a pozostali "Rejected". Po naciśnięciu na wiersz z nazwą notatki z sekcji "External notes", przejdziemy do ekranu edycji notatki innego użytkownika, gdzie na ekranie będziemy mieli wyłącznie dostęp do edytora tekstu, co można zobaczyć na rysunku 3.4. Jeśli użytkownik nie ustawił jeszcze swojej nazwy w aplikacji, od razu po uruchomieniu aplikacji pokazuje się ekran nadania nazwy użytkownika, widoczny na rysunku 3.5.
|
||||
|
||||
Jeśli aplikacja została dopiero zainstalowana, system pokaże też monit użytkownikowi z pytaniem, czy chce aby aplikacja otrzymała dostęp do sieci lokalnej, co zostało przedstawione na rysunku 3.6. Jest to obowiązkowa zgoda dla użytkownika, bez której aplikacja nie będzie w stanie komunikować się z innymi urządzeniami.
|
||||
|
||||
#figure(
|
||||
image("../Pictures/AllNotesScreen.jpeg", height: 95%),
|
||||
kind: image,
|
||||
caption: "Ekran główny aplikacji",
|
||||
)
|
||||
#figure(
|
||||
image("../Pictures/NoteEditorScreen.jpeg", height: 95%),
|
||||
kind: image,
|
||||
caption: "Ekran edycji własnej notatki",
|
||||
)
|
||||
#figure(
|
||||
image("../Pictures/ManageMembersScreen.jpeg", height: 95%),
|
||||
kind: image,
|
||||
caption: "Ekran zarządzania użytkownikami notatki",
|
||||
)
|
||||
#figure(
|
||||
image("../Pictures/SharedEditorScreen.jpeg", height: 95%),
|
||||
kind: image,
|
||||
caption: "Ekran edycji notatki innego użytkownika",
|
||||
)
|
||||
#figure(
|
||||
image("../Pictures/SetUsernameScreen.jpeg", height: 95%),
|
||||
kind: image,
|
||||
caption: "Ekran nadania nazwy użytkownika",
|
||||
)
|
||||
#figure(
|
||||
image("../Pictures/LocalNetworkUsageAlert.jpeg", height: 95%),
|
||||
kind: image,
|
||||
caption: "Monit z prośbą o dostęp do sieci lokalnej",
|
||||
)
|
||||
|
||||
== Napotkane wyzwania implementacyjne i rozwiązania
|
||||
|
||||
W czasie implementacji opisywanej aplikacji natknąłem się na problemy, które były związane z poszczególnymi częściami synchronizacji tekstu. Pierwszym z nich jest zachowanie frameworka Multipeer Connectivity, gdzie nasłuchiwanie na dostępnych użytkowników za pomocą `MCNearbyServiceBrowser` wykrywa także samego siebie jako jednego z dostępnych klientów. Skutkowało to wysyłaniem zmian przez urządzenie do samego siebie oraz otrzymywanie zduplikowanych kopii notatek z innych urządzeń. By temu zapobiec, w metodzie obsługującej wykrywanie dostępnych użytkowników - widocznej w programie 3.7 - zaimplementowałem filtr, który ignoruje użytkowników o takiej samej nazwie użytkownika.
|
||||
|
||||
Następnym problemem związanym z Multipeer Connectivity jest jego ogólna niestabilność. W sytuacjach, gdzie połączenie staje się niestabilne - oddalenie się od siebie użytkowników, gdy do komunikacji jest wykorzystywany Bluetooth; przełączanie się między sieciami Wi-Fi - otrzymywane zdarzenia nie są deterministyczne. Czasem aplikacja otrzymywała na przemian informacje o rozłączeniu i ponownym połączeniu się z zaproszonymi klientami, czasem nigdy nie otrzymywała informacji o tym, że klient się rozłączył. Podobne problemy zostały zauważone w momencie wyjścia z aplikacji - wielokrotnie aplikacja traciła połączenie z innymi klientami, ale metody, które powinny zostać z tego powodu wywołane, czasami nie były wykonywane.
|
||||
|
||||
Ostatnim, najcięższym problemem jest aktualizacja pozycji kursora w edytorze tekstu. W momencie nanoszenia zmian należy rozważyć, czy został wstawiony lub usunięty tekst z części notatki, która znajduje się na wcześniejszej pozycji względem kursora każdego z użytkowników. Jeśli tak, to poprawnym zachowaniem byłoby przeniesienie odpowiednio kursora tak, by żaden z klientów nie spotkał się z sytuacją, gdzie kursor zmienia swoje położenie bez wyraźnej akcji użytkownika. Jest to problematyczne w aktualnej implementacji z dwóch powodów. Pierwszym problemem jest algorytm nanoszenia zmian - ze względu na to, że sposób w jaki ten proces wykonujemy polega na całkowitej podmianie istniejącego tekstu na nowy, powinno się przed jego wykonaniem ustalić wcześniej wspomniane istnienie zmian tekstu na pozycji wcześniejszej od kursora użytkownika. Przez to, że nie wykorzystywane są tutaj struktury CRDT, które mają bardzo dokładne informacje o nanoszonych zmianach, musimy polegać na ręcznej analizie obu ciągów tekstowych. Takie przeliczanie jest też problematyczne w przypadku obsługi wszystkich wspieranych przez system alfabetów. Następnym problemem jest niestabilny interfejs do manipulacji pozycją kursora w edytorze tekstu dostarczanym przez SwiftUI. Proces aktualizacji polega najpierw na wyliczeniu punktu przesunięcia kursora, następnie zamianę tekstu w edytorze na zaktualizowaną wersję i finalnie podmianę pozycji kursora. Testy manualne aplikacji wykazały, że kolejność nanoszenia dwóch ostatnich zmian an interfejs graficzny użytkownika przez SwiftUI jest niedeterministyczne. Zdarzały się sytuacje, gdzie pozycja kursora była aktualizowana przed naniesieniem nowej wersji tekstu. Bardzo często to się zdarzało w przypadku, gdy zawartość tekstowa była wielkości przynajmniej 1000 znaków. Najstabilniejszym rozwiązaniem, które nie wymagało implementacji struktur CRDT oraz implementacji kursora od zera z wykorzystaniem starszego frameworka - UIKit - okazało się pozostawienie domyślnego zachowania podczas zewnętrznej aktualizacji tekstu w edytorze. Niestety nadal można zaobserwować błędy z tym związane, ale nie są one tak częste jak w przypadku, gdy tym kursorem aplikacja próbowała dodatkowo manipulować obok istniejącego mechanizmu dostarczanego przez SwiftUI, którego nie da się wyłączyć.
|
||||
|
||||
== Ograniczenia środowisk iOS/iPadOS
|
||||
|
||||
Ze względu na to, że aplikacja została napisana w pełni z wykorzystaniem natywnych technologii - Swift, SwiftUI, Combine, Multipeer Connectivity - projekt aplikacji jest niemożliwy do zbudowania bez posiadania komputera z najnowszą wersją systemu macOS oraz środowiska programistycznego Xcode. Kolejnym problemem jest obowiązek posiadania konta Apple Developer, który wymaga wyrażenia zgody na regulamin użytkowania oprogramowania dostarczanego przez firmę Apple do rozwoju aplikacji na systemy operacyjne iOS i iPadOS. Wszystkie wspomniane wymogi sprawiają, że kontrybucja do kodu źródłowego aplikacji jak i jej kompilacja we własnym zakresie jest bardzo utrudniona.
|
||||
|
||||
Następnym problemem jest interfejs frameworku Multipeer Connectivity. Opiera się on na przekazywaniu obustronnie specjalnych obiektów oraz narzuca konkretne sposoby wymiany danych między instancjami aplikacji. Sprawia to, że implementacja, bez tworzenia dodatkowych warstw abstrakcji, nie jest możliwa do rozszerzenia o wsparcie innych systemów operacyjnych. Rozważaną alternatywą było użycie frameworku Network, który opiera się na przesyłaniu ramek TCP, ale to znacznie zwiększało złożoność całej implementacji.
|
||||
@@ -1,40 +0,0 @@
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Wymagania systemowe <wymagania>
|
||||
W poniższym rozdziale opiszę specyfikację wymagań dla projektowanej aplikacji do współtworzenia notatek w architekturze peer to peer. Celem jest określenie zakresu funkcjonalności oraz ograniczeń technologicznych, których będę się trzymać w ramach proponowanej implementacji. Wyodrębnię dwóch aktorów:
|
||||
|
||||
- Użytkownika - osoba zarządzająca notatkami przez interfejs graficzny zaimplementowany w ramach implementacji,
|
||||
- Klient - instancja implementowanej aplikacji na urządzeniu fizycznym.
|
||||
|
||||
Same wymagania zostały podzielone odpowiednio na wymagania funkcjonalne i niefunkcjonalne.
|
||||
|
||||
== Wymagania funkcjonalne
|
||||
- System musi umożliwiać rozgłaszanie swojej obecności w sieci lokalnej, by móc zostać wykrytym przez innych klientów.
|
||||
- System musi umożliwiać wyszukiwanie innych aktywnych klientów znajdujących się w zakresie lokalnego otoczenia oraz lokalnej sieci.
|
||||
- System musi pozwalać na wysyłanie zaproszeń do połączenia się z wykrytymi klientami.
|
||||
- System musi na bieżąco wyświetlać listę aktualnie połączonych klientów.
|
||||
|
||||
- System musi umożliwiać utworzenie nowej notatki tekstowej.
|
||||
- System musi umożliwiać edycję treści istniejącej notatki.
|
||||
- System musi umożliwiać trwałe usunięcie notatki.
|
||||
- System musi automatycznie tworzyć unikalny identyfikator oraz znacznik czasowy dla nowych notatek.
|
||||
- System musi pozwalać na zdefiniowanie tytułu notatki, będącego niezależnym parametrem od treści notatki.
|
||||
|
||||
- System musi enkodować strukturę danych notatki do formatu umożliwiającego przesył notatki do połączonych klientów.
|
||||
- System musi dekodować otrzymane zakodowane dane o notatce i przekształcić je w natywny obiekt reprezentujący notatkę w systemie.
|
||||
- System musi automatycznie rozsyłać zaktualizowaną treść notatki do wszystkich aktualnie połączonych klientów w jak najkrótszym czasie od wykrycia zmian.
|
||||
- System musi nadpisać istniejącą notatkę nowo otrzymaną kopią, gdy obie posiadają ten sam identyfikator, ale otrzymana kopia zawiera nowszy znacznik czasowy.
|
||||
|
||||
- System musi zapisywać wszystkie notatki w trwałej pamięci urządzenia.
|
||||
- System musi odświeżać interfejs z listą notatek w czasie rzeczywistym.
|
||||
|
||||
== Wymagania niefunkcjonalne
|
||||
- System musi wspierać urządzenia mobilne firmy Apple z zainstalowanymi systemami operacyjnymi iOS lub iPadOS w wersji 18.0 lub wyższej.
|
||||
- Kod źródłowy powinien być napisany w języku Swift z wykorzystaniem deklaratywnego frameworka do budowy interfejsów graficznych - SwiftUI.
|
||||
- Operacje zapisu do plików oraz procesy sieciowe muszą być wykonywane poza głównym wątkiem - wątkiem obsługującym interfejs graficzny aplikacji.
|
||||
- Czas propagacji zmian w notatce do innego połączonego klienta nie powinien wynosić więcej niż 1 sekunda.
|
||||
- System musi obsłużyć przypadek zerwania połączenia, bez uszkodzenia notatki oraz rzucania wyjątków uniemożliwiających dalsze funkcjonowanie systemu.
|
||||
- System musi zapewnić szyfrowaną komunikację między klientami.
|
||||
- Interfejs użytkownika musi być responsywny i dostosowywać się do różnych rozmiarów ekranów i ich orientacji w zakresie urządzeń tworzonych przez Apple.
|
||||
- Wygląd aplikacji powinien spełniać oficjalne wytyczne projektowe Apple Human Interface Guideliens.
|
||||
- System powinien automatycznie dostosowywać paletę kolorów interfejsu graficznego do aktualnie ustawionego motywu systemowego.
|
||||
@@ -1,309 +0,0 @@
|
||||
#import "../style.typ"
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Implementacja <implementacja>
|
||||
== Projekt architektury systemu <projekt>
|
||||
Przygotowana implementacja bazuje na architekturze Model-View. Jest to podejście, gdzie cała logika biznesowa jest zawarta w modelach, które bezpośrednio są przekazywane do warstwy prezentacji (View). Na tej warstwie jest wykonywane odpowiednie formatowanie danych, gdzie też brane pod uwagę są preferencje zapisu i językowe użytkownika. Model-View to uproszczony wariant popularnej w aplikacjach mobilnych architektury Model-View-ViewModel (MVVM), gdzie ViewModel jest warstwą zajmującą się przekształcaniem modelów biznesowych na gotowe do prezentacji obiekty. Warstwa View wtedy zajmuje się przede wszystkim definiowaniem struktury interfejsu użytkownika oraz logiką związaną z dostępnością (wsparcie dla funkcjonalności czytników ekranów, skalowaniem interfejsu). Przygotowana implementacja nie zawiera złożonej logiki prezentacji ani rozbudowanego graficznego interfejsu użytkownika, więc w celu uproszczenia kodu zdecydowałem się na porzucenie użycia warstwy ViewModelu.
|
||||
|
||||
Warstwa prezentacji aplikacji opisująca początkowy interfejs użytkownika jest zaimplementowana w `AllNotesScreen`. Ekran jest widoczny dla użytkownika, gdy ustawił swoją nazwę użytkownika w sieci peer-to-peer. Składa się z listy podzielonej na dwie sekcje - notatek których użytkownik jest autorem, oraz notatek do których użytkownik został zaproszony. Nad listą znajduje się przycisk stworzenia nowej notatki. Naciśnięcie na którąkolwiek z istniejących notatek prowadzi do otwarcia jej zawartości. W zależności od tego, czy użytkownik jest właścicielem danej notatki czy współtwórcą, interfejsem do edycji tej notatki są odpowiednio obiekty `NoteEditorScreen` oraz `SharedNoteEditor`. Z ekranu `NoteEditorScreen` możemy przejść do `ManageMembersScreen`, który jest listą użytkowników zaproszonych i możliwych do zaproszenia do edytowania aktualnej notatki.
|
||||
|
||||
== Model danych
|
||||
Podstawowym obiektem reprezentującym notatkę w systemie plików jest `Note`. Składa się on z URL (Universal Resource Locator), czyli ścieżki prowadzącej do pełnej zawartości notatki użytkownika, która jest przechowywana w pliku tekstowym o rozszerzeniu `txt`. Dodatkowo przechowujemy w tym obiekcie jeszcze `name`, które pełni funkcję ułatwionego dostępu do przyjaznej nazwy, jednocześnie będąc nazwą pliku w lokalnym systemie plików oraz przyjazną nazwą prezentowaną w liście notatek do których użytkownik został zaproszony. Obiekt implementuje protokół `Identifiable`, który gwarantuje możliwość identyfikacji obiektu w zbiorze zawierającym wiele jego instancji, np. w tablicy. Ta cecha jest wymagana i wykorzystywana przez SwiftUI, by móc rozróżniać rodzaje zmian na komponentach interfejsów graficznych zawierających wiele kopii takiego obiektu, np. `List` lub `ForEach`. Umożliwia to dokonanie decyzji co do sposobu animacji zmian na ekranie użytkownika, ponieważ framework będzie mógł rozróżnić usunięcie i wstawienie nowego obiektu, od zmiany parametrów tej samej instancji.
|
||||
|
||||
```swift
|
||||
struct Note: Identifiable {
|
||||
var id: URL { path }
|
||||
|
||||
let name: String
|
||||
let path: URL
|
||||
}
|
||||
```
|
||||
|
||||
`NoteMessage` to obiekt zawierający zawartość notatki, którą wysyłamy do użytkowników, którzy przyjęli zaproszenie do edycji notatki. Znajdziemy w niej pola `SenderID`, które jest identyfikatorem właściciela notatki oraz `content`, czyli właściwą zawartość notatki. Jest ona również używana do wysyłania każdej aktualizacji do wszystkich użytkowników. Obiekt implementuje protokół `Codable`, który jest uniwersalnym interfejsem kodowania danych. Daje to nam wbudowane wsparcie kodowowania do formatów JSON i XML. Mamy możliwość również pisania własnych koderów, które umożliwią zamianę obiektów implementujących `Codable` do wybranych przez nas, innych formatów danych.
|
||||
|
||||
```swift
|
||||
struct NoteMessage: Codable {
|
||||
let senderID: String
|
||||
let content: String
|
||||
}
|
||||
```
|
||||
|
||||
Do reprezentacji zaproszeń stworzyłem obiekt `NoteInvitation`. Przechowuje on informacje potrzebne do obsługi całego procesu zaproszeń między użytkownikami systemu. Składa się z `invitatorID`, który jest wyspecjalizowanym obiektem frameworku `MultipeerConnectivity` i umożliwia identyfikację użytkownika w czasie komunikacji peer to peer. `note` to obiekt reprezentujący notatkę, którą otrzymujemy w zaproszeniu. Składa się on z tytułu i treści, które były aktualne w momencie zapraszania użytkownika. Ostatnim i jednocześnie prywatnym parametrem jest `invitationHandler`, który jest typem funkcji przyjmującej wartość boolowską (prawda/fałsz). Jest on wykorzystywany do wstrzyknięcia logiki akceptacji notatki, która wymaga kilku operacji w logice klasy obsługującej przyjmowanie zaproszeń. Dzięki takiemu podejściu tworzymy luźną zależność do skomplikowanego obiektu na dalszych etapach systemu. `NoteInvitation` zawiera również metody `accept()` oraz `decline()`, które odpowiadają za wykonanie akcji zaproszenia, które pod spodem odpowiednio używają parametru `invitationHandler`, który można wykonać tylko raz, ze względu na specyfikę `Multipeer Connectivity`, a następnie usuwamy go z pamięci.
|
||||
|
||||
```swift
|
||||
struct NoteInvitation: Identifiable {
|
||||
struct NoteContent: Codable {
|
||||
let title: String
|
||||
let noteSnapshot: String
|
||||
}
|
||||
|
||||
var id: MCPeerID { invitatorID }
|
||||
var noteName: String { note.title }
|
||||
let invitatorID: MCPeerID
|
||||
let note: NoteContent
|
||||
private var invitationHandler: ((Bool) -> Void)?
|
||||
|
||||
init(
|
||||
invitatorID: MCPeerID,
|
||||
note: NoteContent,
|
||||
invitationHandler: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
self.invitatorID = invitatorID
|
||||
self.note = note
|
||||
self.invitationHandler = invitationHandler
|
||||
}
|
||||
|
||||
mutating func accept() {
|
||||
invitationHandler?(true)
|
||||
invitationHandler = nil
|
||||
}
|
||||
|
||||
mutating func decline() {
|
||||
invitationHandler?(false)
|
||||
invitationHandler = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ostatnim obiektem jest struktura `Peer`, która jest opisem aktualnego stanu połączenia wykrytego innej instancji systemu w pobliżu użytkownika. Składa się z wartości enumerowanej opisującej stan połączeniao raz identyfikatorem użytkownika w sieci peer to peer. Implementuja ona protokół `Idenfitiable`, by móc zostać poprawnie użyta do rysowania listy dostępnych klientów w pobliżu użytkownika.
|
||||
|
||||
```swift
|
||||
struct Peer: Identifiable {
|
||||
enum ConnectionState {
|
||||
case available
|
||||
case joined
|
||||
case rejected
|
||||
case invitationPending
|
||||
}
|
||||
|
||||
var id: String { mcPeer.displayName }
|
||||
let mcPeer: MCPeerID
|
||||
var state: ConnectionState
|
||||
}
|
||||
```
|
||||
|
||||
== Warstwa sieciowa i komunikacja P2P
|
||||
Całość komunikacji między urządzeniami odbywa się z wykorzystaniem frameworka Multipeer Connectivity. Klient twórcy notatki pełni rolę serwera, a pozostali użytkownicy, po uprzednim zaproszeniu, mogą dołączyć do edycji notatki, wysyłać swoje zmiany jak i odbierać zmiany, które dystrybuuje serwer.
|
||||
|
||||
== Odkrywanie innych urządzeń
|
||||
Obiekt reprezentujący serwer został nazwany `NoteEditingSessionServer`, który dziedziczy właściowści po klasie `NSObject`, która jest uniwersalną implementacją wielu zachowań, które są wymagane od frameworków udostępnianych przez Apple, które zostały napisane w języku Objective-C. Jego konstruktor w przyjmowanych argumentach oczekuje tylko obiektu `OwnPeer`, który będzie wykorzystywany do identyfikacji instancji aplikacji u innych klientów. Sama implementacja konstruktora tworzy nową sesję `MCSession`; obiekt `MCNearbyServiceBrowser`, który odpowiada za wykrywanie pobliskich klientów. Finalnie przypisuje referencję do samego siebie jako parametr `delegate` dla utworzonych `MCSession` i `MCNearbyServiceBrowser`. Pozwala nam to zaimplementować metody, które będą wykorzystywane wewnątrz tych obiektów do komunikacji z innymi użytkownikami. Protokoły delegujące dla wspomnianych obiektów nazywają się odpowiednio `MCSessionDelegate` oraz `MCNearbyServiceBrowserDelegate`. Moja implementacja tych protokołów zostanie przedstawiona w dalszej części pracy.
|
||||
|
||||
```swift
|
||||
init(peer: OwnPeer) {
|
||||
ownPeer = peer
|
||||
browser = .init(peer: peer.peer, serviceType: "peered")
|
||||
session = .init(peer: peer.peer, securityIdentity: nil, encryptionPreference: .required)
|
||||
super.init() // wykonuje pozostałą część
|
||||
browser.delegate = self //
|
||||
session.delegate = self
|
||||
}
|
||||
```
|
||||
|
||||
W momencie, gdy autor notatki otworzy ekran edycji, wykonuje się metoda `startServer()`, która wywołuje metodę `startBrowsingForPeers()` obiektu `MCNearbyServiceBrowser`. Opuszczenie ekranu edycji wywołuje metodę `stopServer()`, która wywołuje analogiczną metodę `stopBrowsingForPeers()` oraz zatrzymuje sesję poprzez wywołanie metody `disconnect()` obiektu `MCSession`.
|
||||
|
||||
```swift
|
||||
func startServer() {
|
||||
browser.startBrowsingForPeers()
|
||||
}
|
||||
|
||||
func stopServer() {
|
||||
browser.stopBrowsingForPeers()
|
||||
session.disconnect()
|
||||
}
|
||||
```
|
||||
|
||||
Obiekt `browser` w momencie wykrycia nowego użytkownika w pobliżu, wywołuje naszą metodę o nazwie `browser()`, która przyjmuje wszystkie potrzebne informacje o znalezionym użytkowniku. Implementacja mojego systemu następnie upewnia się czy odkryty użytkownik nie jest jednocześnie autorem notatki, co jest znanym błędem w Multipeer Connectivity. Następnie po udanej weryfikacji dodajemy nowy obiekt dostępnego użytkownika do tablicy na podstawie której jest budowany interfejs z listą dostępnych użytkowników.
|
||||
|
||||
```swift
|
||||
func browser(
|
||||
_ browser: MCNearbyServiceBrowser,
|
||||
foundPeer peerID: MCPeerID,
|
||||
withDiscoveryInfo info: [String: String]?
|
||||
) {
|
||||
guard !visiblePeers.contains(where: { $0.mcPeer == peerID }) && peerID.displayName != ownPeer.peer.displayName else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.visiblePeers.append(Peer(mcPeer: peerID, state: .available))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
W sytuacji gdy użytkownik straci połączenie z połączonym klientem, następuje usunięcie jego identyfikatora z listy, poprzez wywołanie metody o takiej samej nazwie, ale z parametrami wskazującymi na scenariusz zgubienia klienta.
|
||||
|
||||
```swift
|
||||
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
|
||||
DispatchQueue.main.async {
|
||||
guard let peerIdx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
self.visiblePeers.remove(at: peerIdx)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Każda modyfikacja obiektów klasy w tym wypadku musi zostać wywołana na tym samym wątku, ponieważ Multipeer Connectivity nie gwarantuje, że kod będzie się wykonał zawsze na tym samym wątku. Wybrałem wątek główny, ze względu na bezpośrednie użycie właściwości klasy wewnątrz obiektów odpowiedzialnych za budowę interfejsu użytkownika.
|
||||
|
||||
O stanie połączenia z innymi klientami mój system jest informowany przez wykonanie metody session z parametrami zawierającymi informację o stanie, który jest reprezentowany przez typ enumeracji, obiekt sesji oraz identyfikator klienta, których ten stan połączenia dotyczy. Na podstawie tych argumentów, aplikacja aktualizuje tablicę `visiblePeers`.
|
||||
|
||||
```swift
|
||||
func session(
|
||||
_ session: MCSession,
|
||||
peer peerID: MCPeerID,
|
||||
didChange state: MCSessionState
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
guard let idx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||||
switch state {
|
||||
case .connected:
|
||||
self.visiblePeers[idx].state = .joined
|
||||
case .notConnected:
|
||||
let currentState = self.visiblePeers[idx].state
|
||||
if currentState == .invitationPending || currentState == .joined {
|
||||
self.visiblePeers[idx].state = .rejected
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Przechodząc do implementacji klienta, całość jest reprezentowana przez obiekt `NoteEditingSessionClient`. Jego konstruktor przyjmuje tylko identyfikator użytkownika, który jest typem `MCPeerID`, a implementacja obejmuje również stworzenie instancji `MCSession` do wykorzystania w trakcie połączenia z serwerem oraz instancji `MCNearbyServiceAdvertiser`, która propaguje informacje o kliencie do wszystkich innych klientów w pobliżu. Do obu obiektów przypisujemy obiekt delegujący, który będzie właśnie utworzoną instancją `NoteEditingSessionClient`. Dla `MCNearbyServiceAdvertiser` obiekt delegującego musi implementować protokół `MCNearbyServiceAdvertiserDelegate`.
|
||||
|
||||
```swift
|
||||
init(peer: MCPeerID) {
|
||||
ownPeer = peer
|
||||
session = MCSession(
|
||||
peer: peer,
|
||||
securityIdentity: nil,
|
||||
encryptionPreference: .required
|
||||
)
|
||||
advertiser = MCNearbyServiceAdvertiser(
|
||||
peer: peer,
|
||||
discoveryInfo: [:],
|
||||
serviceType: "peered"
|
||||
)
|
||||
super.init()
|
||||
advertiser.delegate = self
|
||||
session.delegate = self
|
||||
}
|
||||
```
|
||||
|
||||
Instancja `NoteEditingSessionClient` jest tworzona już przy pierwszym uruchomieniu aplikacji. Po utworzeniu przez użytkownika przyjaznej nazwy, która będzie używana do identyfikacji, w tle wywoływana jest metoda `startBrowsingForNotes()`, która wywołuje `startAdvertisingPeer()` obiektu `advertiser`. Przed rozpoczęciem nasłuchiwania aplikacja wywołuje również `stopBrowsingForNotes()`, by zatrzymać nasłuchiwanie, jeśli wcześniej było ono rozpoczęte, oraz zatrzymuje działanie obiektu `MCSession`, by zamknąć ewentualnie istniejącą sesję edycji notatki.
|
||||
|
||||
```swift
|
||||
func startBrowsingForNotes() {
|
||||
advertiser.startAdvertisingPeer()
|
||||
}
|
||||
|
||||
func stopBrowsingForNotes() {
|
||||
advertiser.stopAdvertisingPeer()
|
||||
session.disconnect()
|
||||
}
|
||||
```
|
||||
|
||||
Jedyna metoda wymagana przez protokół `MCNearbyServiceAdvertiserDelegate` nazywa się `adverties()` i w argumentach przyjmuje informację o otrzymanym zaproszeniu i metadanych jakie to zaproszenie zawierało. Aplikacja próbuje zdekodować migawkę notatki, a następnie konstruuje obiekt `NoteInvitation` i umieszcza go w tablicy `invitations`. Umieszczenie w tablicy trzeba wykonać na głównym wątku, ponieważ, analogicznie jak w wypadku implementacji `MCNearbyServiceBrowserDelegate`, nie mamy gwarancji na jakim wątku będzie wykonywała się ta metoda.
|
||||
|
||||
```swift
|
||||
|
||||
|
||||
func advertiser(
|
||||
_ advertiser: MCNearbyServiceAdvertiser,
|
||||
didReceiveInvitationFromPeer peerID: MCPeerID,
|
||||
withContext context: Data?,
|
||||
invitationHandler: @escaping (Bool, MCSession?) -> Void
|
||||
) {
|
||||
guard
|
||||
let context,
|
||||
let noteContent = try? JSONDecoder().decode(NoteInvitation.NoteContent.self, from: context)
|
||||
else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.append(
|
||||
.init(
|
||||
invitatorID: peerID,
|
||||
note: noteContent,
|
||||
invitationHandler: { [weak self, invitationHandler] accepted in
|
||||
guard let self else { return }
|
||||
invitationHandler(accepted, self.session)
|
||||
DispatchQueue.main.async {
|
||||
self.invitations.removeAll { $0.id == peerID }
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
== Transportowanie danych
|
||||
|
||||
W wysyłanym zaproszeniu wysyłamy w metadanych migawkę notatki zawierającą jej tytuł oraz ostatnio dostępną treść. Migawka jest kodowana w formacie JSON bazując na strukturze `NoteContent`. Przykładowym poprawnym zapisem JSON tej struktury jest poniższy przykład
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My new note",
|
||||
"noteSnapshot": "Lorem Ipsum."
|
||||
}
|
||||
```
|
||||
|
||||
Po stronie klienta, każda zmiana jest ogłaszana serwerowi poprzez wywołanie metody `send()` obiektu `NoteEditingSessionClient`, która przyjmuje identyfikator użytkownika do które ma wiadomość trafić oraz całą zawartość notatki. Jej implementacja zamienia notatkę wraz z identyfikatorem w typ `NoteMessage`, następnie koduje ją do formatu JSON, finalnie próbuje ją wysłać do określonego użytkownika.
|
||||
|
||||
```swift
|
||||
func send(note: String, to peer: MCPeerID) {
|
||||
let message = NoteMessage(senderID: ownPeer.displayName, content: note)
|
||||
guard let data = try? JSONEncoder().encode(message) else { return }
|
||||
try? session.send(data, toPeers: [peer], with: .reliable)
|
||||
}
|
||||
```
|
||||
|
||||
Przykładowy zapis instancji obiektu `NoteMessage` wygląda następująco:
|
||||
|
||||
```json
|
||||
{
|
||||
"senderID": "User2",
|
||||
"content": "Lorem Ipsum Test"
|
||||
}
|
||||
```
|
||||
|
||||
Po tym jak serwer odbierze wysłaną wiadomość, wywoływana jest metoda `session`, która w argumentach przekazuje zakodowane dane, sesję serwera oraz identyfikator użytkownika, który wysłał załączone dane. Po udanym zdekodowaniu danych, wybieramy wszystkich użytkowników, którzy dołączyli do sesji edycji notatki i wysyłamy do nich kopię otrzymanej wiadomości, a serwer dodatkowo wysyła identyczną kopię do warstwy prezentacji.
|
||||
|
||||
```swift
|
||||
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
|
||||
guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return }
|
||||
|
||||
let otherPeers = session.connectedPeers.filter { $0 != peerID }
|
||||
if !otherPeers.isEmpty {
|
||||
try? session.send(data, toPeers: otherPeers, with: .reliable)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.noteChangesEmitter.send(message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
== Algorytm rozwiązywania konfliktów
|
||||
|
||||
Rozwiązywanie konfliktów jest dokonywane na podstawie strategii Last Writer Wins, gdzie ostatnio otrzymana wiadomość nadpisuje zawartość pozostałych kopii. W przypadku nanoszenia zmian na interfejs użytkownika, wykorzystałem natywną obsługę zmiany tekstu przez systemowy kompoent `TextEditor`, który w większości wypadków potrafi sobie poradzić czy prostych podmianach zawartości tekstu.
|
||||
|
||||
Zrezygnowałem z implementacji struktur danych CRDT ze względu na bardzo wysoki koszt implementacji, ponieważ oprócz samych struktur, musiałbym napisać własną implementację obsługi podmiany tekstu i aktualizacji kursora w edytorze tekstowym, co znacznie wykracza poza zakres tej pracy.
|
||||
|
||||
== Środowisko developerskie i stack technologiczny
|
||||
|
||||
Uruchomienie projektu wymaga posiadania komputera Macintosh z systemem operacyjnym macOS w wersji 26.0 (Tahoe) oraz środowisko programistyczne Xcode w wersji 26.0. Do uruchomienia aplikacji potrzebne jest założenie konta Apple ID i zaakceptowania warunków użytkowania konta deweloperskiego, dzięki któremu można wygenerować certyfikat, którego potrzebuje Xcode do zbudowania aplikacji.
|
||||
|
||||
Aplikacja wykorzystuje framework SwiftUI do budowania warstwy prezentacji. Jest to deklaratywny, wieloplatformowy framework między innymi dostarczający mechanizmy, które zostały wykorzystane w ramach tej pracy:
|
||||
- automatycznego odświeżania interfejsu na podstawie nasłuchiwania na zmiany modelów danych
|
||||
- automatycznego zapisu prymitywnych danych na dysku
|
||||
- wstrzykiwania zależności wgłąb hierarchii interfejsu
|
||||
- zaawansowanych wzorców stosowanych w tworzeniu dobrych doświadczeń użytkownika - np. wysuwalne panele (bottom sheets).
|
||||
|
||||
Kluczowym frameworkiem w budowaniu tej aplikacji jest Multipeer Connectivity, który zapewniał ułatwioną konfigurację całej komunikacji między pobliskimi użytkownikami. Pomocniczymi bibliotekami są `Foundation` oraz `Combine`.
|
||||
|
||||
`Foundation` daje dostęp do złożonych, ale bardzo często wykorzystywanych typów i funkcji w Swifcie - `URL`, `FileManager`, `JSONDecoder`, `JSONEncoder`.
|
||||
|
||||
`Combine` jest biblioteką dodającą programowanie reaktywne do Swifta, ale również jako pierwsza zapewniła mechanizmy komunikowania zmian w modelach danych do widoków implementowanych w SwiftUI. Moja praca wykorzystywała przede wszystkim obiekty `PassthroughSubjects`, które służyły za obiekty emitujące każde nowo otrzymane dane od innych użytkowników.
|
||||
|
||||
== Interfejs użytkownika
|
||||
|
||||
|
||||
== Napotkane wyzwania implementacyjne i rozwiązania
|
||||
== Ograniczenia środowisk iOS/macOS
|
||||
@@ -0,0 +1,294 @@
|
||||
#import "../style.typ"
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Testowanie i weryfikacja <testy>
|
||||
|
||||
W celu zapewnienia poprawności działania zaimplementowanej aplikacji oraz weryfikacji spełnienia przyjętych wymagań funkcjonalnych i niefunkcjonalnych, przeprowadziłem testy obejmujące warstwę logiki biznesowej, kodowania danych oraz interakcje między urządzeniami. Poniższy rozdział opisuje przyjętą metodologię testowania, zaimplementowane testy jednostkowe, scenariusze testowe przeprowadzone w środowisku rzeczywistym oraz analizę wydajności i zużycia zasobów systemowych.
|
||||
|
||||
== Metodologia testowania
|
||||
|
||||
1. Testy jednostkowe - weryfikacja izolowanych komponentów logiki biznesowej z wykorzystaniem zastępczych implementacji zależności. Do ich implementacji wykorzystano framework Swift Testing, dostępny od wersji Xcode 16 oraz systemów operacyjnych z rodziny iOS/iPadOS 18. Framework ten oferuje nowoczesną składnię opartą na atrybutach, wbudowane wsparcie dla testów parametrycznych.
|
||||
|
||||
2. Testy integracyjne i scenariuszowe - ręczna weryfikacja poprawności komunikacji peer-to-peer, odkrywania urządzeń oraz synchronizacji notatek w rzeczywistym środowisku sieciowym (Wi-Fi oraz Bluetooth).
|
||||
|
||||
3. Testy wydajnościowe - pomiar czasów propagacji zmian, zużycia pamięci operacyjnej oraz obciążenia procesora w trakcie działania aplikacji.
|
||||
|
||||
Testy jednostkowe zostały wykonane w izolacji od systemu plików oraz frameworka Multipeer Connectivity poprzez wstrzyknięcie abstrakcji `StorageProvider`. Dzięki temu możliwa była szybka i powtarzalna weryfikacja logiki przechowywania notatek bez konieczności przygotowywania fizycznego katalogu na dysku.
|
||||
|
||||
== Testy jednostkowe
|
||||
|
||||
Podstawowym przetestowanym komponentem jest klasa `NotesStorage`, odpowiedzialna za zarządzanie cyklem życia notatek w lokalnym systemie plików. Do testów przygotowałem zastępczą implementację `InMemoryStorageProvider` (program 4.1), która symuluje zachowanie systemu plików w pamięci operacyjnej. Implementacja ta przechowuje pliki w słowniku, udostępniając zawartość katalogu oraz tworzenia plików zgodnie z protokołem `StorageProvider`.
|
||||
|
||||
#let in_memory_storage = [```swift
|
||||
final class InMemoryStorageProvider: StorageProvider {
|
||||
private(set) var files: [String: Data] = [:]
|
||||
|
||||
func contentsOfDirectory(atPath path: String) throws -> [String] {
|
||||
return files.keys.compactMap { filePath -> String? in
|
||||
guard filePath.hasPrefix(path) else { return nil }
|
||||
let remainder = String(filePath.dropFirst(path.count))
|
||||
let stripped = remainder.hasPrefix("/") ? String(remainder.dropFirst()) : remainder
|
||||
guard !stripped.isEmpty, !stripped.contains("/") else { return nil }
|
||||
return stripped
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createFile(
|
||||
atPath path: String,
|
||||
contents data: Data?,
|
||||
attributes attr: [FileAttributeKey: Any]?
|
||||
) -> Bool {
|
||||
files[path] = data ?? Data()
|
||||
return true
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
in_memory_storage,
|
||||
kind: raw,
|
||||
caption: [Implementacja zastępczego StorageProvider dla testów jednostkowych],
|
||||
)
|
||||
|
||||
Na bazie powyższego kodu zbudowałem pięć przypadków testowych klasy `NotesStorageTests` (program 4.2). Pierwszy z nich weryfikuje, że przy pustym katalogu metoda `loadNotes()` zwraca pustą tablicę. Kolejne dwa testy sprawdzają poprawność tworzenia pliku oraz odczytu istniejącej notatki. Ostatni test sprawdza, czy dwukrotne wywołanie metody tworzącej notatkę o tej samej nazwie generuje dwa odrębne obiekty.
|
||||
|
||||
#let notes_storage_tests_part_1 = [```swift
|
||||
@Suite
|
||||
struct NotesStorageTests {
|
||||
private func makeStorage(root: URL = URL(filePath: "/test")) -> (NotesStorage, InMemoryStorageProvider) {
|
||||
let provider = InMemoryStorageProvider()
|
||||
let storage = NotesStorage(storageProvider: provider, rootDirectory: root)
|
||||
return (storage, provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadNotesReturnsEmptyArrayWhenNoFiles() {
|
||||
let (storage, _) = makeStorage()
|
||||
let notes = storage.loadNotes()
|
||||
#expect(notes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func createNoteCreatesFile() {
|
||||
let root = URL(filePath: "test")
|
||||
let (storage, provider) = makeStorage(root: root)
|
||||
storage.createNote(name: "My note")
|
||||
#expect(!provider.files.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadNotesReturnCreatedNote() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, provider) = makeStorage(root: root)
|
||||
let filePath = root.appendingPathComponent("Note").path
|
||||
provider.createFile(atPath: filePath, contents: Data(), attributes: nil)
|
||||
let notes = storage.loadNotes()
|
||||
#expect(notes.count == 1)
|
||||
#expect(notes.first?.name == "Note")
|
||||
}
|
||||
|
||||
@Test
|
||||
func createNoteDeduplicatesNames() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, provider) = makeStorage(root: root)
|
||||
let existingPath = root.appendingPathComponent("Note").path
|
||||
provider.createFile(atPath: existingPath, contents: Data(), attributes: nil)
|
||||
storage.createNote(name: "Note")
|
||||
#expect(provider.files.count == 2)
|
||||
let paths = provider.files.keys
|
||||
#expect(paths.contains(where: { $0.contains("Note 1") }))
|
||||
}
|
||||
```]
|
||||
|
||||
#let notes_storage_tests_part_2 = [```swift
|
||||
@Test
|
||||
func createNoteTwiceCreatesTwoFiles() {
|
||||
let root = URL(filePath: "/test")
|
||||
let (storage, _) = makeStorage(root: root)
|
||||
storage.createNote(name: "Note")
|
||||
storage.createNote(name: "Note")
|
||||
let notes = storage.loadNotes()
|
||||
#expect(notes.count == 2)
|
||||
}
|
||||
}
|
||||
```]
|
||||
#figure(
|
||||
[#notes_storage_tests_part_1, #notes_storage_tests_part_2],
|
||||
kind: raw,
|
||||
caption: [Testy jednostkowe dla NotesStorage],
|
||||
)
|
||||
|
||||
=== Weryfikacja kodowania danych sieciowych
|
||||
|
||||
Komunikacja między urządzeniami wymaga poprawnej serializacji i deserializacji obiektów domenowych do formatu JSON. W ramach testów jednostkowych zweryfikowano dwie kluczowe struktury: `NoteMessage` oraz `NoteInvitation.NoteContent`.
|
||||
|
||||
`NoteMessageCodableTests` (program 4.3) zawiera trzy przypadki testowe. Pierwszy sprawdza, czy obiekt jest poprawnie kodowany do formatu JSON z zachowaniem oczekiwanych nazw kluczy (`senderID`, `content`). Drugi weryfikuje poprawność dekodowania z ciągu znaków JSON. Trzeci test polega na zakodowaniu obiektu, potem zdekodowaniu i porównaniu go z obiektem oryginalnym.
|
||||
|
||||
#let note_message_codable_tests = [```swift
|
||||
@Suite
|
||||
struct NoteMessageCodableTests {
|
||||
@Test
|
||||
func encodesToJSON() throws {
|
||||
let message = NoteMessage(senderID: "host", content: "Content")
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String])
|
||||
#expect(json["senderID"] == "host")
|
||||
#expect(json["content"] == "Content")
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodesFromJSON() throws {
|
||||
let json = #"{"senderID":"host","content":"contentt"}"#
|
||||
let data = try #require(json.data(using: .utf8))
|
||||
let message = try JSONDecoder().decode(NoteMessage.self, from: data)
|
||||
#expect(message.senderID == "host")
|
||||
#expect(message.content == "contentt")
|
||||
}
|
||||
|
||||
@Test("encode → decode")
|
||||
func coding() throws {
|
||||
let original = NoteMessage(senderID: "host", content: "coding test")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(NoteMessage.self, from: data)
|
||||
#expect(decoded.senderID == original.senderID)
|
||||
#expect(decoded.content == original.content)
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_message_codable_tests,
|
||||
kind: raw,
|
||||
caption: [Testy kodowania i dekodowania NoteMessage],
|
||||
)
|
||||
|
||||
Analogiczny zbiór testów został przygotowany dla struktury `NoteInvitation.NoteContent` (program 4.4), która reprezentuje migawkę notatki przesyłaną w zaproszeniu do sesji.
|
||||
|
||||
#let note_content_codable_tests = [```swift
|
||||
@Suite
|
||||
struct NoteContentCodableTests {
|
||||
@Test("encode → decode")
|
||||
func coding() throws {
|
||||
let original = NoteInvitation.NoteContent(title: "My note", noteSnapshot: "Note Sample Snapshot")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(NoteInvitation.NoteContent.self, from: data)
|
||||
#expect(decoded.title == original.title)
|
||||
#expect(decoded.noteSnapshot == original.noteSnapshot)
|
||||
}
|
||||
|
||||
@Test
|
||||
func containsExpectedKeys() throws {
|
||||
let content = NoteInvitation.NoteContent(title: "Titlee", noteSnapshot: "Snapshott")
|
||||
let data = try JSONEncoder().encode(content)
|
||||
let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String])
|
||||
#expect(json["title"] == "Titlee")
|
||||
#expect(json["noteSnapshot"] == "Snapshott")
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
note_content_codable_tests,
|
||||
kind: raw,
|
||||
caption: [Testy kodowania i dekodowania NoteContent],
|
||||
)
|
||||
|
||||
`NoteInvitationTests` (program 4.5) obejmuje siedem przypadków testowych. Dwa pierwsze weryfikują, czy metody `accept()` oraz `decline()` przekazują odpowiednio wartości logiczne `true` i `false` do handlera. Kolejne dwa testy sprawdzają czy wielokrotne wywołanie tej samej metody nie powoduje powtórnego wywołania handlera. Piąty test gwarantuje, że po zaakceptowaniu zaproszenia próba jego odrzucenia jest ignorowana. Ostatnie dwa testy weryfikują poprawność obliczanych właściwości: `noteName` zwraca tytuł notatki, a `id` jest tożsame z identyfikatorem nadawcy (`MCPeerID`).
|
||||
|
||||
#let note_invitation_tests_part_1 = [```swift
|
||||
@Suite
|
||||
struct NoteInvitationTests {
|
||||
@Test
|
||||
func acceptCallsHandlerWithTrue() {
|
||||
var received: Bool? = nil
|
||||
var invitation = NoteInvitation { received = $0 }
|
||||
invitation.accept()
|
||||
#expect(received == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func declineCallsHandlerWithFalse() {
|
||||
var received: Bool? = nil
|
||||
var invitation = NoteInvitation { received = $0 }
|
||||
invitation.decline()
|
||||
#expect(received == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptIsIdempotent() {
|
||||
var callCount = 0
|
||||
var invitation = NoteInvitation { _ in callCount += 1 }
|
||||
invitation.accept()
|
||||
invitation.accept()
|
||||
#expect(callCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func declineIsIdempotent() {
|
||||
var callCount = 0
|
||||
var invitation = NoteInvitation { _ in callCount += 1 }
|
||||
invitation.decline()
|
||||
invitation.decline()
|
||||
#expect(callCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func declineAfterAcceptIsNoOp() {
|
||||
var callCount = 0
|
||||
var invitation = NoteInvitation { _ in callCount += 1 }
|
||||
invitation.accept()
|
||||
invitation.decline()
|
||||
#expect(callCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func noteNameReturnsTitle() {
|
||||
let invitation = NoteInvitation { _ in }
|
||||
#expect(invitation.noteName == "Shared note")
|
||||
}
|
||||
```]
|
||||
#let note_invitation_tests_part_2 = [```swift
|
||||
@Test
|
||||
func idIsInvitatorPeerID() {
|
||||
let peerID = MCPeerID(displayName: "host")
|
||||
let invitation = NoteInvitation(
|
||||
invitatorID: peerID,
|
||||
note: .init(title: "T", noteSnapshot: "S")
|
||||
)
|
||||
#expect(invitation.id == peerID)
|
||||
}
|
||||
}
|
||||
```]
|
||||
|
||||
#figure(
|
||||
[#note_invitation_tests_part_1, #note_invitation_tests_part_2],
|
||||
kind: raw,
|
||||
caption: [Testy jednostkowe NoteInvitation],
|
||||
)
|
||||
|
||||
== Scenariusze testowe
|
||||
|
||||
Ze względu na złożoność oraz niedeterministyczny charakter frameworka Multipeer Connectivity, część funkcjonalności aplikacji wymagała ręcznej weryfikacji w rzeczywistym środowisku. Testy scenariuszowe przeprowadzono na dwóch fizycznych urządzeniach: iPhone 16 Pro (iOS 26) oraz iPad Air 2020 (iPadOS 26), połączonych wspólną siecią Wi-Fi oraz z włączonym modułem Bluetooth. Odległość między urządzeniami wynosiła około 2 metry.
|
||||
|
||||
*Scenariusz 1: Odkrywanie urządzeń w sieci lokalnej.*
|
||||
|
||||
Celem była weryfikacja wymagania funkcjonalnego dotyczącego rozgłaszania obecności oraz wykrywania innych klientów. Użytkownik A (serwer) otworzył ekran edycji notatki, natomiast użytkownik B (klient) pozostawał na ekranie głównym. Oczekiwanym rezultatem było pojawienie się identyfikatora użytkownika B na liście dostępnych klientów w ekranie zarządzania członkami. Test zakończył się sukcesem - urządzenie B zostało wykryte w ciągu mniej niż 2 sekund.
|
||||
|
||||
*Scenariusz 2: Wysyłanie i akceptacja zaproszenia.*
|
||||
|
||||
Użytkownik A wysłał zaproszenie do edycji notatki użytkownikowi B. Na urządzeniu B pojawiło się powiadomienie o zaproszeniu z prawidłowym tytułem notatki. Po akceptacji notatka pojawiła się w sekcji "External notes" na urządzeniu B, a użytkownik A otrzymał informację o dołączeniu klienta do sesji poprzez zmianę status obok nazwy na "Joined".
|
||||
|
||||
*Scenariusz 3: Synchronizacja treści notatki w czasie rzeczywistym.*
|
||||
|
||||
Użytkownik A dokonywał zmian w treści notatki, podczas gdy użytkownik B miał otwartą tę samą notatkę w trybie edycji. Zmiany wprowadzane przez użytkownika A pojawiały się na urządzeniu użytkownika B z opóźnieniem nieprzekraczającym 1s.
|
||||
|
||||
== Analiza wydajności i zużycia zasobów
|
||||
|
||||
Weryfikacja wymagań niefunkcjonalnych została uzupełniona o pomiary wydajnościowe przeprowadzone przy użyciu narzędzi Instruments dostarczanych wraz z Xcode.
|
||||
|
||||
Średnie zużycie pamięci RAM przy uruchomionej aplikacji, bez aktywnej sesji edycji, wynosiło 20 MB. Podczas aktywnej sesji peer-to-peer z dwoma połączonymi klientami zużycie wzrastało do 30 MB. Wzrost ten jest spowodowany głównie utrzymywaniem obiektów związanych z sesją `MCSession` oraz obsługą komponentu edycji tekstu `TextEditor`. Nie zaobserwowano wycieków pamięci w trakcie użytkowania.
|
||||
|
||||
Obciążenie procesora w stanie spoczynku wynosiło 0-1%, gdzie przedział maksymalny wynosi 0-600%, ze względu na obecność 6 rdzeni, których zużycie, w zakresie 0-100%, się sumuje. Podczas intensywnej edycji tekstu z jednoczesną synchronizacją obciążenie wzrastało do 30-40% na urządzeniu iPhone 16 Pro. Głównym źródłem obciążenia była obsługa edycji tekstu oraz wysyłanie danych przez framework Multipeer Connectivity.
|
||||
|
||||
Podsumowując - przeprowadzone testy jednostkowe, scenariuszowe oraz wydajnościowe potwierdzają, że zaimplementowana aplikacja spełnia przyjęte wymagania funkcjonalne i niefunkcjonalne. Architektura oparta na protokole `StorageProvider` umożliwiła efektywne testowanie logiki przechowywania, natomiast manualne scenariusze wykazały stabilność komunikacji P2P w typowych warunkach użytkowania.
|
||||
@@ -0,0 +1,30 @@
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Podsumowanie i kierunki rozwoju <podsumowanie>
|
||||
W tym rozdziale opiszę i ocenię zakres wymagań jaki udało się spełnić w ramach opisanej implementacji, a na końcu wymienię propozycje usprawnień implementacji. Propozycje składają się ze zmian zbyt złożonych bym mógł je podjąć przed zakończeniem tej pracy inżynierskiej.
|
||||
|
||||
== Osiągnięte cele
|
||||
|
||||
Przygotowana implementacja spełnia wszystkie podstawowe wymagania jakie zostały nakreślone w tej pracy - została stworzona aplikacja mobilna na systemy operacyjne iOS i iPadOS, która, wykorzystując wbudowany framework Multipeer Connectivity, umożliwia współtworzenie notatek wśród użytkowników, którzy znajdują się w zasięgu określonych sieci bezprzewodowych. Istnieje możliwość zapraszania pobliskich użytkowników, dołączania do innych sesji, podstawowej edycja tekstu, trwałego przechowywania własnych notatek w pamięci urządzeniach. Do osiągnięcia tego nie potrzeba centralnego serwera obsługującego ruch klientów, ponieważ klienci między sobą negocjowali nanoszenie zmian. Dzięki braku potrzeby centralnego serwera, nie ma też konieczności, aby urządzenia klienckie miały dostęp do Internetu. Do współpracy wystarczy działający moduł Bluetooth lub Wi-Fi. Synchronizacja znajduje się w założonym limicie czasowym, bez zaobserwowanych większych niż założone opóźnień. Finalnie zostały przeprowadzone testy jednostkowe jak i manualne, które zweryfikowały poprawność implementacji.
|
||||
|
||||
== Możliwości dalszej rozbudowy
|
||||
|
||||
O ile przedstawiony program spełnia określone wymagania, tak istnieje duże pole do znacznych optymalizacji skutkujących lepszymi doświadczeniami użytkownika, stabilnością systemu, większą niezależnością od konkretnego systemu operacyjnego.
|
||||
|
||||
Największą zmianą jest przejście z frameworka Multipeer Connectivity na Network. W tym wypadku będzie trzeba zaimplementować własny protokół oparty na TCP, ale taka implementacja będzie mogła być użyta również na innych platformach, nie tylko tych, które udostępnia firma Apple. Otrzymamy dzięki tej zmianie również większą stabilność, ponieważ Network nie wykonuje tak dużo zadań jak Multipeer Connectivity, które ukrywa duże ilości problemów za niewidoczną dla programisty abstrakcją. Do tego Network otrzymuje znacznie częściej aktualizacje oraz jest lepiej zintegrowany z językiem programowania Swift.
|
||||
|
||||
Kolejną dużą zmianą jest zastosowanie innego komponentu do edycji tekstu niż `TextEditor` dostępny w frameworku SwiftUI. Lepszym wyborem byłby `UITextView` dostępny w frameworku UIKit, który daje programiście znacznie większą kontrolę nad zachowaniami - zaznaczaniem, wstawianiem, usuwaniem tekstu. Dzięki temu można osiągnąć znacznie stabilniejsze zachowanie kursora w trakcie równoczesnej edycji tej samej notatki przez wielu użytkowników na raz, które byłoby zbliżone do aktualnie popularnych rozwiązań zcentralizowanych.
|
||||
|
||||
Ostatnią propozycją większej zmiany jest zastosowanie bezkonfliktowych struktur danych (CRDT), które pozwoliłyby na wysyłanie znacznie mniejszej ilości danych między użytkownikami w tej samej sesji. Pozwoliłoby to na znacznie wydajniejszą pod kątem zużycia zasobów komunikację oraz szybszą w przypadku bardzo dużych notatek.
|
||||
|
||||
Mniejszymi elementami możliwymi do rozbudowy jest lepsza obsługa błędów - często się zdarza że klient, który utracił połączenie jest traktowany tak samo jak klient który odrzucił zaproszenie do edycji notatki. Dodatkowo aktualnie użytkownik nie jest w stanie usunąć zaproszonego do edycji użytkownika. Kolejną wartościową funkcjonalnością byłaby możliwość wyświetlania jako klient innych użytkowników notatki. Ostatnią z funkcjonalności, choć niemałą, byłoby dodanie możliwości formatowania tekstu oraz umieszczenia zawartości innej niż tekst.
|
||||
|
||||
== Wykorzystanie sztucznej inteligencji
|
||||
|
||||
W niniejszej pracy inżynierskiej były wykorzystywane modele językowe - Claude Sonnet 4.5 i 4.6, Gemini 3 Pro - do następujących zadań:
|
||||
|
||||
- wsparcie w rozwiązywaniu błędów implementacyjnych, przykładowym problemem był błąd, gdzie czasem wiadomości między użytkownikami mogły się nie synchronizować mimo obecności w sesji edycji notatek,
|
||||
- stworzenie wzorca generującego plik PDF podobny do przygotowanego przez uczelnię w LaTex, ale dla języka Typst,
|
||||
- wykrywanie literówek, niespójności, zbędnych powtórzeń w treści pracy.
|
||||
|
||||
Podstawowa implementacja oraz zakres rozdziału teoretycznego zostały opracowane w pełni bez udziału modeli językowych.
|
||||
@@ -1,6 +0,0 @@
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Testowanie i weryfikacja <testy>
|
||||
== Metodologia testowania
|
||||
== Scenariusze testowe
|
||||
== Analiza wydajności i zużycia zasobów
|
||||
@@ -1,6 +0,0 @@
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
= Podsumowanie i kierunki rozwoju <podsumowanie>
|
||||
== Osiągnięte cele
|
||||
== Ocena spełnienia wymagań
|
||||
== Możliwości dalszej rozbudowy
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
LFS
BIN
Binary file not shown.
+16
-11
@@ -1,4 +1,4 @@
|
||||
#import "style.typ": definition, example, theorem, zut-template
|
||||
#import "style.typ": abstract, definition, example, list-of-figures, theorem, zut-template
|
||||
#import "Bibliography.typ": bib-setup, render-bib
|
||||
|
||||
#set text(lang: "pl")
|
||||
@@ -10,13 +10,18 @@
|
||||
#include "table_of_contents.typ"
|
||||
#pagebreak()
|
||||
|
||||
#zut-template([
|
||||
#include "Chapters/0. Introduction.typ"
|
||||
#include "Chapters/1. Theoretical Scope.typ"
|
||||
#include "Chapters/2. Requirements.typ"
|
||||
#include "Chapters/3. Implementation.typ"
|
||||
#include "Chapters/4. Tests.typ"
|
||||
#include "Chapters/5. Summary.typ"
|
||||
])
|
||||
|
||||
#render-bib()
|
||||
#include "Abstract.typ"
|
||||
#pagebreak(to: "odd")
|
||||
#zut-template(
|
||||
[
|
||||
#include "Chapters/0. Introduction.typ"
|
||||
#include "Chapters/1. Theoretical Scope.typ"
|
||||
#include "Chapters/2. Implementation.typ"
|
||||
#include "Chapters/3. Tests.typ"
|
||||
#include "Chapters/4. Summary.typ"
|
||||
#render-bib()
|
||||
#pagebreak()
|
||||
#list-of-figures()
|
||||
],
|
||||
shorttitle: "Aplikacja do współtworzenia notatek z wykorzystaniem technologii peer-to-peer",
|
||||
)
|
||||
|
||||
+213
-51
@@ -79,7 +79,7 @@
|
||||
margin: (
|
||||
top: 3.2cm,
|
||||
bottom: 3.5cm,
|
||||
inside: 3.5cm, // 2.5cm tekst + 1cm bindingoffset (rozwiązuje problem marginesu z lewej z Twojego kodu)
|
||||
inside: 3.5cm, // 2.5cm tekst + 1cm
|
||||
outside: 2.5cm,
|
||||
),
|
||||
|
||||
@@ -88,11 +88,22 @@
|
||||
let pg = counter(page).get().first()
|
||||
let is-odd = calc.odd(pg)
|
||||
|
||||
// Czy ta strona otwiera rozdział? (→ plain style: brak nagłówka)
|
||||
// is chapter's first page?
|
||||
let chapter-locs = query(heading.where(level: 1))
|
||||
let is-chapter-page = chapter-locs.any(h => counter(page).at(h.location()).first() == pg)
|
||||
|
||||
if not is-chapter-page {
|
||||
// Detect blank pages inserted by pagebreak(to: "odd").
|
||||
// The marker is placed before the pagebreak, so it lands on the last page
|
||||
// of the previous chapter (pg - 1 relative to the blank even page).
|
||||
let chapter-break-markers = query(<chapter-break-marker>)
|
||||
let is-blank-page = (
|
||||
not is-odd
|
||||
and chapter-break-markers.any(
|
||||
m => counter(page).at(m.location()).first() == pg - 1,
|
||||
)
|
||||
)
|
||||
|
||||
if chapter-locs.len() == 0 {} else if not is-chapter-page and not is-blank-page {
|
||||
// Tytuł bieżącego rozdziału do nagłówka
|
||||
let prev = query(heading.where(level: 1).before(here()))
|
||||
let ch-label = if prev.len() > 0 {
|
||||
@@ -143,9 +154,8 @@
|
||||
let chapter-locs = query(heading.where(level: 1))
|
||||
let is-chapter-page = chapter-locs.any(h => counter(page).at(h.location()).first() == pg)
|
||||
if is-chapter-page {
|
||||
set text(font: "Fira Sans", size: 10pt)
|
||||
align(right, box(fill: blue-zut, inset: (x: 7pt, y: 4pt))[
|
||||
#text(fill: white, weight: "bold")[#pg]
|
||||
#text(font: "Fira Sans", size: 10pt, fill: white, weight: "bold")[#pg]
|
||||
])
|
||||
}
|
||||
},
|
||||
@@ -169,13 +179,16 @@
|
||||
set heading(numbering: "1.1")
|
||||
|
||||
show heading: it => {
|
||||
let num = if it.numbering != none {
|
||||
let num = if it.numbering != none and it.outlined {
|
||||
counter(heading).display(it.numbering)
|
||||
}
|
||||
|
||||
if it.level == 1 {
|
||||
// Wymuszenie nowej strony dla głównego rozdziału
|
||||
pagebreak(weak: true)
|
||||
// Marker placed before the break so it appears on the last page of the
|
||||
// previous chapter; used by the header to detect blank even pages.
|
||||
[#metadata("chapter-break") <chapter-break-marker>]
|
||||
pagebreak(to: "odd", weak: true)
|
||||
v(1.5em, weak: true)
|
||||
|
||||
block(width: 100%, inset: (bottom: 1em, top: 7em))[
|
||||
@@ -208,35 +221,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 3.5 SPIS TREŚCI ─────────────────────────────────────
|
||||
set outline(indent: auto)
|
||||
|
||||
show outline.entry: it => {
|
||||
if it.level == 1 {
|
||||
v(12pt, weak: true)
|
||||
text(size: 11pt, weight: "bold", font: "Fira Sans", fill: blue-zut)[
|
||||
#h(1.25cm)
|
||||
#it
|
||||
]
|
||||
} else if it.level == 2 {
|
||||
v(3pt, weak: true)
|
||||
text(size: 10pt, font: "Fira Sans")[
|
||||
#h(1.25cm)
|
||||
#it
|
||||
]
|
||||
} else if it.level == 3 {
|
||||
v(1pt, weak: true)
|
||||
text(size: 9pt, font: "Fira Sans")[
|
||||
#h(2.5cm)
|
||||
#it
|
||||
]
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 3.6 BLOKI KODU ──────────────────────────────────────
|
||||
show raw.where(block: true): it => {
|
||||
block(
|
||||
@@ -253,31 +237,78 @@
|
||||
]
|
||||
}
|
||||
|
||||
show raw.where(block: false): it => {
|
||||
box(
|
||||
fill: gray-zut.lighten(93%),
|
||||
inset: (x: 3pt, y: 1pt),
|
||||
radius: 2pt,
|
||||
set raw(theme: "zut.tmTheme")
|
||||
show raw.where(block: true): it => {
|
||||
let lines = it.lines
|
||||
|
||||
block(
|
||||
fill: gray-zut.lighten(100%),
|
||||
radius: 4pt,
|
||||
clip: true,
|
||||
width: 100%,
|
||||
)[
|
||||
#set text(size: 10pt, font: ("Fira Mono", "Courier New"))
|
||||
#it
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
inset: (left: 10pt, right: 10pt, top: 2pt, bottom: 2pt),
|
||||
align: (x, y) => if x == 0 { right + top } else { left + top },
|
||||
|
||||
grid.vline(x: 1, stroke: (paint: luma(160), thickness: 1pt)),
|
||||
|
||||
..lines
|
||||
.map(line => (
|
||||
text(fill: luma(160), size: 9pt)[#line.number],
|
||||
line.body,
|
||||
))
|
||||
.flatten(),
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
// ── 3.7 PODPISY RYSUNKÓW I TABEL ────────────────────────
|
||||
set figure(gap: 0.8em)
|
||||
let raw-fig-counter = counter("raw-fig")
|
||||
let img-fig-counter = counter("img-fig")
|
||||
|
||||
show figure.caption: it => {
|
||||
set text(size: 9.5pt, font: "Fira Sans")
|
||||
let kind-label = if it.kind == image { [Rysunek] } else if it.kind == table { [Tabela] } else { [Algorytm] }
|
||||
[
|
||||
#text(fill: blue-zut, weight: "bold")[
|
||||
#kind-label #it.counter.display()
|
||||
]#it.separator#it.body
|
||||
]
|
||||
show heading.where(level: 1): it => {
|
||||
raw-fig-counter.update(1)
|
||||
img-fig-counter.update(1)
|
||||
it
|
||||
}
|
||||
|
||||
show figure.where(kind: raw): it => {
|
||||
// Step the custom counter
|
||||
raw-fig-counter.step()
|
||||
|
||||
// Build the number: heading-index.figure-index
|
||||
let heading-num = counter(heading.where(level: 1)).display()
|
||||
let fig-num = raw-fig-counter.display("1")
|
||||
let full-number = heading-num + "." + fig-num
|
||||
|
||||
it.body
|
||||
v(10pt, weak: true)
|
||||
align(center, block(width: auto)[
|
||||
#text(weight: "bold", fill: blue-zut, font: "Fira Sans")[Program #full-number:]
|
||||
#text(font: "Fira Sans")[#it.caption.body]
|
||||
])
|
||||
}
|
||||
|
||||
show figure.where(kind: image): it => {
|
||||
// Step the custom counter
|
||||
img-fig-counter.step()
|
||||
|
||||
// Build the number: heading-index.figure-index
|
||||
let heading-num = counter(heading.where(level: 1)).display()
|
||||
let fig-num = img-fig-counter.display("1")
|
||||
let full-number = heading-num + "." + fig-num
|
||||
|
||||
it.body
|
||||
v(10pt, weak: true)
|
||||
align(center, block(width: auto)[
|
||||
#text(weight: "bold", fill: blue-zut, font: "Fira Sans")[Rysunek #full-number:]
|
||||
#text(font: "Fira Sans")[#it.caption.body]
|
||||
])
|
||||
}
|
||||
|
||||
// ── 3.8 TABELE ──────────────────────────────────────────
|
||||
set table(
|
||||
@@ -290,3 +321,134 @@
|
||||
|
||||
body
|
||||
} // /zut-template
|
||||
|
||||
/// Spis rysunków – wywołaj jako ostatni element wewnątrz zut-template.
|
||||
///
|
||||
/// Wyszukuje wszystkie figure(kind: image) w dokumencie i odtwarza
|
||||
/// numery X.Y identyczne z tymi generowanymi przez show figure.where(kind: image)
|
||||
/// w zut-template.
|
||||
#let list-of-figures() = {
|
||||
[
|
||||
#set heading(numbering: none)
|
||||
#show heading: it => {
|
||||
v(1em)
|
||||
|
||||
block(width: 100%, inset: (bottom: 3em, top: 7em, left: 2em))[
|
||||
#align(left)[
|
||||
#text(size: 25pt, fill: blue-zut, weight: "regular", font: "Fira Sans", it.body)
|
||||
]
|
||||
]
|
||||
}
|
||||
#heading(level: 1, outlined: false)[Spis rysunków]
|
||||
]
|
||||
|
||||
context {
|
||||
let all-figs = query(figure.where(kind: image))
|
||||
|
||||
let local = 1
|
||||
let prev-ch = -1
|
||||
|
||||
for fig in all-figs {
|
||||
let ch = counter(heading.where(level: 1)).at(fig.location()).first()
|
||||
|
||||
if ch != prev-ch {
|
||||
local = 0
|
||||
prev-ch = ch
|
||||
}
|
||||
local = local + 1
|
||||
|
||||
let full-number = str(ch) + "." + str(local)
|
||||
let pg = counter(page).at(fig.location()).first()
|
||||
let cap = fig.caption.body
|
||||
|
||||
v(8pt, weak: true)
|
||||
link(fig.location())[
|
||||
#grid(
|
||||
columns: (3em, 1fr, auto),
|
||||
align: (left, left, right),
|
||||
text(
|
||||
size: 11pt,
|
||||
weight: "regular",
|
||||
font: "Fira Sans",
|
||||
)[#full-number],
|
||||
// Podpis + wypełnienie kropkami
|
||||
[
|
||||
#text(size: 11pt, font: "Fira Sans")[#cap]
|
||||
#box(width: 1fr, inset: (x: 0pt))[
|
||||
#text[#repeat[ . ]]
|
||||
]
|
||||
],
|
||||
// Numer strony
|
||||
text(
|
||||
size: 10pt,
|
||||
weight: "regular",
|
||||
font: "Fira Sans",
|
||||
)[#pg],
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Funkcja rysująca abstrakt – polska wersja u góry, angielska na dole, jedna strona A4.
|
||||
///
|
||||
/// Parametry:
|
||||
/// pl-title – nagłówek sekcji polskiej (domyślnie "Streszczenie")
|
||||
/// pl-body – treść streszczenia po polsku
|
||||
/// pl-keywords – słowa kluczowe (pojedynczy ciąg, np. "A, B, C")
|
||||
/// en-title – nagłówek sekcji angielskiej (domyślnie "Abstract")
|
||||
/// en-body – treść abstraktu po angielsku
|
||||
/// en-keywords – keywords (pojedynczy ciąg, np. "A, B, C")
|
||||
#let abstract(
|
||||
pl-title: "Streszczenie",
|
||||
pl-body: str,
|
||||
pl-keywords: "",
|
||||
en-title: "Abstract",
|
||||
en-body: str,
|
||||
en-keywords: "",
|
||||
) = {
|
||||
set page(
|
||||
paper: "a4",
|
||||
margin: (
|
||||
top: 3.2cm,
|
||||
bottom: 3.5cm,
|
||||
inside: 3.5cm,
|
||||
outside: 2.5cm,
|
||||
),
|
||||
header: none,
|
||||
footer: none,
|
||||
numbering: none,
|
||||
)
|
||||
|
||||
set text(font: "New Computer Modern", size: 12pt, lang: "pl")
|
||||
set par(justify: true, leading: 0.65em)
|
||||
set heading(numbering: none, outlined: false)
|
||||
|
||||
show heading: it => {
|
||||
if it.level == 1 {
|
||||
align(center)[
|
||||
#text(size: 17pt, fill: blue-zut, weight: "regular", font: "Fira Sans", it.body)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// polish version of the abstract
|
||||
v(0.7cm)
|
||||
[= #pl-title]
|
||||
v(0.5cm)
|
||||
par(pl-body, first-line-indent: 2em)
|
||||
if pl-keywords != "" {
|
||||
[*słowa kluczowe:* #pl-keywords]
|
||||
}
|
||||
|
||||
v(1fr)
|
||||
|
||||
// english version
|
||||
[= #en-title]
|
||||
set text(lang: "en")
|
||||
v(0.5cm)
|
||||
par(en-body)
|
||||
if en-keywords != "" {
|
||||
[*keywords:* #en-keywords]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
#set heading(numbering: "1.1")
|
||||
#set text(font: "Fira Sans")
|
||||
#set outline(title: text(size: 24pt, fill: color-dark-blue, weight: "regular")[Spis treści])
|
||||
#set outline(title: block(text(size: 24pt, fill: color-dark-blue, weight: "regular")[Spis treści], inset: (
|
||||
"bottom": 1.5em,
|
||||
)))
|
||||
|
||||
#show outline.entry: it => {
|
||||
let is-lvl-1 = it.level == 1
|
||||
@@ -27,9 +29,13 @@
|
||||
text(size: 12pt, fill: black, title)
|
||||
}
|
||||
|
||||
let page-style = text(size: 10pt, fill: color-page-num, weight: "medium", page-num)
|
||||
let page-style = if is-lvl-1 {
|
||||
text(size: 10pt, fill: color-page-num, weight: "medium", page-num)
|
||||
} else {
|
||||
text(size: 10pt, fill: black, weight: "medium", page-num)
|
||||
}
|
||||
|
||||
v(if is-lvl-1 { 14pt } else { 6pt }, weak: true)
|
||||
v(if is-lvl-1 { 18pt } else { 10pt }, weak: true)
|
||||
|
||||
link(it.element.location())[
|
||||
#grid(
|
||||
|
||||
@@ -76,4 +76,3 @@
|
||||
// Miasto i rok
|
||||
#move(text(size: 14.2pt)[#placesubmit, #yearsubmit], dx: 8pt, dy: -10pt)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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>name</key>
|
||||
<string>ZUT</string>
|
||||
<key>settings</key>
|
||||
<array>
|
||||
|
||||
<dict>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key><string>#000000</string>
|
||||
<key>background</key><string>#F0F0F0</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>name</key><string>Keyword</string>
|
||||
<key>scope</key>
|
||||
<string>keyword, storage.type, storage.modifier, keyword.control</string>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key><string>#800080</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>name</key><string>String</string>
|
||||
<key>scope</key><string>string</string>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key><string>#008000</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>name</key><string>Comment</string>
|
||||
<key>scope</key><string>comment</string>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key><string>#808080</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>name</key><string>Number</string>
|
||||
<key>scope</key><string>constant.numeric</string>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key><string>#0000CD</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user